qwen-code Web Shell 会话与 GitHub PR 绑定:从 GitDialog 回写到存量回填的完整链路解析
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
导读:本文围绕 qwen-code 项目中「Web Shell 会话 ↔ GitHub PR 号」双向绑定能力展开。当终端里同时运行 20+ 会话时,侧栏信息往往回答不了"哪个会话对应 PR #N"——本设计通过数据模型、写入端、sidecar 持久化、侧栏展示与搜索、存量回填、状态定时刷新六层机制打通该链路。读完本文,你将掌握 PR 绑定在 SDK、daemon、bridge、core、web-shell 各层的落点,理解 shell 工具 post-hook 如何以「执行闸门 + gh 归因」的方式实时识别
gh pr create,以及/review指令与 worktree 约定如何作为零网络来源回填存量会话。
一、问题背景:会话侧栏的检索链路为何全断
Web Shell 同时运行 20+ 会话时,侧栏信息不足以回答「哪个会话对应 PR #N」。设计文档 2026-08-20-webshell-session-pr-binding.md 将当时的链路断裂总结为四个环节:
- 创建端不回写:
GitDialog.doCreatePr创建 PR 拿到{url, number}后只显示状态消息,不回写会话元数据(对应 GitDialog.tsx 的 PR 创建逻辑)。 - 数据模型缺失:
DaemonSessionSummary/BridgeSessionSummary无 PR 字段,updateSessionMetadata只放行displayName。 - 搜索不覆盖:侧栏搜索只匹配标题和 sessionId,不匹配分支名、worktree slug、PR 号。
- 无持久化载体:即使内存中做了绑定,daemon 重启后也会丢失。
方案的整体目标是:让「会话 → PR」和「PR → 会话」两个方向都可检索、可展示、可持久化,且绑定来源可被可靠归因(防止文本伪造)。
二、数据模型:多 PR 列表与四层校验
2.1prs数组结构
DaemonSessionSummary与BridgeSessionSummary(镜像,需同步)增加prs字段:
"prs": [{ "number": 9517, "url": "https://github.com/owner/repo/pull/9517", "state": "open" }]约束要点:
- 多 PR 支持:一个会话可能创建多个 PR(stacked PR、连续修复),
prs按绑定时间排序(最后一个 = 最新),上限10个(超出丢弃最旧)。同号重复绑定刷新 url 并移到最新位。 state:可选快照,取值open/merged/closed。写入端(GitDialog)记open,回填记 gh 查询时刻的值,刷新定时器负责 open→merged/closed 迁移,badge 对 merged 弱化显示。number:正整数;url:http(s) URL。badge/tooltip 直接作为链接目标渲染,拒绝javascript:等 scheme——route、bridge、SDK 校验器、sidecar 校验四层统一要求。- 字段可选、可缺省;不提供「清除」语义。
- 写入 API 保持单条:
updateSessionMetadata(sessionId, { pr: {number, url} })每次绑定一个,daemon 负责 upsert 进列表;读取/事件/响应均为完整prs数组。
2.2 core 侧的实现印证
core 新增服务 packages/core/src/services/session-pr-service.ts 定义了完整的类型与边界:
export interface SessionPr { number: number; url: string; createdAt: string; state?: SessionPrState; // 'open' | 'merged' | 'closed' source?: SessionPrSource; // 'create' | 'worktree' | 'review' issues?: SessionPrIssue[]; // 定时刷新快照的关联 issue,客户端永不绑定 }- 上限常量:
SESSION_PR_LIST_LIMIT = 10,URL 长度上限SESSION_PR_URL_MAX_LENGTH = 2048(为 enterprise host 与长路径留余量)。 - URL 形状校验 isValidSessionPrUrl:
≤2048、/^https?:\/\//i、无控制字符。控制字符校验的原因是 bridge 会把 URL 插值进 stderr audit 行,控制字符可伪造日志行。读侧对整份列表 fail-closed——写入一条毒 URL 会抹掉全部绑定并每轮再毒,因此写边界(replaceSessionPrs、upsertSessionPr)拒绝读侧会拒绝的条目。 source是绑定 provenance(来源证明),按权威度排序决定 tail-10 裁剪时谁先被挤出:worktree(会话"为该 PR 存在",权威最高)>create(gh 验证过的创建)> 无 provenance 的旧条目 >review(只是 review 过)。实现见 sessionPrSourceAuthority 与 capSessionPrListByAuthority。
三、写入端:三条绑定时机
3.1 GitDialog 创建 PR 成功时回写(用户主力流程)
GitDialog.doCreatePr成功后,仅用 dialog 已有的sessionId(sessionIdRef.current,即连接会话或 dialog 为提交信息生成等操作解析出的会话)调用updateSessionMetadata(sessionId, { pr })。代码印证见 GitDialog.tsx:
if (typeof result.number === 'number' && result.url) { const pr = { number: result.number, url: result.url, state: 'open' as const }; const sid = sessionIdRef.current; if (sid) { ws.updateSessionMetadata(sid, { pr }).catch((err: unknown) => { console.warn('Failed to bind PR to session:', ...); }); } }两个关键决策:
- 不调
resolveSessionForWorkspace——它可能创建幽灵会话或误绑「最近会话」。workspace 级打开 GitDialog(无会话上下文)时不回写,跳过不报错。 - 写入失败仅降级为 console 警告,不影响 PR 创建成功的状态展示(best-effort)。
3.2 SDK / bridge / ACP 链路透传
- SDK DaemonClient.ts 的
updateSessionMetadatametadata 参数扩展pr?: { number: number; url: string }(单条),响应解析完整prs数组。 - daemon 两个 PATCH metadata 路由(
/session/:id/metadata与 workspace 作用域版本)校验pr后透传,bridge 更新成功后将 sidecar upsert 的完整列表回显在响应里。 - bridge packages/acp-bridge/src/bridge.ts 的
updateSessionMetadata先做全部校验再变更(组合请求不允许部分生效),upsert 进 live entry.prs(按 number 去重、上限 10),session_metadata_updatedSSE 事件 data 带完整prs。 - ACP
session/update_metadata(packages/cli/src/serve/acp-http/dispatch.ts)同样把最新绑定 upsert 进 sidecar。
3.3 shell 工具 post-hook:实时识别 agent 的gh pr create
当 agent 在 shell 里直接运行gh pr create时,run_shell_command完成、未中止且退出码为 0 时触发绑定。核心是执行闸门 + gh 归因两层:
- 执行闸门(commandRunsGhPrCreate):先判断命令某段以
gh pr create开头。闸门语法是封闭集合——命令段依次为「单词形式的前置赋值」→「{sudo, env, nohup, command}的任意嵌套链(每个至多三个 flag/赋值)」→「gh 二进制(裸gh、gh.exe/.cmd/.bat、任意路径限定写法)」→「pr create或pr new」。语法之外的形状(含空白的引号值、$(…)、bash -c "…"、timeout 60 gh …、子 shell、调用内部换行续行)不匹配、fail-closed:创建照常执行,只是不绑定,可通过/review <N>恢复。 - gh 归因(core github-prs.ts 的 fetchCurrentBranchPullRequest):由 gh 自身执行
gh pr view --json number,url,state,headRefName解析当前工作分支对应的 PR,仅当该 PR 状态为 OPEN 且其 URL 出现在本次命令输出里才绑定。命令/输出文本无法把打印出的 URL 归因到 gh 自身的执行(复合命令、引号内短语、注释、--help都能骗过纯文本匹配),所以文本匹配只做执行闸门,归因以 gh 的解析为准;gh 无法解析或状态不可识别时一律不绑(fail-closed)。open 闸门同时挡住「过闸的重试命令(gh pr create || gh pr view)解析到分支既有 PR」这一类误绑。
命中后经upsertSessionPrs直写 sidecar(复刻 worktree sidecar 的「工具进程直写」模式,CLI/daemon 双模生效);已绑定的号原样保留、不重盖 createdAt,只有真正新增的绑定才写入并经qwen/notify/session/pr-binding通知 daemon 标记 session catalog,与自动标题的onSessionCatalogChanged同通道,live-state 客户端 ~2s 内 refetch 到绑定。
范围限定:只覆盖前台完成的运行——未中断的前台运行,以及「promote 被拒(子进程已先退出)」的运行走这道闸门;Ctrl+B promote 成功的运行与is_background: true的运行不实时绑定(经后台注册表结算、输出流入文件、无 pre-run 快照,transcript 又明确不是恢复来源)。这类 PR 经/review <N>或 worktreepr-<N>约定绑定。
inline 凭据采集(ghPrCreateInlineEnv):export/unset只对其后的段可见(create 之后的export不归因);unset NAME、env -u NAME、env -i记为删除(undefinedoverlay,验证腿同步去掉 create 显式丢弃的环境凭据);${VAR:-default}一类带运算符的参数展开与$(…)一样保持字面量(猜错不得给验证腿授权);赋值值限单个 shell 词。目的是让 gh 验证腿以与 create 相同的方式认证(inline token 与 ambient auth 的取舍一致)。
并发安全:sidecar 写入与 daemon 侧写入(GitDialog/回填/刷新)经proper-lockfile跨进程锁序列化——两级:进程内队列(enqueuePrMutation)+ 文件锁(withSidecarLock),同 mailbox 先例。锁按规范化路径而非文件 realpath 取得(realpath: false),目标文件不必存在、也不再预先物化空文件——否则并发 holder 的无操作写会 unlink 掉自己物化的空文件,导致 ENOENT 丢写。
四、持久化:<chatsDir>/<sessionId>.pr.jsonsidecar
新增 sidecar 文件<chatsDir>/<sessionId>.pr.json,复刻 worktree sidecar 模式,改动面比 transcript 更小(displayName 走custom_titletranscript 记录是因为标题属于会话内容流;PR 绑定是会话外部元数据,worktree sidecar 是同类先例)。
- 读写接口:
readSessionPrs(容忍 ENOENT / JSON 损坏 / 形状校验失败,均返回 null,仅意外 I/O 抛错)、writeSessionPrs(经atomicWriteJSON原子写)、upsertSessionPr/upsertSessionPrs(按 number 去重、移到最新、cap 10)。 - 归档联动:
SessionService增加getPrSessionPathForArchiveState路径助手;归档/取消归档移动 sidecar(moveSessionPrSidecar,锁定两端、双队列串行、两侧都存在时按 number 合并),删除会话时清理(与 worktree sidecar 一一对应)。 - 列表回填:session-list.ts 的
enrichPrSidecars回填 persisted summary 的prs;live 会话的 entry.prs 只含本 daemon 生命周期内的绑定,回填时与 sidecar 历史按 number 合并(live 的 url 优先,live-only 的排最后;state以 sidecar 为准——定时器只刷 sidecar,live entry 停在绑定时刻)。
五、展示与搜索:web-shell 侧栏
renderSessionRow:会话行标题旁渲染小号 badge(session.prs非空时),显示最新 PR 号,多于一个时追加+N;点击经useExternalLinkOpener打开最新 PR(desktop webview 下target="_blank"会被静默丢弃);click/doubleClick/keydown 均 stopPropagation(双击 badge 不触发重命名)。SessionDetailsTooltip:列出全部绑定 PR(最新在前),各为外链。filteredSessions匹配逻辑扩展:label、sessionId之外,增加任意一个绑定 PR 号(输入9517或#9517都命中)、branch.name、worktree.branch、worktree.slug(sessionMatchesGitQuery,WebShellSidebar 与 WorkspaceSection 共用)。- SSE 消费侧:web-shell 不直接消费
session_metadata_updated更新 store;bridge 的markSessionCatalogChanged()触发 catalog revision bump,侧栏 live-state 轮询(2s 周期)发现后自动 refetch——badge 在绑定后 ~2s 内出现(与改名等其他客户端变更的传播机制一致)。 - i18n:新增
sidebar.sessionPr/sidebar.sessionPrMultiple两个 key(EN/ZH)。
六、存量回填:POST /sessions/backfill-prs
新增 daemon 路由POST /sessions/backfill-prs(进程级、按需触发,启动不自动扫描),实现见 packages/cli/src/serve/routes/session-pr-backfill.ts。遍历 registry 中所有 trusted workspace runtime,每个 workspace 扫描 persisted 会话(active + archived),isValidSessionId门禁先于一切路径构造。
6.1 两个来源(按权威升序插入,最强者最后、不被 tail-10 挤出)
/review <N|url>显式指令:仅解析用户键入的提示词——user 文本记录的首个 text part,或 TUI 技能展开场景下slash_command系统记录的systemPayload.rawCommand(TUI 先展开技能体再记录,键入命令被追加在展开体末尾、模式不可及,只存活于该字段)。#N与pull/N两种形态(URL 形态须与 workspace 同仓库,仓库 key 不可解析时 fail-closed)。assistant 散文/工具调用/工具结果里的引用不绑。review 会话绑到被 review 的 PR——这是「按 PR N 反查 review 会话」的正确语义。- worktree 约定:sidecar slug
pr-<N>/ branchworktree-pr-<N>直接给出 PR 号(零网络),会话「为该 PR 存在」,权威最高。正则见 parsePrNumberFromWorktree:/^pr-([1-9]\d{0,8})$/与/^worktree-pr-([1-9]\d{0,8})$/——[1-9]开头、无 PR 0、前导零不进,保证往返无歧义。
6.2 明确排除的两个来源
- 不用 transcript
gh pr create痕迹:历史命令没有 gh 侧归因(echo "...gh pr create...url"一类命令即可骗过纯文本闸门并打印任意同仓库 URL 伪造绑定),已移除该源;实时创建由 shell post-hook 归因绑定。 - 不用裸
gitBranch:首版曾把 transcript gitBranch 与 gh headRefName 交集作为来源,实测是纯噪声——workspace 当时所在分支的 PR 被绑到所有会话(主 workspace 272 命中全是这类,含 review 其他 PR 的会话与无关闲聊)。已移除并按 createdAt 时间窗清理错误绑定后重跑。transcript 分支映射在任何平台都不是来源。
6.3 gh 页闸门与 URL 兜底链
- 每 workspace 一次
fetchGitHubPullRequests({state:'all', limit:500, slim:true})提供 number→url/state 映射;slim只取 number/url/headRefName/state(全字段 + 500 触发 GitHub GraphQL 504,slim 约 4s/60KB)。slim 字段常量GH_PR_LIST_FIELDS_SLIM = 'number,url,headRefName,state'见 github-prs.ts。 - gh 页按仓库 key 闸门:fork 布局(origin=fork)下
gh pr list解析的是父仓库,页内 PR 属于另一仓库——与 workspace origin key 不一致的条目一律跳过(fail-closed);workspace key 不可解析时同样 fail-closed。约定号不受闸门影响。 - URL 兜底链:gh 映射 →
/review <url>形态命名的 URL → gh 页按号归属(fork 布局下仍优先 gh 自己的权威 URL,不同步合成 fork URL)→ git remote web URL 推导<repo>/pull/<N>(fetchRemoteWebUrl,支持 https / scp 风格 ssh /ssh://与 enterprise host);解析不到号的会话原样跳过。 - URL 形态的两道闸:仓库 key 须为 workspace 自身或 gh 页解析到的仓库(fork 父仓库);URL 还须过 sidecar 的形状校验。
- 同号借用规则:裸
/review N与约定号pr-N指本仓库的 PR N;URL 形态只有在其仓库 key 属于「workspace 自身或已确认的 fork 父仓库」时才把 URL 借给同号,来自未信任(divergent)gh 页仓库的形态只绑定它自己命名的 PR(否则gh repo set-default到陌生仓库就能把陌生仓库的 PR N 绑到「为本仓库 PR N 存在」的会话上)。
6.4 计划器与响应
每会话一次锁定内的读-改-写(replaceSessionPrs计划器,进程内队列 + 跨进程文件锁):已绑定同一 number 的候选跳过(不刷新 createdAt、不重排);本轮未再提供的既有条目视为外来占位者先占槽位;合并列表超过 tail-10 时按 provenance 排序裁剪(worktree > create > 无 provenance > review,同级按最旧位置)。gh pr create创建的 PR 被/review 100重提后不会被降级成 review 挤出(「持久化 source」与「本轮 stamp」取高者)。单份上限列表一次写入,失败不残留半成品;重复调用幂等;按候选隔离——单个 sidecar 写失败计入writeErrors并继续,不中止整个 workspace。
路由在written > 0(有 sidecar 重写,含仅挤出的计划)时失效列表缓存并markSessionCatalogChanged(),live-state 客户端 ~2s 内 refetch。响应按 workspace 聚合scanned/bound/written/alreadyBound/overLimit/unresolved/writeErrors/ghAvailable/platform(失败的 workspace 带error);untrusted workspace 跳过。各字段语义在 SessionPrBackfillWorkspaceResult 中有完整注释。
6.5 Aone workspace 特化
Aone workspace(origin 为 Aone 主机,详见 docs/design/2026-08-27-session-pr-aone-provider.md):来源与 GitHub 完全相同(/review指令 + worktree 约定,任何平台都不用 transcript 分支映射)。/review <url>形态在 Aone 上只当号源、绝不借出 URL,且只承认恰为本仓库 remote 伪造形状<origin>/pull/<N>的形态(全路径逐段相等);每个待绑定号经一次有上限、带缓存的a1 repo mr view取detailUrl+ state(绝不从 remote 拼 URL,view 失败或超预算计unresolved待下轮)。响应带platform: 'github' | 'aone';ghAvailable仅 GitHub。
七、合入状态快照与定时刷新
- 快照来源:slim 查询增取 gh
state(OPEN/MERGED/CLOSED →open/merged/closed);回填写入当时值,GitDialog 新建记open。 - 定时器:daemon 启动后挂独立低频任务(不挂列表轮询热路径),默认5 分钟一轮(常量
DEFAULT_SESSION_PR_REFRESH_INTERVAL_MS = 5 * 60 * 1000),环境变量QWEN_SESSION_PR_REFRESH_MINUTES可调(0= 关闭);unref()不阻碍进程退出,首轮延迟启动避开 boot(FIRST_RUN_DELAY_MS = 60_000,见 session-pr-refresh.ts)。 - 每轮流程:遍历 trusted workspace → 读
.pr.jsonsidecar 挑出非 merged 绑定 → 无目标直接跳过(零 gh 调用)→ 有目标发一次 slimgh pr list --state all --limit 500→ updateSessionPrStates 原地回写 state(不重排顺序、不刷新 createdAt,与 upsert 共享同一路径写入队列避免竞态)→ badge 经现有 2s 轮询自动更新。快照仅当 URL 规范化相等时应用(map 按 number 键,但同号不同仓库的 PR 是不同 PR)。 - 成本:只对含未合入绑定的 workspace 发查询(每 ~4s);gh 不可用时静默跳过该轮。
- 明确不做:定时器不做新 PR 发现(重扫 transcript 全量 6 分钟不适合 5 分钟周期);发现由 shell post-hook(实时)与 backfill(按需)承担。
八、关键决策与范围边界
8.1 关键决策回顾
- 绑定时机三通道:GitDialog 创建 PR 成功时(用户主力流程);agent 在 shell 里
gh pr create由 shell post-hook 实时识别(归因以gh pr view为准);存量由 backfill 两源回填(/review指令、worktree 约定)。不用裸 gitBranch(实测纯噪声)、不用 transcriptgh pr create痕迹(无 gh 侧归因、文本可伪造)。 - sidecar 而非 transcript 记录:PR 绑定是会话外部元数据,worktree sidecar 是同类先例,改动面更小。
- 多 PR 列表(cap 10):只保留最新一个会让「按 PR 号反查会话」在 stacked PR / 连续修复场景失效;badge 显示最新号 +
+N,tooltip 列全部,搜索匹配任意一个。 - workspace 级打开 GitDialog 不回写:绝不通过
resolveSessionForWorkspace创建新会话来绑定。
8.2 范围边界(明确不做)
- 服务端分页过滤(20+ 会话规模客户端搜索足够;
sourceType/sourceId过滤管道是将来扩展的样板)。 - 无 worktree sidecar 且 transcript 无
/review指令的会话:回填无可靠来源,不覆盖。 - promote 成功 /
is_background: true的gh pr create不实时绑定,也不从 transcript 恢复;这两类 PR 经/review <N>或pr-<N>约定绑定。 - 执行闸门语法不再逐形状扩展:封闭集合之外的写法 fail-closed(创建照常、只是不绑)。
- Aone 的
/review <url>形态只识别/pull/<N>URL,codereview/<id>URL 不作为 URL 形态来源(裸/review <id>与约定号照常经mr view解析)。 - PR-backed worktree 的删除语义:
exit_worktree {action:'remove'}对 fork PR worktree 会被既有的hasUnmergedWorktreeCommits守卫拒绝(PR head 只经 FETCH_HEAD 取得、无远端跟踪引用)——这是合并基线上就存在的行为,本 PR 只把pr-<N>形状从 slug 校验里放行,不改变删除守卫;按 PR 基线 SHA 豁免属于独立的后续工作。
九、影响文件全景
| 层 | 文件 |
|---|---|
| SDK 类型 | packages/sdk-typescript/src/daemon/types.ts(DaemonSessionSummary.pr) |
| SDK 事件 | packages/sdk-typescript/src/daemon/events.ts(MetadataUpdated data + 校验) |
| SDK 客户端 | packages/sdk-typescript/src/daemon/DaemonClient.ts(updateSessionMetadata 参数) |
| bridge 类型 | packages/acp-bridge/src/bridgeTypes.ts(BridgeSessionSummary.pr、metadata 参数) |
| bridge | packages/acp-bridge/src/bridge.ts(updateSessionMetadata 校验/存储/广播) |
| core | packages/core/src/services/session-pr-service.ts(新增)+ SessionService 路径助手/归档移动/删除清理;tools/shell.ts(gh pr create post-hook);packages/core/src/utils/github-prs.ts(slim 查询、fetchCurrentBranchPullRequest) |
| daemon 路由 | packages/cli/src/serve/routes/session.ts(两个 PATCH 路由校验 + sidecar 写入)、packages/cli/src/serve/acp-http/dispatch.ts(ACPsession/update_metadata) |
| daemon 列表 | packages/cli/src/serve/server/session-list.ts(enrichPrSidecars) |
| daemon 回填 | packages/cli/src/serve/routes/session-pr-backfill.ts(POST /sessions/backfill-prs) |
| daemon 刷新 | packages/cli/src/serve/server/session-pr-refresh.ts(定时状态刷新) |
| web-shell | GitDialog.tsx(回写)、WebShellSidebar.tsx(badge + 搜索)、SessionDetailsTooltip.tsx(PR 行)、locale 文件 |
| 测试 | 上述各层的 collocated 单测 |
结语
Web Shell 会话与 PR 的双向绑定是「多会话并行 + GitHub 工作流」场景下可检索性的关键拼图。整套设计以fail-closed 的安全姿态贯穿始终:URL 四层校验、读侧整份列表拒绝、gh 归因优于文本匹配、仓库 key 闸门、provenance 防降级——任何一处不确定都倾向"不绑"而不是"误绑"。结合 session-pr-service.ts 与 session-pr-backfill.ts 的源码,可以清晰地看到设计文档中的每一条约束都被落实为可测试的代码边界。
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考