diff --git a/docs/superpowers/specs/issue-handoff/spec.md b/docs/superpowers/specs/issue-handoff/spec.md index 692730a..3b5342f 100644 --- a/docs/superpowers/specs/issue-handoff/spec.md +++ b/docs/superpowers/specs/issue-handoff/spec.md @@ -27,7 +27,7 @@ - **发送方**:当前持有 worktree / 会话的机器(同事)。 - **接管方**:目标 assignee 的机器(我)。 -- **会话包**:tar.xz,OSS 对象 key 为 `spx-handoff///issue--.tar.xz`(时间形如 `20261008T010203Z`,同一工单多次移交不撞名)。 +- **会话包**:tar.xz,OSS 对象 key 为 `spx-handoff///issue--.tar.xz`(时间形如 `20261008T010203Z`,同一工单多次移交不撞名)。会话交接的包另放在收件箱目录下,见下文「会话交接(单个会话)」。 - **移交存储**:放会话包的 OSS 存储桶,见下文「移交存储配置」。 ## 会话包格式 @@ -77,7 +77,9 @@ codex/YYYY/MM/DD/rollout-*.jsonl 审查会话(存在才有) 命令「Superpowers: 配置移交存储」(`superpowers.configureHandoffStore`)依次弹四个输入框:endpoint(如 `oss-cn-beijing.aliyuncs.com`)、bucket、AccessKey ID、AccessKey Secret(密码框)。框里预填已有值;四项都填完才保存,中途 Esc 什么都不改。endpoint、bucket 存扩展设置(`settings/store.ts` 的 `handoffOssEndpoint` / `handoffOssBucket`),两个 AccessKey 存 VS Code SecretStorage(`auth/secrets.ts`),整台机器共用一份。 -四项缺一项就算没配置:移交、接管、同意移交申请都直接 toast 提示去跑这个命令,什么都不改(同意申请时也不把它记成申请失败的原因)。 +也可以从 env 文件一次配好:`vscode://clurdra.superpowers-vscode-clurdra/configure-handoff-store?envFile=`,读文件里的 `OSS_ENDPOINT`、`OSS_BUCKET_NAME`、`OSS_ACCESS_KEY`、`OSS_SECRET_KEY`(luffy-agent-platform 后端 `.env` 的写法,`KEY=value`,注释和别的变量跳过;endpoint 去掉 `https://`),四项齐了按命令同样的方式保存并提示「移交存储已保存:()」;参数不对、文件读不到、缺项只报错(只报变量名),什么都不改。AccessKey 不进日志、不进提示。URI 参数值按入口的编码契约做两层 urlencode(见 `uri/uriHandler.ts`)。 + +四项缺一项就算没配置:移交、接管、同意移交申请,以及会话交接的发送、接收、刷新待接收列表,都直接 toast 提示去跑这个命令,什么都不改(同意申请时也不把它记成申请失败的原因;刷新时给 webview 回空列表)。 ## 发送方:移交 @@ -121,6 +123,17 @@ codex/YYYY/MM/DD/rollout-*.jsonl 审查会话(存在才有) 幂等:3、4 可重复执行;6 之前失败会话包仍在,按钮仍显示,用户可重试。 +## 会话交接(单个会话) + +会话 tab 里的一个 claude 会话交给同事,和工单无关,也走移交存储。 + +- **收件箱**:每人每仓库一个 OSS 目录 `spx-handoff///session/<收件人>/`。会话包 key 为 `<收件箱>!<发送人>!<会话名 slug>.tar.xz`,slug 把字母、数字、`._-` 以外的字符换成 `_`、最长 40 字。owner / repo / 收件人 / 发送人只收 `[\w.-]`,sid 必须是 UUID,否则拒绝生成。owner / repo / 收件人转小写(Gitea 不分大小写,各人 remote 地址的写法可能不同)。key 的生成与解析在 `cc/sessionHandoff.ts`(`sessionInboxPrefix` / `sessionHandoffKey` / `parseSessionHandoffKey`)。 +- **包内容**:`handoff.json`(`kind: 'managed-session'`,含 from / to / workspacePath / session)+ `claude/.jsonl` + `claude//`,xz 1 级,和工单移交共用 `packHandoff` / `unpackHandoff`。 +- **发送**(`session-handoff/start { sessionId, to }`):关掉该会话的终端等 600ms → 收集会话、写清单、打包 → 列对方收件箱,删掉同 sid 的旧包 → 分片上传(对象私有)→ 本机会话记录写 `handedOffTo` / `handedOffAt`。 +- **待接收列表**(`session-handoff/refresh` → `session-handoff/pending { items: [{ key, sid, from, name }] }`):列自己的收件箱,从 key 解析出 sid / 发送人 / 会话名,不下载。面板加载时和每次发送、接收后各拉一次,没有推送。 +- **接收**(`session-handoff/accept { key }`):key 必须解析得出、且落在我在当前仓库的收件箱里,否则拒绝 → 下载(包名固定,不拿 key 拼本机路径)→ 解包、校验清单(收件人是我、会话 id 和 key 一致)→ 工作区路径不同就改写会话里的路径 → 装到 `projectsDirFor(workspaceRoot)`(先清同 sid 副本)→ 写会话 tab 记录 → 删 OSS 上的包(失败只 warn)。包已不在(别处收过)时提示并刷新列表。 +- 进度提示和工单移交一样:同一条 toast 依次显示 关闭终端、收集会话、`打包 x MB`、`上传 x MB / y MB`;接收方 `下载 x MB / y MB`、解包、装会话、写入会话列表,完成前不消失。 + ## 附带修正 完成列流程里「把 jsonl 拷回主仓库」(`panel/handlers/issues.ts` ~L388)改为整包**搬迁**:jsonl + `/` 一起移动到 `projectsDirFor(workspaceRoot)`,并删除源文件——否则新版 claude 因重复 sid 拒绝 resume。与接管方第 4 步共用同一个安装函数。 @@ -131,14 +144,18 @@ codex/YYYY/MM/DD/rollout-*.jsonl 审查会话(存在才有) |---|---|---| | `cc/sessionBundle.ts` | 纯 fs:`findClaudeSessionFiles(sid, projectsRoot)`、`installClaudeSession(sid, srcDir, dstProjectsDir, projectsRoot)`(先删本机所有副本再拷)、`codexRolloutPathFor(sid, codexRoot)`、`installCodexSession(relPath, srcFile, codexRoot)` | 临时目录 vitest | | `cc/handoffManifest.ts` | 清单类型、`buildHandoffManifest(...)`、`parseHandoffManifest(json)`、OSS 对象 key `handoffBundleKey(...)` | 纯函数 vitest | -| `cc/handoffArchive.ts` | `packHandoff(stagingDir, outFile, { onProgress })` / `unpackHandoff(file, dstDir)`,xz 1 级;另有会话交接(单会话,仍走 Gitea 附件,白名单没有 .xz)用的 gzip 版 `packSessionHandoff` / `unpackSessionHandoff` | 真 tar 往返 vitest | +| `cc/handoffArchive.ts` | `packHandoff(stagingDir, outFile, { onProgress })` / `unpackHandoff(file, dstDir)`,xz 1 级,工单移交和会话交接共用 | 真 tar 往返 vitest | +| `cc/sessionHandoff.ts` | 会话交接:收件箱前缀、包 key 的生成与解析,`managed-session` 清单 | 纯函数 vitest | | `git/worktreePath.ts` | 从 `panel/handlers/sessions.ts` 抽出 `resolveWorktreeDir`、`deriveSlug`,新增 `slugFromBranch` | 纯函数 vitest | | `git/worktree.ts` | 导出 `runGit`、`findLiveWorktreeForBranch`;新增 `ensureWorktreeFromRemote` | `test/git/` mock child_process | | `gitea/api.ts` | `listRepoAssignees` | — | -| `oss/handoffStore.ts` | 纯 Node:`uploadHandoffBundle`(分片上传、私有权限、进度)、`downloadHandoffBundle`(流式写文件、进度、空闲超时)、`deleteHandoffBundle` | 真机往返脚本(不进仓库) | -| `oss/handoffStoreSetup.ts` | `readHandoffStore(ctx)`、「配置移交存储」命令 | — | +| `oss/handoffStore.ts` | 纯 Node:`uploadHandoffBundle`(分片上传、私有权限、进度)、`downloadHandoffBundle`(流式写文件、进度、空闲超时)、`deleteHandoffBundle`、`listHandoffBundles`(按前缀列、翻页)、`isHandoffBundleMissing` | 真机往返脚本(不进仓库) | +| `oss/handoffStoreSetup.ts` | `readHandoffStore(ctx)`、「配置移交存储」命令、`saveHandoffStore`(命令和 URI 共用) | — | +| `oss/handoffStoreEnv.ts` | `parseHandoffStoreEnv(text)`:从 env 文本读四项 | 纯函数 vitest | +| `uri/uriHandler.ts` | 外部 URI 按 path 分发:`/create-session`、`/configure-handoff-store`,未知 path 报错 | — | | `panel/handlers/handoff.ts` | 纯决策:`canStartHandoff(issue)`、`canAcceptHandoff(issue, me)`、`handoffStateExtra` / `handoffUiPatch` 两套 payload、`worktreePushedCheck` 结果判定、`transferProgressText` 进度文案 | vitest | -| `panel/handlers/handoffFlow.ts` | 两个 orchestrator:`handleHandoffStart`、`handleHandoffAccept`、`handleHandoffUsers` | — | +| `panel/handlers/handoffFlow.ts` | 两个 orchestrator:`handleHandoffStart`、`handleHandoffAccept`、`handleHandoffUsers`;`progressToast`、`requireHandoffStore` 会话交接也用 | — | +| `panel/handlers/sessionHandoff.ts` | 会话交接:选人、发送、待接收列表、接收 | — | | webview | `HandoffModal.tsx`、`IssueDetailPanel` 两个按钮、`IssueCard` 角标、`useIssues` 三个 postMessage、`me` 状态 | — | 消息(`src/panel/messages.ts` 与 `webview-ui/src/lib/messages.ts` 镜像): @@ -150,6 +167,7 @@ codex/YYYY/MM/DD/rollout-*.jsonl 审查会话(存在才有) - `pnpm test` / `pnpm lint` / `pnpm typecheck`;`cd cli && make test`。 - OSS 真机往返(临时脚本,不进仓库,对象放 `spx-handoff/_selftest/` 下):上传 → 匿名 `curl` 必须 403 → 下载 → 解包一致 → 删除 → 匿名 `curl` 404、带密钥下载报 NoSuchKey。分片上传(≥100KB)和单次上传(<100KB)两条路都测私有权限。 +- 会话交接真机往返(同上):上传进收件箱 → 按收件人前缀列到、key 解析回 sid / 发送人 / 会话名,相近名字的收件箱列不到 → 匿名 `curl` 403 → 同一 sid 再交一次只剩新包 → 下载解包一致 → 删除后收件箱为空、再下载报对象不存在 → `_selftest/` 下 0 个对象。 - 端到端:本机用两个 VS Code 窗口(不同 Gitea 账号 token)走一遍 移交 → 接管 → 恢复实施会话 → 拖到完成。 ## 不做 diff --git a/vscode/README.md b/vscode/README.md index b67178d..d30d407 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -91,12 +91,14 @@ cd cli && make install # 编译到 bin/spx 并安装到 ~/.local/bin/ **Profile tab**:工作区级 KV 表(行 = key,列 = dev / prod 等),值可多行、可贴图,落盘 `.spx/profiles.json`。 -**会话 tab**:项目级 Claude 会话管理器——新建(可指定 profile 和首条 prompt)、重命名、恢复、导入已 `/rename` 的会话。落盘 `.spx/session-names.json`。 +**会话 tab**:项目级 Claude 会话管理器——新建(可指定 profile 和首条 prompt)、重命名、恢复、导入已 `/rename` 的会话。落盘 `.spx/session-names.json`。单个会话可以「交接」给同事:会话包打成 xz 包、以私有权限传到移交存储(同工单移交,没配会直接提示去跑「配置移交存储」)里对方的收件箱 `spx-handoff///session/<对方>/`;对方会话 tab 顶部「待接收」列出自己收件箱里的包,点「接收」下载装好会话、删掉 OSS 上的包。同一会话再交给同一人只保留最新一份;两边进度提示一步步显示打包 / 上传 / 下载了多少 MB。 **改动 tab**:PR 文件审阅。左侧文件树 + 统计(可过滤 `tests/`),右侧内联 diff(统一 / 分栏),「已查看」按文件变更指纹失效,每个文件可用 deepseek 生成一句话改动说明。 也可以从外部拉起会话:`vscode://clurdra.superpowers-vscode-clurdra/create-session?profile=<名>&name=&prompt=<提示词>&cwd=<路径>`(参数需两层 urlencode)。 +从 env 文件配置移交存储:`vscode://clurdra.superpowers-vscode-clurdra/configure-handoff-store?envFile=`(参数同样两层 urlencode),读文件里的 `OSS_ENDPOINT` / `OSS_BUCKET_NAME` / `OSS_ACCESS_KEY` / `OSS_SECRET_KEY`,四项齐了就保存,缺项或读不到只报错、什么都不改。 + ## 会话与 profile | 会话 | 终端名 | CLI | 工作目录 | profile | diff --git a/vscode/src/cc/handoffArchive.test.ts b/vscode/src/cc/handoffArchive.test.ts index ead7f2f..631ee0f 100644 --- a/vscode/src/cc/handoffArchive.test.ts +++ b/vscode/src/cc/handoffArchive.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, openSync, readFileSync, readSync, rmSync, statS import * as os from 'node:os' import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { formatByteSize, packHandoff, packSessionHandoff, unpackHandoff, unpackSessionHandoff } from './handoffArchive' +import { formatByteSize, packHandoff, unpackHandoff } from './handoffArchive' const tempDirs: string[] = [] afterEach(() => { @@ -72,16 +72,3 @@ describe('packHandoff / unpackHandoff', () => { await expect(packHandoff(path.join(tempDir('handoff-archive-missing-'), 'nope'), archive)).rejects.toThrow(/tar 打包失败/) }) }) - -describe('packSessionHandoff / unpackSessionHandoff', () => { - it('打出 gzip 包,解开后内容一致', async () => { - const src = staging() - const archive = path.join(tempDir('session-archive-out-'), 'bundle.tgz') - await packSessionHandoff(src, archive) - expect(headBytes(archive, 2)).toEqual([0x1F, 0x8B]) - - const out = tempDir('session-archive-unpacked-') - await unpackSessionHandoff(archive, out) - expect(readFileSync(path.join(out, 'claude', 'sid.jsonl'), 'utf8')).toBe(readFileSync(path.join(src, 'claude', 'sid.jsonl'), 'utf8')) - }) -}) diff --git a/vscode/src/cc/handoffArchive.ts b/vscode/src/cc/handoffArchive.ts index 7482c7f..14b39ee 100644 --- a/vscode/src/cc/handoffArchive.ts +++ b/vscode/src/cc/handoffArchive.ts @@ -64,7 +64,7 @@ async function tarPack(args: string[], outFile: string, onProgress?: (packedByte } /** - * 工单移交的会话包:tar.xz,xz 1 级。会话 jsonl 里的重复内容隔得很远,gzip 只看 32KB + * 工单移交、会话交接的会话包:tar.xz,xz 1 级。会话 jsonl 里的重复内容隔得很远,gzip 只看 32KB * 以内的重复,压不动;xz 1 级能压到 gzip 的三分之一左右,速度也够。用的是 macOS 自带 * bsdtar 内建的 xz,不用另装软件。 */ @@ -80,13 +80,3 @@ export async function unpackHandoff(archive: string, dstDir: string): Promise { - await tarPack(['-czf', outFile, '-C', stagingDir, '.'], outFile) -} - -export async function unpackSessionHandoff(archive: string, dstDir: string): Promise { - await fsp.mkdir(dstDir, { recursive: true }) - await tarUnpack(['-xzf', archive, '-C', dstDir]) -} diff --git a/vscode/src/cc/sessionHandoff.test.ts b/vscode/src/cc/sessionHandoff.test.ts index 9a3315f..afd5cf0 100644 --- a/vscode/src/cc/sessionHandoff.test.ts +++ b/vscode/src/cc/sessionHandoff.test.ts @@ -1,68 +1,81 @@ import { describe, expect, it } from 'vitest' +import { handoffBundleKey } from './handoffManifest' import { buildSessionHandoffManifest, - isMailboxIssue, - MAILBOX_MARKER, - MAILBOX_TITLE, - parseSessionHandoffAttachmentName, + parseSessionHandoffKey, parseSessionHandoffManifest, - sessionHandoffAttachmentName, + sessionHandoffKey, + sessionInboxPrefix, slugSessionName, } from './sessionHandoff' const SID = '11111111-1111-4111-8111-111111111111' +const REPO = { owner: 'hkjx', repo: 'luffy-agent-platform' } -describe('isMailboxIssue', () => { - it('title 命中', () => { - expect(isMailboxIssue({ title: MAILBOX_TITLE })).toBe(true) +describe('sessionInboxPrefix', () => { + it('收件人是目录,结尾带 /', () => { + expect(sessionInboxPrefix({ ...REPO, to: 'cruldra' })).toBe('spx-handoff/hkjx/luffy-agent-platform/session/cruldra/') }) - it('label 命中', () => { - expect(isMailboxIssue({ title: '其他', labels: [{ name: 'spx-session-mailbox' }] })).toBe(true) + it('owner / repo / 收件人不分大小写,都转小写', () => { + expect(sessionInboxPrefix({ owner: 'HKJX', repo: 'Luffy-Agent-Platform', to: 'Cruldra' })).toBe(sessionInboxPrefix({ ...REPO, to: 'cruldra' })) }) - it('body marker 命中', () => { - expect(isMailboxIssue({ title: 'x', body: `hello ${MAILBOX_MARKER}` })).toBe(true) + it('bob 的收件箱前缀不是 bobby 包的前缀', () => { + const bobby = sessionHandoffKey({ ...REPO, to: 'bobby', sid: SID, from: 'a', name: '' }) + expect(bobby.startsWith(sessionInboxPrefix({ ...REPO, to: 'bob' }))).toBe(false) }) - it('普通工单不是', () => { - expect(isMailboxIssue({ title: '修登录', body: 'foo', labels: [{ name: 'bug' }] })).toBe(false) + it.each([ + ['owner', { owner: 'a/b', repo: 'r', to: 'c' }], + ['repo', { owner: 'o', repo: '../x', to: 'c' }], + ['to', { owner: 'o', repo: 'r', to: 'c!d' }], + ['to', { owner: 'o', repo: 'r', to: '' }], + ])('%s 非法 → 抛错', (label, opts) => { + expect(() => sessionInboxPrefix(opts)).toThrow(new RegExp(label)) }) }) -describe('sessionHandoffAttachmentName', () => { +describe('sessionHandoffKey / parseSessionHandoffKey', () => { it('编解码往返', () => { - const name = sessionHandoffAttachmentName({ - sid: SID, - from: 'chw', - to: 'cruldra', - name: '测试服初始化', - }) - expect(name).toBe(`spx-handoff-session!${SID}!chw!cruldra!测试服初始化.tgz`) - expect(parseSessionHandoffAttachmentName(name)).toEqual({ - sid: SID, - from: 'chw', - to: 'cruldra', - name: '测试服初始化', - }) + const key = sessionHandoffKey({ ...REPO, to: 'cruldra', sid: SID, from: 'chw', name: '测试服初始化' }) + expect(key).toBe(`spx-handoff/hkjx/luffy-agent-platform/session/cruldra/${SID}!chw!测试服初始化.tar.xz`) + expect(parseSessionHandoffKey(key)).toEqual({ ...REPO, to: 'cruldra', sid: SID, from: 'chw', name: '测试服初始化' }) + }) + it('发送人和会话名保留大小写', () => { + const key = sessionHandoffKey({ owner: 'HKJX', repo: 'R', to: 'Bob', sid: SID, from: 'Alice', name: 'Fix-Login' }) + expect(parseSessionHandoffKey(key)).toEqual({ owner: 'hkjx', repo: 'r', to: 'bob', sid: SID, from: 'Alice', name: 'Fix-Login' }) + }) + it('key 落在收件人的收件箱里', () => { + const key = sessionHandoffKey({ ...REPO, to: 'cruldra', sid: SID, from: 'chw', name: 'x' }) + expect(key.startsWith(sessionInboxPrefix({ ...REPO, to: 'cruldra' }))).toBe(true) }) it('空名字也合法', () => { - const name = sessionHandoffAttachmentName({ sid: SID, from: 'a', to: 'b', name: '' }) - expect(parseSessionHandoffAttachmentName(name)).toEqual({ - sid: SID, - from: 'a', - to: 'b', - name: '', - }) + const key = sessionHandoffKey({ ...REPO, to: 'b', sid: SID, from: 'a', name: ' ' }) + expect(parseSessionHandoffKey(key)).toEqual({ ...REPO, to: 'b', sid: SID, from: 'a', name: '' }) + }) + it('会话名里的 / ! 空格变成 _,不会弄乱 key', () => { + const key = sessionHandoffKey({ ...REPO, to: 'b', sid: SID, from: 'a', name: 'foo/bar!baz qux' }) + expect(parseSessionHandoffKey(key)?.name).toBe('foo_bar_baz_qux') }) it('slug 去掉非法字符', () => { expect(slugSessionName('foo/bar baz')).toBe('foo_bar_baz') }) it('sid 非 UUID → 抛错', () => { - expect(() => sessionHandoffAttachmentName({ sid: '../x', from: 'a', to: 'b', name: '' })).toThrow(/UUID/) + expect(() => sessionHandoffKey({ ...REPO, to: 'b', sid: '../x', from: 'a', name: '' })).toThrow(/UUID/) }) - it('login 含 ! → 抛错', () => { - expect(() => sessionHandoffAttachmentName({ sid: SID, from: 'a!b', to: 'c', name: '' })).toThrow(/from/) + it('from 含 ! → 抛错', () => { + expect(() => sessionHandoffKey({ ...REPO, to: 'c', sid: SID, from: 'a!b', name: '' })).toThrow(/from/) }) - it('工单移交包名解析不到', () => { - expect(parseSessionHandoffAttachmentName('spx-handoff-issue-12.tgz')).toBeUndefined() + it('to 含 / → 抛错', () => { + expect(() => sessionHandoffKey({ ...REPO, to: 'c/d', sid: SID, from: 'a', name: '' })).toThrow(/to/) + }) + it.each([ + ['工单移交的包', handoffBundleKey({ ...REPO, issueNumber: 12, at: new Date('2026-10-08T01:02:03Z') })], + ['不在收件箱目录里的文件名', `spx-handoff-session!${SID}!a!b!x.tgz`], + ['收件箱下面多一层目录', `spx-handoff/o/r/session/b/sub/${SID}!a!x.tar.xz`], + ['sid 不是 UUID', 'spx-handoff/o/r/session/b/not-a-uuid!a!x.tar.xz'], + ['扩展名不对', `spx-handoff/o/r/session/b/${SID}!a!x.tgz`], + ['少了发送人', `spx-handoff/o/r/session/b/${SID}!x.tar.xz`], + ])('%s 解析不到', (_label, key) => { + expect(parseSessionHandoffKey(key)).toBeUndefined() }) }) diff --git a/vscode/src/cc/sessionHandoff.ts b/vscode/src/cc/sessionHandoff.ts index a552038..0ce686f 100644 --- a/vscode/src/cc/sessionHandoff.ts +++ b/vscode/src/cc/sessionHandoff.ts @@ -1,20 +1,14 @@ /** - * 会话交接:mailbox 工单识别、附件文件名、managed-session 清单。 + * 会话交接:会话包在 OSS 上的对象 key(收件箱)、managed-session 清单。 * * 与工单移交的 handoff.json 分开:那边有 issue/worktree/四种 sid,这边只有一个 * claude 会话。混用解析会把会话包当工单包读崩。 */ -export const MAILBOX_TITLE = 'spx-session-mailbox' -export const MAILBOX_LABEL = 'spx-session-mailbox' -export const MAILBOX_MARKER = '' -export const MAILBOX_BODY = `${MAILBOX_MARKER} -Superwork 会话交接邮箱。不要当任务处理,不要关闭。 -` - const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i -const LOGIN_RE = /^[\w.-]+$/ -const FILENAME_RE = /^spx-handoff-session!([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})!([\w.-]+)!([\w.-]+)!([^!]*)\.tgz$/i +/** Gitea 的用户名、仓库名只有字母、数字和 `_` `.` `-`;`/`、`!` 这类会弄乱 key 的字符一律不收。 */ +const NAME_RE = /^[\w.-]+$/ +const KEY_RE = /^spx-handoff\/([\w.-]+)\/([\w.-]+)\/session\/([\w.-]+)\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})!([\w.-]+)!([^!/]*)\.tar\.xz$/i export interface SessionHandoffManifest { version: 1 @@ -27,25 +21,21 @@ export interface SessionHandoffManifest { claude: Array<{ id: string }> } -export interface SessionHandoffAttachmentRef { +/** 一个会话包是谁在哪个仓库交给谁的:全都编在它的对象 key 里,列出收件箱不用下载就能显示。 */ +export interface SessionHandoffRef { + /** owner、repo、to 是转成小写后的,见 [sessionInboxPrefix] */ + owner: string + repo: string + to: string sid: string from: string - to: string + /** 会话名经 [slugSessionName] 处理后的样子,可能为空 */ name: string } -export function isMailboxIssue(issue: { - title?: string - body?: string - labels?: Array<{ name: string }> | null -}): boolean { - if (issue.title === MAILBOX_TITLE) - return true - if ((issue.labels ?? []).some(l => l.name === MAILBOX_LABEL)) - return true - if (typeof issue.body === 'string' && issue.body.includes(MAILBOX_MARKER)) - return true - return false +function requireName(label: string, value: string): void { + if (!NAME_RE.test(value)) + throw new Error(`${label} 含非法字符:${value}`) } export function slugSessionName(name: string): string { @@ -55,26 +45,39 @@ export function slugSessionName(name: string): string { return trimmed.replace(/[^\p{L}\p{N}._-]+/gu, '_').slice(0, 40) } -export function sessionHandoffAttachmentName(opts: { - sid: string - from: string - to: string - name: string -}): string { - if (!UUID_RE.test(opts.sid)) - throw new Error(`会话 id 不是 UUID:${opts.sid}`) - if (!LOGIN_RE.test(opts.from)) - throw new Error(`from 含非法字符:${opts.from}`) - if (!LOGIN_RE.test(opts.to)) - throw new Error(`to 含非法字符:${opts.to}`) - return `spx-handoff-session!${opts.sid}!${opts.from}!${opts.to}!${slugSessionName(opts.name)}.tgz` +/** + * `to` 在这个仓库的会话交接收件箱:OSS 上的目录前缀 `spx-handoff///session//`。 + * 结尾带 `/`,列 `bob` 的收件箱不会列到 `bobby` 的包。三段都转小写:Gitea 的用户名、仓库名 + * 不分大小写,各人 git remote 地址里的写法可能不一样,转小写两边才对得上同一个收件箱。 + * + * @throws owner / repo / to 含 `[\w.-]` 以外的字符。 + */ +export function sessionInboxPrefix(opts: { owner: string, repo: string, to: string }): string { + requireName('owner', opts.owner) + requireName('repo', opts.repo) + requireName('to', opts.to) + return `spx-handoff/${opts.owner}/${opts.repo}/session/${opts.to}/`.toLowerCase() } -export function parseSessionHandoffAttachmentName(filename: string): SessionHandoffAttachmentRef | undefined { - const m = FILENAME_RE.exec(filename) +/** + * 会话包的对象 key:收件箱前缀后面跟 `!!<会话名>.tar.xz`。 + * + * @throws sid 不是 UUID,或 owner / repo / from / to 含 `[\w.-]` 以外的字符。 + */ +export function sessionHandoffKey(opts: { owner: string, repo: string, to: string, sid: string, from: string, name: string }): string { + const prefix = sessionInboxPrefix(opts) + if (!UUID_RE.test(opts.sid)) + throw new Error(`会话 id 不是 UUID:${opts.sid}`) + requireName('from', opts.from) + return `${prefix}${opts.sid}!${opts.from}!${slugSessionName(opts.name)}.tar.xz` +} + +/** 把对象 key 拆回 [SessionHandoffRef];不是 [sessionHandoffKey] 生成的样子就返回 undefined。 */ +export function parseSessionHandoffKey(key: string): SessionHandoffRef | undefined { + const m = KEY_RE.exec(key) if (!m) return undefined - return { sid: m[1], from: m[2], to: m[3], name: m[4] ?? '' } + return { owner: m[1], repo: m[2], to: m[3], sid: m[4], from: m[5], name: m[6] } } function optString(v: unknown): string | undefined { @@ -95,7 +98,7 @@ export function parseSessionHandoffManifest(json: string): SessionHandoffManifes const workspacePath = optString(raw.workspacePath) if (!from || !to || !createdAt || !workspacePath) throw new Error('handoff.json 缺少 from / to / createdAt / workspacePath') - if (!LOGIN_RE.test(from) || !LOGIN_RE.test(to)) + if (!NAME_RE.test(from) || !NAME_RE.test(to)) throw new Error('handoff.json from/to 非法') const sessionRaw = (raw.session ?? {}) as Record const id = optString(sessionRaw.id) @@ -134,7 +137,7 @@ export function buildSessionHandoffManifest(opts: { }): SessionHandoffManifest { if (!UUID_RE.test(opts.session.id)) throw new Error(`会话 id 不是 UUID:${opts.session.id}`) - if (!LOGIN_RE.test(opts.from) || !LOGIN_RE.test(opts.to)) + if (!NAME_RE.test(opts.from) || !NAME_RE.test(opts.to)) throw new Error('from/to 非法') return { version: 1, diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index c9db911..dca15d7 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -14,7 +14,7 @@ import { CONFIGURE_HANDOFF_STORE_COMMAND, configureHandoffStore } from './oss/ha import { KanbanWebviewPanel } from './panel/KanbanPanel' import { offerPromptUpdates } from './settings/promptUpdate' import { getSettings, migratePromptTemplates, persistSeededProfiles } from './settings/store' -import { registerCreateSessionUriHandler } from './uri/createSessionUriHandler' +import { registerUriHandler } from './uri/uriHandler' import { webhookCoordinator } from './webhook/coordinator' /** @@ -56,8 +56,8 @@ export function activate(context: ExtensionContext): SuperpowersApi { // 状态栏常驻展示当前 Gitea 身份;token 用错人(同步/复制)时第一眼能看出来。 void initIdentity(context) - // vscode://clurdra.superpowers-vscode-clurdra/create-session 外部触发开 cc 会话 tab。 - registerCreateSessionUriHandler(context) + // vscode://clurdra.superpowers-vscode-clurdra/ 外部入口:开 cc 会话 tab、从 env 文件配置移交存储。 + registerUriHandler(context) const treeView = window.createTreeView('superpowers.kanban', { treeDataProvider: new EmptyTreeProvider(), diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts index 479baae..6f7c26f 100644 --- a/vscode/src/gitea/api.ts +++ b/vscode/src/gitea/api.ts @@ -9,8 +9,6 @@ * distinguish 401 (token invalid) from other failure modes. */ -import { Buffer } from 'node:buffer' - import { logger } from '../logging/logger' export const PAGE_SIZE = 50 @@ -100,8 +98,6 @@ export async function listIssuesByFilter(opts: { /** 缺省 = 不按人过滤,拉仓库全部工单(团队视图)。 */ filter?: 'assigned_by' | 'created_by' user?: string - /** 逗号分隔的 label 名。label 必须已存在,否则 Gitea 会丢掉过滤条件。 */ - labels?: string }): Promise { const out: GiteaIssue[] = [] let page = 1 @@ -113,8 +109,6 @@ export async function listIssuesByFilter(opts: { url.searchParams.set('state', 'all') if (opts.filter && opts.user) url.searchParams.set(opts.filter, opts.user) - if (opts.labels) - url.searchParams.set('labels', opts.labels) url.searchParams.set('limit', String(PAGE_SIZE)) url.searchParams.set('page', String(page)) @@ -131,77 +125,6 @@ export async function listIssuesByFilter(opts: { return out } -export interface GiteaLabel { - id: number - name: string - color: string -} - -export async function listRepoLabels(opts: { - host: string - token: string - owner: string - repo: string -}): Promise { - const out: GiteaLabel[] = [] - let page = 1 - while (true) { - const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/labels`) - url.searchParams.set('limit', String(PAGE_SIZE)) - url.searchParams.set('page', String(page)) - const res = await fetch(url.toString(), { headers: authHeaders(opts.token) }) - await ensureOk(res) - const batch = await res.json() as GiteaLabel[] - if (!Array.isArray(batch) || batch.length === 0) - break - out.push(...batch) - if (batch.length < PAGE_SIZE) - break - page += 1 - } - return out -} - -export async function createRepoLabel(opts: { - host: string - token: string - owner: string - repo: string - name: string - color: string - description?: string -}): Promise { - const res = await fetch(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/labels`, { - method: 'POST', - headers: { ...authHeaders(opts.token), 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: opts.name, color: opts.color, description: opts.description ?? '' }), - }) - await ensureOk(res) - return (await res.json()) as GiteaLabel -} - -export async function createIssue(opts: { - host: string - token: string - owner: string - repo: string - title: string - body: string - labels?: number[] -}): Promise { - const res = await fetch(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues`, { - method: 'POST', - headers: { ...authHeaders(opts.token), 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: opts.title, - body: opts.body, - ...(opts.labels && opts.labels.length > 0 ? { labels: opts.labels } : {}), - }), - }) - await ensureOk(res) - return (await res.json()) as GiteaIssue -} - /** * Fetches the firehose `/repos/{owner}/{repo}/issues/comments` endpoint, which * returns every comment in the repo across all issues. Paginated until a short @@ -713,15 +636,6 @@ export async function getRawFile(opts: { return res.text() } -export interface GiteaAttachment { - id: number - name: string - size: number - uuid: string - browser_download_url: string - created_at: string -} - /** 可被指派的用户(仓库协作者 + owner),移交选人用。 */ export async function listRepoAssignees(opts: { host: string @@ -736,72 +650,3 @@ export async function listRepoAssignees(opts: { await ensureOk(res) return (await res.json()) as GiteaUser[] } - -export async function listIssueAttachments(opts: { - host: string - token: string - owner: string - repo: string - index: number -}): Promise { - const res = await fetch( - `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets`, - { headers: authHeaders(opts.token) }, - ) - await ensureOk(res) - return (await res.json()) as GiteaAttachment[] -} - -export async function uploadIssueAttachment(opts: { - host: string - token: string - owner: string - repo: string - index: number - name: string - data: Buffer -}): Promise { - const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets`) - url.searchParams.set('name', opts.name) - const form = new FormData() - // Buffer 的底层 ArrayBufferLike 包含 SharedArrayBuffer,BlobPart 只认 ArrayBuffer; - // 用 Uint8Array 包一层拷贝到普通 ArrayBuffer 上,绕开这条 TS 类型不兼容。 - form.append('attachment', new Blob([new Uint8Array(opts.data)], { type: 'application/gzip' }), opts.name) - // 不能带 Content-Type:multipart boundary 由 fetch 按 FormData 自动生成。 - const res = await fetch(url.toString(), { - method: 'POST', - headers: { Authorization: `token ${opts.token}`, Accept: 'application/json' }, - body: form, - }) - await ensureOk(res) - return (await res.json()) as GiteaAttachment -} - -/** - * `browser_download_url` 走的是 web 路由(/attachments/),Gitea 的 web - * 鉴权组同样接受 `Authorization: token`。未登录时它会 302 到登录页返回 HTML, - * 所以除 ok 外还要拒掉 HTML 响应,别把登录页当 tgz 存下来。 - */ -export async function downloadAttachment(opts: { token: string, url: string }): Promise { - const res = await fetch(opts.url, { headers: { Authorization: `token ${opts.token}` }, redirect: 'follow' }) - await ensureOk(res) - const contentType = res.headers.get('content-type') ?? '' - if (contentType.includes('text/html')) - throw new GiteaApiError(res.status, `附件下载被重定向到页面(鉴权失败?):${opts.url}`) - return Buffer.from(await res.arrayBuffer()) -} - -export async function deleteIssueAttachment(opts: { - host: string - token: string - owner: string - repo: string - index: number - attachmentId: number -}): Promise { - const res = await fetch( - `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets/${opts.attachmentId}`, - { method: 'DELETE', headers: authHeaders(opts.token) }, - ) - await ensureOk(res) -} diff --git a/vscode/src/gitea/issueLoader.ts b/vscode/src/gitea/issueLoader.ts index 63c2bd5..d9b5e6e 100644 --- a/vscode/src/gitea/issueLoader.ts +++ b/vscode/src/gitea/issueLoader.ts @@ -13,7 +13,6 @@ import type { GiteaComment, GiteaIssue } from './api' import type { Issue, IssueColumn } from './types' import * as fs from 'node:fs' -import { isMailboxIssue } from '../cc/sessionHandoff' import { resolveWorktreePath } from '../git/worktree' import { getCurrentUser, @@ -488,7 +487,6 @@ export async function loadIssues(opts: { ]) merged = mergeIssues(assigned, created) } - merged = merged.filter(issue => !isMailboxIssue(issue)) // ② 每张工单读取一次评论,再从中解析 column const commentLists = await mapPool(merged, COMMENT_FETCH_CONCURRENCY, issue => @@ -590,7 +588,7 @@ export async function loadSingleIssue(opts: { const { host, token, owner, repo, workspaceRoot, issueNumber } = opts const issue = await getIssue({ host, token, owner, repo, index: issueNumber }) - if (!issue || isMailboxIssue(issue)) + if (!issue) return null const [comments, prerequisite] = await Promise.all([ diff --git a/vscode/src/gitea/mailboxIssue.ts b/vscode/src/gitea/mailboxIssue.ts deleted file mode 100644 index 1c0e3c6..0000000 --- a/vscode/src/gitea/mailboxIssue.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { GiteaLabel } from './api' -import { isMailboxIssue, MAILBOX_BODY, MAILBOX_LABEL, MAILBOX_TITLE } from '../cc/sessionHandoff' -import { createIssue, createRepoLabel, listIssuesByFilter, listRepoLabels } from './api' - -export async function findMailboxIssue(opts: { - host: string - token: string - owner: string - repo: string -}): Promise { - const labels = await listRepoLabels(opts) - if (!labels.some(l => l.name === MAILBOX_LABEL)) - return undefined - const issues = await listIssuesByFilter({ ...opts, labels: MAILBOX_LABEL }) - return issues.find(i => isMailboxIssue(i))?.number -} - -export async function ensureMailboxIssue(opts: { - host: string - token: string - owner: string - repo: string -}): Promise { - const existing = await findMailboxIssue(opts) - if (existing !== undefined) - return existing - const labels = await listRepoLabels(opts) - let label: GiteaLabel | undefined = labels.find(l => l.name === MAILBOX_LABEL) - if (!label) - label = await createRepoLabel({ ...opts, name: MAILBOX_LABEL, color: '586069', description: 'Superwork session mailbox' }) - const created = await createIssue({ - ...opts, - title: MAILBOX_TITLE, - body: MAILBOX_BODY, - labels: [label.id], - }) - return created.number -} diff --git a/vscode/src/oss/handoffStore.ts b/vscode/src/oss/handoffStore.ts index 2ed6c33..853b1d7 100644 --- a/vscode/src/oss/handoffStore.ts +++ b/vscode/src/oss/handoffStore.ts @@ -1,5 +1,5 @@ /** - * 工单移交的会话包放在阿里云 OSS 上:发送方上传,接管方下载后删掉。 + * 工单移交和会话交接的会话包放在阿里云 OSS 上:发送方上传,接收方下载后删掉。 * * 这里只管传文件,不碰 VS Code;配置从哪来见 `src/oss/handoffStoreSetup.ts`。 */ @@ -107,6 +107,32 @@ export async function downloadHandoffBundle( throw new Error(`下载不完整:收到 ${received} 字节,应为 ${total} 字节`) } +/** `err` 是不是「对象不存在」:会话包已经被别人收走或删掉了。 */ +export function isHandoffBundleMissing(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === 'NoSuchKey' +} + export async function deleteHandoffBundle(store: HandoffStore, key: string): Promise { await client(store).delete(key) } + +/** OSS 上的一个会话包。 */ +export interface HandoffBundleObject { + key: string + size: number + /** ISO 时间,如 `2026-10-08T01:02:03.000Z` */ + lastModified: string +} + +/** 列出 key 以 `prefix` 开头的全部对象。OSS 一页最多给 1000 个,这里翻页直到列完。 */ +export async function listHandoffBundles(store: HandoffStore, prefix: string): Promise { + const oss = client(store) + const out: HandoffBundleObject[] = [] + let token: string | undefined + do { + const page = await oss.listV2({ 'prefix': prefix, 'max-keys': 1000, ...(token ? { 'continuation-token': token } : {}) }) + out.push(...page.objects.map(o => ({ key: o.name, size: o.size, lastModified: o.lastModified }))) + token = page.isTruncated ? page.nextContinuationToken : undefined + } while (token) + return out +} diff --git a/vscode/src/oss/handoffStoreEnv.test.ts b/vscode/src/oss/handoffStoreEnv.test.ts new file mode 100644 index 0000000..f1f4127 --- /dev/null +++ b/vscode/src/oss/handoffStoreEnv.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { parseHandoffStoreEnv } from './handoffStoreEnv' + +const STORE = { + endpoint: 'oss-cn-beijing.aliyuncs.com', + bucket: 'luffy-agent-platform', + accessKeyId: 'AK-ID', + accessKeySecret: 'AK-SECRET', +} + +describe('parseHandoffStoreEnv', () => { + it('luffy 后端 .env 的写法:跳过注释、空行和别的变量', () => { + const text = [ + '# 数据库', + 'DATABASE_URL=postgres://x', + '', + 'OSS_ENDPOINT=oss-cn-beijing.aliyuncs.com', + 'OSS_ACCESS_KEY=AK-ID', + 'OSS_SECRET_KEY=AK-SECRET', + 'OSS_BUCKET_NAME=luffy-agent-platform', + 'OSS_PUBLIC_BASE=https://cdn.example.com', + ].join('\n') + expect(parseHandoffStoreEnv(text)).toEqual({ ok: true, store: STORE }) + }) + + it('认 CRLF 换行、export 前缀、等号两边空格、成对引号和行尾注释', () => { + const text = [ + 'export OSS_ENDPOINT = "oss-cn-beijing.aliyuncs.com"', + 'OSS_BUCKET_NAME=luffy-agent-platform # 正式服', + 'OSS_ACCESS_KEY=\'AK-ID\'', + 'OSS_SECRET_KEY=AK-SECRET', + ].join('\r\n') + expect(parseHandoffStoreEnv(text)).toEqual({ ok: true, store: STORE }) + }) + + it('值里的 # 不挨着空格就不算注释', () => { + const text = 'OSS_ENDPOINT=e\nOSS_BUCKET_NAME=b\nOSS_ACCESS_KEY=a\nOSS_SECRET_KEY=s#ec+ret/==' + const result = parseHandoffStoreEnv(text) + expect(result.ok && result.store.accessKeySecret).toBe('s#ec+ret/==') + }) + + it('endpoint 去掉协议和结尾的 /', () => { + const text = 'OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com/\nOSS_BUCKET_NAME=b\nOSS_ACCESS_KEY=a\nOSS_SECRET_KEY=s' + const result = parseHandoffStoreEnv(text) + expect(result.ok && result.store.endpoint).toBe('oss-cn-beijing.aliyuncs.com') + }) + + it('同一个变量写两遍以后一遍为准', () => { + const text = 'OSS_ENDPOINT=old\nOSS_ENDPOINT=new\nOSS_BUCKET_NAME=b\nOSS_ACCESS_KEY=a\nOSS_SECRET_KEY=s' + const result = parseHandoffStoreEnv(text) + expect(result.ok && result.store.endpoint).toBe('new') + }) + + it('缺项和空值都报出变量名', () => { + const text = 'OSS_ENDPOINT=e\nOSS_ACCESS_KEY=\n# OSS_SECRET_KEY=s\nOSS_BUCKET_NAME=b' + expect(parseHandoffStoreEnv(text)).toEqual({ ok: false, missing: ['OSS_ACCESS_KEY', 'OSS_SECRET_KEY'] }) + }) + + it('空文本四项全缺', () => { + expect(parseHandoffStoreEnv('')).toEqual({ ok: false, missing: ['OSS_ENDPOINT', 'OSS_BUCKET_NAME', 'OSS_ACCESS_KEY', 'OSS_SECRET_KEY'] }) + }) +}) diff --git a/vscode/src/oss/handoffStoreEnv.ts b/vscode/src/oss/handoffStoreEnv.ts new file mode 100644 index 0000000..92b9842 --- /dev/null +++ b/vscode/src/oss/handoffStoreEnv.ts @@ -0,0 +1,57 @@ +/** + * 从 env 文件的文本里读移交存储配置。认的是 luffy-agent-platform 后端 `.env` 的写法: + * `OSS_ENDPOINT`、`OSS_BUCKET_NAME`、`OSS_ACCESS_KEY`、`OSS_SECRET_KEY` 四行 `KEY=value`, + * 空行、`#` 注释和别的变量都跳过。 + */ + +import type { HandoffStore } from './handoffStore' + +const ENV_NAMES = ['OSS_ENDPOINT', 'OSS_BUCKET_NAME', 'OSS_ACCESS_KEY', 'OSS_SECRET_KEY'] as const + +type EnvName = typeof ENV_NAMES[number] + +const LINE_RE = /^(?:export\s+)?(\w+)\s*=(.*)$/ + +/** 读出来的配置,或者缺了哪几个变量(只有变量名,不带值)。 */ +export type HandoffStoreEnvResult = { ok: true, store: HandoffStore } | { ok: false, missing: EnvName[] } + +/** 去掉值两边成对的引号;没加引号的值去掉 ` #` 起的行尾注释。 */ +function unquote(raw: string): string { + const v = raw.trim() + const quote = v[0] + if (quote === '"' || quote === '\'') { + const end = v.indexOf(quote, 1) + if (end > 0) + return v.slice(1, end) + } + return v.replace(/\s#.*$/, '').trim() +} + +/** + * 同一个变量写了几遍以最后一遍为准,值为空算缺。endpoint 去掉 `https://` 和结尾的 `/`: + * 设置里存的是不带协议的域名,上传下载一律走 https。 + */ +export function parseHandoffStoreEnv(text: string): HandoffStoreEnvResult { + const values = new Map() + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) + continue + const m = LINE_RE.exec(trimmed) + if (m && (ENV_NAMES as readonly string[]).includes(m[1])) + values.set(m[1], unquote(m[2])) + } + const missing = ENV_NAMES.filter(name => !values.get(name)) + if (missing.length > 0) + return { ok: false, missing } + const get = (name: EnvName): string => values.get(name) ?? '' + return { + ok: true, + store: { + endpoint: get('OSS_ENDPOINT').replace(/^https?:\/\//i, '').replace(/\/+$/, ''), + bucket: get('OSS_BUCKET_NAME'), + accessKeyId: get('OSS_ACCESS_KEY'), + accessKeySecret: get('OSS_SECRET_KEY'), + }, + } +} diff --git a/vscode/src/oss/handoffStoreSetup.ts b/vscode/src/oss/handoffStoreSetup.ts index 82ee1e9..28d534f 100644 --- a/vscode/src/oss/handoffStoreSetup.ts +++ b/vscode/src/oss/handoffStoreSetup.ts @@ -55,7 +55,15 @@ export async function configureHandoffStore(ctx: ExtensionContext): Promise { + await saveSettings(ctx, { handoffOssEndpoint: store.endpoint, handoffOssBucket: store.bucket }) + await setHandoffOssKeys(ctx, { accessKeyId: store.accessKeyId, accessKeySecret: store.accessKeySecret }) + void window.showInformationMessage(`移交存储已保存:${store.bucket}(${store.endpoint})`) } diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index c96f955..be14361 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -571,7 +571,7 @@ export class KanbanWebviewPanel { return } if (msg.type === 'session-handoff/accept') { - void sessionHandoff.handleSessionHandoffAccept(this, msg.attachmentId) + void sessionHandoff.handleSessionHandoffAccept(this, msg.key) return } if (msg.type === 'session-handoff/refresh') { diff --git a/vscode/src/panel/handlers/handoffFlow.ts b/vscode/src/panel/handlers/handoffFlow.ts index cdfda12..3409f70 100644 --- a/vscode/src/panel/handlers/handoffFlow.ts +++ b/vscode/src/panel/handlers/handoffFlow.ts @@ -99,7 +99,7 @@ export function scratchDir(prefix: string): Promise { } /** 读移交存储配置;没配置就 toast 提示去跑「配置移交存储」,返回 undefined。 */ -async function requireHandoffStore(panel: KanbanWebviewPanel): Promise { +export async function requireHandoffStore(panel: KanbanWebviewPanel): Promise { const store = await readHandoffStore(panel.context) if (!store) toast(panel, 'error', HANDOFF_STORE_MISSING_MESSAGE) @@ -110,7 +110,7 @@ async function requireHandoffStore(panel: KanbanWebviewPanel): Promise void, dismiss: () => void } { +export function progressToast(panel: KanbanWebviewPanel, prefix: string): { say: (step: string) => void, dismiss: () => void } { let id: string | undefined let shown = '' return { diff --git a/vscode/src/panel/handlers/sessionHandoff.ts b/vscode/src/panel/handlers/sessionHandoff.ts index 26091c0..0a5e729 100644 --- a/vscode/src/panel/handlers/sessionHandoff.ts +++ b/vscode/src/panel/handlers/sessionHandoff.ts @@ -1,7 +1,18 @@ +/** + * 会话交接:把会话 tab 里的一个 claude 会话交给同事。 + * + * 会话包放在移交存储(阿里云 OSS)上,每个人在每个仓库有一个收件箱目录, + * key 的格式见 `src/cc/sessionHandoff.ts`。发送方打包上传进对方的收件箱, + * 接收方列自己的收件箱得到待接收列表,接收后把包删掉。 + */ + +import type { HandoffStore } from '../../oss/handoffStore' import type { KanbanWebviewPanel } from '../KanbanPanel' +import type { SessionHandoffPendingItem } from '../messages' +import type { RepoCtx } from './handoffFlow' import { promises as fsp } from 'node:fs' import * as path from 'node:path' -import { packSessionHandoff, unpackSessionHandoff } from '../../cc/handoffArchive' +import { formatByteSize, packHandoff, unpackHandoff } from '../../cc/handoffArchive' import { stageClaudeSessionFiles } from '../../cc/handoffBundle' import { HANDOFF_MANIFEST_FILE } from '../../cc/handoffManifest' import { resolveProfilePath } from '../../cc/profiles' @@ -14,32 +25,26 @@ import { } from '../../cc/sessionBundle' import { buildSessionHandoffManifest, - parseSessionHandoffAttachmentName, + parseSessionHandoffKey, parseSessionHandoffManifest, - sessionHandoffAttachmentName, + sessionHandoffKey, + sessionInboxPrefix, } from '../../cc/sessionHandoff' import { projectsDirFor } from '../../cc/sessionWatcher' -import { - deleteIssueAttachment, - downloadAttachment, - listIssueAttachments, - listRepoAssignees, - uploadIssueAttachment, -} from '../../gitea/api' -import { ensureMailboxIssue, findMailboxIssue } from '../../gitea/mailboxIssue' +import { listRepoAssignees } from '../../gitea/api' import { logger } from '../../logging/logger' +import { + deleteHandoffBundle, + downloadHandoffBundle, + isHandoffBundleMissing, + listHandoffBundles, + uploadHandoffBundle, +} from '../../oss/handoffStore' import { readManagedSessions, writeManagedSessions } from '../../sessions/managedStore' -import { dismiss, resolveRepoCtx, scratchDir, toast } from './handoffFlow' +import { transferProgressText } from './handoff' +import { progressToast, requireHandoffStore, resolveRepoCtx, scratchDir, toast } from './handoffFlow' import { pushManagedSessions } from './managedSessions' -export interface SessionHandoffPendingItem { - attachmentId: number - sid: string - from: string - to: string - name: string -} - export async function handleSessionHandoffUsers(panel: KanbanWebviewPanel): Promise { const ctx = await resolveRepoCtx(panel) if (!ctx) { @@ -60,27 +65,40 @@ export async function handleSessionHandoffUsers(panel: KanbanWebviewPanel): Prom } } +/** 列我的收件箱发给 webview。拿不到列表(没配移交存储、没仓库、网络错)也回一个空列表,webview 不用一直等。 */ export async function handleSessionHandoffRefresh(panel: KanbanWebviewPanel): Promise { + const post = (items: SessionHandoffPendingItem[]): void => panel.postMessage({ type: 'session-handoff/pending', items }) + const store = await requireHandoffStore(panel) + if (!store) { + post([]) + return + } const ctx = await resolveRepoCtx(panel) if (!ctx) { - panel.postMessage({ type: 'session-handoff/pending', items: [] }) + post([]) return } try { - const items = await listPendingForMe(ctx) - panel.postMessage({ type: 'session-handoff/pending', items }) + post(await listPendingForMe(store, ctx)) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'warn', source: 'panel', message: '拉取待接收会话失败', details: message }) - panel.postMessage({ type: 'session-handoff/pending', items: [] }) + post([]) } } +/** + * 发送方:关掉会话终端 → 收集会话文件、写清单、xz 打包 → 删掉对方收件箱里这个会话的旧包 + * → 上传 → 本机记下交给了谁。同一条进度提示随步骤改文案,结束才收掉。 + */ export async function handleSessionHandoffStart(panel: KanbanWebviewPanel, sessionId: string, to: string): Promise { const finish = (): void => panel.postMessage({ type: 'session-handoff/done', sessionId }) - let spinner: string | undefined + let progress: ReturnType | undefined try { + const store = await requireHandoffStore(panel) + if (!store) + return const ctx = await resolveRepoCtx(panel) if (!ctx) return @@ -95,62 +113,47 @@ export async function handleSessionHandoffStart(panel: KanbanWebviewPanel, sessi toast(panel, 'error', `找不到会话文件 ${sessionId},无法交接`) return } + const key = sessionHandoffKey({ owner: ctx.owner, repo: ctx.repo, to, sid: session.id, from: ctx.me, name: session.name }) + + progress = progressToast(panel, `正在把「${session.name || sessionId.slice(0, 8)}」交给 ${to}`) + const say = progress.say const existing = panel.managedTerminals.get(sessionId) if (existing) { + say('关闭终端') try { existing.dispose() } + // 终端已经死了也无所谓,目的只是让 jsonl 不再被写 catch {} await new Promise(r => setTimeout(r, 600)) } - spinner = toast(panel, 'info', `正在把「${session.name || sessionId.slice(0, 8)}」交给 ${to}…`, { spinner: true, dismissOnTimer: 120_000 }) - + say('收集会话') const manifest = buildSessionHandoffManifest({ from: ctx.me, to, workspacePath: ctx.workspaceRoot, session: { id: session.id, name: session.name, profilePath: session.profilePath }, }) - const attachmentName = sessionHandoffAttachmentName({ - sid: session.id, - from: ctx.me, - to, - name: session.name, - }) - + // 包必须放在 staging 之外,否则 tar 会把半成品包也打进去。 const staging = await scratchDir('spx-session-handoff-') const outDir = await scratchDir('spx-session-handoff-out-') - let mailboxIndex: number try { - const claudeDir = path.join(staging, 'claude') - await stageClaudeSessionFiles(files, claudeDir) + await stageClaudeSessionFiles(files, path.join(staging, 'claude')) await fsp.writeFile(path.join(staging, HANDOFF_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`) - mailboxIndex = await ensureMailboxIssue(ctx) - const archive = path.join(outDir, attachmentName) - await packSessionHandoff(staging, archive) - for (const old of await listIssueAttachments({ ...ctx, index: mailboxIndex })) { - const parsed = parseSessionHandoffAttachmentName(old.name) - if (parsed && parsed.sid === session.id && parsed.to === to) - await deleteIssueAttachment({ ...ctx, index: mailboxIndex, attachmentId: old.id }) + const archive = path.join(outDir, 'session-handoff.tar.xz') + await packHandoff(staging, archive, { onProgress: bytes => say(`打包 ${formatByteSize(bytes)}`) }) + // 同一个会话再交给同一人:先删掉对方收件箱里这个会话的旧包,对方只看到最新的一份。 + for (const old of await listHandoffBundles(store, sessionInboxPrefix({ owner: ctx.owner, repo: ctx.repo, to }))) { + if (parseSessionHandoffKey(old.key)?.sid === session.id) + await deleteHandoffBundle(store, old.key) } - await uploadIssueAttachment({ - ...ctx, - index: mailboxIndex, - name: attachmentName, - data: await fsp.readFile(archive), - }) - } - catch (err) { - const message = err instanceof Error ? err.message : String(err) - logger.add({ level: 'error', source: 'panel', message: `会话交接打包/上传失败 ${sessionId}`, details: message }) - if (spinner) - dismiss(panel, spinner) - toast(panel, 'error', `交接失败:${message}`) - return + await uploadHandoffBundle(store, key, archive, (sent, total) => say(transferProgressText('上传', sent, total))) + logger.add({ level: 'info', source: 'panel', message: `会话交接:会话包已上传 (${key}, ${(await fsp.stat(archive)).size} bytes)` }) } finally { + // rm 失败不该抛出掩盖上面已经判定好的成败——scratch 目录只是垃圾。 await fsp.rm(staging, { recursive: true, force: true }).catch(() => {}) await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {}) } @@ -160,137 +163,139 @@ export async function handleSessionHandoffStart(panel: KanbanWebviewPanel, sessi await writeManagedSessions(ctx.workspaceRoot, data) await pushAll(panel) - if (spinner) - dismiss(panel, spinner) + progress.dismiss() toast(panel, 'success', `已交给 ${to}(对方在会话 tab 待接收)`) } catch (err) { const message = err instanceof Error ? err.message : String(err) - logger.add({ level: 'error', source: 'panel', message: `会话交接未预期错误 ${sessionId}`, details: message }) + logger.add({ level: 'error', source: 'panel', message: `会话交接失败 ${sessionId}`, details: message }) + progress?.dismiss() toast(panel, 'error', `交接失败:${message}`) - if (spinner) - dismiss(panel, spinner) } finally { finish() } } -export async function handleSessionHandoffAccept(panel: KanbanWebviewPanel, attachmentId: number): Promise { +/** + * 接收方:只认我在这个仓库收件箱里的 key → 下载 → 解包、校验清单 → 发送方和我的 + * 工作区路径不同就改写会话里的路径 → 装会话文件 → 记进会话 tab → 删掉 OSS 上的包。 + * 删包之前失败,包还在列表里,可以重试。 + */ +export async function handleSessionHandoffAccept(panel: KanbanWebviewPanel, key: string): Promise { const finish = (): void => panel.postMessage({ type: 'session-handoff/done', sessionId: '' }) - let spinner: string | undefined + let progress: ReturnType | undefined + // extracted 声明在 try 外面:mkdtemp 之前就失败时 finally 不能去 rm 它。 + let extracted: string | undefined try { + const store = await requireHandoffStore(panel) + if (!store) + return const ctx = await resolveRepoCtx(panel) if (!ctx) return - const mailboxIndex = await findMailboxIssue(ctx) - if (mailboxIndex === undefined) { - toast(panel, 'error', '还没有会话交接邮箱') - return - } - const assets = await listIssueAttachments({ ...ctx, index: mailboxIndex }) - const asset = assets.find(a => a.id === attachmentId) - if (!asset) { - toast(panel, 'error', '找不到这份待接收附件') - return - } - const named = parseSessionHandoffAttachmentName(asset.name) - if (!named || named.to !== ctx.me) { + // key 是 webview 发来的,只认我在当前仓库收件箱里的包。 + const ref = parseSessionHandoffKey(key) + if (!ref || !key.startsWith(sessionInboxPrefix({ owner: ctx.owner, repo: ctx.repo, to: ctx.me }))) { toast(panel, 'error', '这份交接不是给你的') return } + const label = ref.name || ref.sid.slice(0, 8) - spinner = toast(panel, 'info', `正在接收「${named.name || named.sid.slice(0, 8)}」…`, { spinner: true, dismissOnTimer: 120_000 }) - - const archiveDir = await scratchDir('spx-session-handoff-in-') - const extracted = await scratchDir('spx-session-handoff-ex-') + progress = progressToast(panel, `正在接收「${label}」`) + const say = progress.say + extracted = await scratchDir('spx-session-handoff-in-') + // 包名固定,不拿 key 拼本机路径。 + const archive = path.join(extracted, 'session-handoff.tar.xz') try { - const buf = await downloadAttachment({ token: ctx.token, url: asset.browser_download_url }) - const archive = path.join(archiveDir, asset.name) - await fsp.writeFile(archive, buf) - await unpackSessionHandoff(archive, extracted) - const manifest = parseSessionHandoffManifest(await fsp.readFile(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8')) - if (manifest.to !== ctx.me) - throw new Error(`清单接收人是 ${manifest.to},不是你`) - const claudeDir = path.join(extracted, 'claude') - if (manifest.workspacePath !== ctx.workspaceRoot) { - for (const { id } of manifest.claude) { - await rewritePathInFile(path.join(claudeDir, `${id}.jsonl`), manifest.workspacePath, ctx.workspaceRoot) - await rewritePathInTree(path.join(claudeDir, id), manifest.workspacePath, ctx.workspaceRoot) - } - } - const dstProjectsDir = projectsDirFor(ctx.workspaceRoot) - await fsp.mkdir(dstProjectsDir, { recursive: true }) - for (const { id } of manifest.claude) { - await installClaudeSession({ - sid: id, - srcDir: claudeDir, - dstProjectsDir, - projectsRoot: claudeProjectsRoot(), - }) - } + await downloadHandoffBundle(store, key, archive, (got, total) => say(transferProgressText('下载', got, total))) + } + catch (err) { + if (!isHandoffBundleMissing(err)) + throw err + progress.dismiss() + toast(panel, 'error', `「${label}」已经不在收件箱里了,可能在别的机器上接收过`) + await handleSessionHandoffRefresh(panel) + return + } - const data = await readManagedSessions(ctx.workspaceRoot) - const existing = data.sessions.find(s => s.id === manifest.session.id) - const profilePath = resolveProfilePath(manifest.session.profilePath) - if (existing) { - if (manifest.session.name) - existing.name = manifest.session.name - existing.profilePath = profilePath + say('解包') + await unpackHandoff(archive, extracted) + const manifest = parseSessionHandoffManifest(await fsp.readFile(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8')) + if (manifest.to !== ctx.me) + throw new Error(`清单接收人是 ${manifest.to},不是你`) + if (manifest.session.id !== ref.sid) + throw new Error(`清单里的会话 ${manifest.session.id} 和收件箱里的 ${ref.sid} 对不上`) + + say('装会话') + const claudeDir = path.join(extracted, 'claude') + if (manifest.workspacePath !== ctx.workspaceRoot) { + for (const { id } of manifest.claude) { + await rewritePathInFile(path.join(claudeDir, `${id}.jsonl`), manifest.workspacePath, ctx.workspaceRoot) + await rewritePathInTree(path.join(claudeDir, id), manifest.workspacePath, ctx.workspaceRoot) } - else { - data.sessions.push({ - id: manifest.session.id, - name: manifest.session.name || named.name || manifest.session.id.slice(0, 8), - profilePath, - createdAt: Date.now(), - }) - } - await writeManagedSessions(ctx.workspaceRoot, data) - await deleteIssueAttachment({ ...ctx, index: mailboxIndex, attachmentId }).catch((err) => { - const message = err instanceof Error ? err.message : String(err) - logger.add({ level: 'warn', source: 'panel', message: '接收后删附件失败', details: message }) + } + const dstProjectsDir = projectsDirFor(ctx.workspaceRoot) + await fsp.mkdir(dstProjectsDir, { recursive: true }) + for (const { id } of manifest.claude) { + await installClaudeSession({ + sid: id, + srcDir: claudeDir, + dstProjectsDir, + projectsRoot: claudeProjectsRoot(), }) } - finally { - await fsp.rm(archiveDir, { recursive: true, force: true }).catch(() => {}) - await fsp.rm(extracted, { recursive: true, force: true }).catch(() => {}) + + say('写入会话列表') + const data = await readManagedSessions(ctx.workspaceRoot) + const existing = data.sessions.find(s => s.id === manifest.session.id) + const profilePath = resolveProfilePath(manifest.session.profilePath) + if (existing) { + if (manifest.session.name) + existing.name = manifest.session.name + existing.profilePath = profilePath + } + else { + data.sessions.push({ + id: manifest.session.id, + name: manifest.session.name || label, + profilePath, + createdAt: Date.now(), + }) + } + await writeManagedSessions(ctx.workspaceRoot, data) + try { + await deleteHandoffBundle(store, key) + } + catch (err) { + logger.add({ level: 'warn', source: 'panel', message: `接收会话后删除 OSS 上的会话包失败 (${key})`, details: err instanceof Error ? err.message : String(err) }) } await pushAll(panel) - if (spinner) - dismiss(panel, spinner) - toast(panel, 'success', `已接收「${named.name || named.sid.slice(0, 8)}」,来自 ${named.from}`) + progress.dismiss() + toast(panel, 'success', `已接收「${label}」,来自 ${manifest.from}`) } catch (err) { const message = err instanceof Error ? err.message : String(err) - logger.add({ level: 'error', source: 'panel', message: `接收会话失败 attachment=${attachmentId}`, details: message }) + logger.add({ level: 'error', source: 'panel', message: `接收会话失败 ${key}`, details: message }) + progress?.dismiss() toast(panel, 'error', `接收失败:${message}`) - if (spinner) - dismiss(panel, spinner) } finally { + // finish 先发:下面的 rm 失败不该让 webview 一直等。 finish() + if (extracted) + await fsp.rm(extracted, { recursive: true, force: true }).catch(() => {}) } } -async function listPendingForMe(ctx: { host: string, token: string, owner: string, repo: string, me: string }): Promise { - const mailboxIndex = await findMailboxIssue(ctx) - if (mailboxIndex === undefined) - return [] - const assets = await listIssueAttachments({ ...ctx, index: mailboxIndex }) +async function listPendingForMe(store: HandoffStore, ctx: RepoCtx): Promise { + const objects = await listHandoffBundles(store, sessionInboxPrefix({ owner: ctx.owner, repo: ctx.repo, to: ctx.me })) const items: SessionHandoffPendingItem[] = [] - for (const a of assets) { - const parsed = parseSessionHandoffAttachmentName(a.name) - if (!parsed || parsed.to !== ctx.me) - continue - items.push({ - attachmentId: a.id, - sid: parsed.sid, - from: parsed.from, - to: parsed.to, - name: parsed.name, - }) + for (const o of objects) { + const ref = parseSessionHandoffKey(o.key) + if (ref) + items.push({ key: o.key, sid: ref.sid, from: ref.from, name: ref.name }) } return items } diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index 5e226f9..924a99a 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -18,11 +18,11 @@ export interface ManagedSessionShowItem extends ManagedSession { tabOpen?: boolean } +/** 我收件箱里一份待接收的会话包,`key` 是它在移交存储上的对象 key。 */ export interface SessionHandoffPendingItem { - attachmentId: number + key: string sid: string from: string - to: string name: string } @@ -223,7 +223,7 @@ export type WebviewToExtension | { type: 'handoff/request-reject', issueNumber: number } | { type: 'session-handoff/users' } | { type: 'session-handoff/start', sessionId: string, to: string } - | { type: 'session-handoff/accept', attachmentId: number } + | { type: 'session-handoff/accept', key: string } | { type: 'session-handoff/refresh' } | { type: 'dependency/set', issueNumber: number, prerequisiteNumber: number } | { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number } diff --git a/vscode/src/uri/configureHandoffStoreUri.ts b/vscode/src/uri/configureHandoffStoreUri.ts new file mode 100644 index 0000000..a2c2bde --- /dev/null +++ b/vscode/src/uri/configureHandoffStoreUri.ts @@ -0,0 +1,47 @@ +import type { ExtensionContext, Uri } from 'vscode' +import { promises as fsp } from 'node:fs' +import * as path from 'node:path' +import { window } from 'vscode' +import { logger } from '../logging/logger' +import { parseHandoffStoreEnv } from '../oss/handoffStoreEnv' +import { saveHandoffStore } from '../oss/handoffStoreSetup' + +/** + * 从 env 文件配置移交存储,由 `uriHandler.ts` 分发过来: + * + * vscode://clurdra.superpowers-vscode-clurdra/configure-handoff-store?envFile= + * + * 读文件里 `OSS_ENDPOINT` / `OSS_BUCKET_NAME` / `OSS_ACCESS_KEY` / `OSS_SECRET_KEY` 四个变量, + * 齐了就和「配置移交存储」命令一样保存;参数不对、文件读不到、缺项都只报错,什么都不改。 + * 提示和日志里只出现 bucket、endpoint 和变量名,AccessKey 不出现。 + */ +export async function handleConfigureHandoffStoreUri(context: ExtensionContext, uri: Uri): Promise { + const envFile = (new URLSearchParams(uri.query).get('envFile') ?? '').trim() + if (!envFile) { + void window.showErrorMessage('configure-handoff-store URI 缺少 envFile 参数') + return + } + if (!path.isAbsolute(envFile)) { + void window.showErrorMessage(`configure-handoff-store URI:envFile 要写绝对路径(${envFile})`) + return + } + let text: string + try { + text = await fsp.readFile(envFile, 'utf8') + } + catch (err) { + void window.showErrorMessage(`configure-handoff-store URI:读不到 ${envFile}:${err instanceof Error ? err.message : String(err)}`) + return + } + const parsed = parseHandoffStoreEnv(text) + if (!parsed.ok) { + void window.showErrorMessage(`configure-handoff-store URI:${envFile} 里缺少 ${parsed.missing.join('、')}`) + return + } + await saveHandoffStore(context, parsed.store) + logger.add({ + level: 'info', + source: 'uri', + message: `URI 已保存移交存储:${parsed.store.bucket}(${parsed.store.endpoint}),来自 ${envFile}`, + }) +} diff --git a/vscode/src/uri/createSessionUriHandler.ts b/vscode/src/uri/createSessionUriHandler.ts index beb02d9..cbbd600 100644 --- a/vscode/src/uri/createSessionUriHandler.ts +++ b/vscode/src/uri/createSessionUriHandler.ts @@ -1,4 +1,4 @@ -import type { ExtensionContext, Uri } from 'vscode' +import type { Uri } from 'vscode' import { promises as fsp } from 'node:fs' import { window, workspace } from 'vscode' import { listClaudeProfiles } from '../cc/profiles' @@ -6,34 +6,15 @@ import { logger } from '../logging/logger' import { createCcSessionTab } from '../panel/handlers/managedSessions' /** - * 外部触发开 cc 会话 tab 的 URI 入口(供 delegate-fix skill 等外部方调用): + * 外部触发开 cc 会话 tab(供 delegate-fix skill 等外部方调用),由 `uriHandler.ts` 分发过来: * * vscode://clurdra.superpowers-vscode-clurdra/create-session * ?profile= * &name=<终端 tab 名,如 fix:fable5> * &prompt=<初始 prompt> * &cwd=<绝对路径,可选,缺省用 workspaceRoot> - * - * 编码契约:VS Code 把外部 URI 交给扩展前会对 query 整体做一次 percent-decode - * (vs/base/common/uri.ts 的 URI.parse),这里的 URLSearchParams 是第二次 decode。 - * 因此发送方对参数值必须做两层 urlencode,值里的 & = % + 等字符才能安全通过。 */ -export function registerCreateSessionUriHandler(context: ExtensionContext): void { - context.subscriptions.push(window.registerUriHandler({ - handleUri: (uri: Uri): void => { - void handleCreateSessionUri(uri).catch((err) => { - void window.showErrorMessage(`处理 create-session URI 失败:${err instanceof Error ? err.message : String(err)}`) - }) - }, - })) -} - -async function handleCreateSessionUri(uri: Uri): Promise { - if (uri.path !== '/create-session') { - void window.showErrorMessage(`未知的 superpowers URI path: ${uri.path}`) - return - } - +export async function handleCreateSessionUri(uri: Uri): Promise { const params = new URLSearchParams(uri.query) const profileName = (params.get('profile') ?? '').trim() const name = (params.get('name') ?? '').trim() diff --git a/vscode/src/uri/uriHandler.ts b/vscode/src/uri/uriHandler.ts new file mode 100644 index 0000000..d1040fa --- /dev/null +++ b/vscode/src/uri/uriHandler.ts @@ -0,0 +1,33 @@ +import type { ExtensionContext, Uri } from 'vscode' +import { window } from 'vscode' +import { handleConfigureHandoffStoreUri } from './configureHandoffStoreUri' +import { handleCreateSessionUri } from './createSessionUriHandler' + +/** 每个 path 一个路由,参数说明见各自的文件。 */ +const ROUTES = new Map Promise>([ + ['/create-session', (_context, uri) => handleCreateSessionUri(uri)], + ['/configure-handoff-store', handleConfigureHandoffStoreUri], +]) + +/** + * 扩展的外部 URI 入口 `vscode://clurdra.superpowers-vscode-clurdra/?<参数>`:按 path + * 分给 [ROUTES] 里的路由,未知 path 报错。 + * + * 编码契约:VS Code 把外部 URI 交给扩展前会对 query 整体做一次 percent-decode + * (vs/base/common/uri.ts 的 URI.parse),路由里的 URLSearchParams 是第二次 decode。 + * 因此发送方对参数值必须做两层 urlencode,值里的 & = % + 空格等字符才能安全通过。 + */ +export function registerUriHandler(context: ExtensionContext): void { + context.subscriptions.push(window.registerUriHandler({ + handleUri: (uri: Uri): void => { + const route = ROUTES.get(uri.path) + if (!route) { + void window.showErrorMessage(`未知的 superpowers URI path: ${uri.path}`) + return + } + void route(context, uri).catch((err) => { + void window.showErrorMessage(`处理 ${uri.path.slice(1)} URI 失败:${err instanceof Error ? err.message : String(err)}`) + }) + }, + })) +} diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index 5c5babe..fa71f33 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -19,7 +19,6 @@ import { env, Uri, window, workspace } from 'vscode' import { loginForToken } from '../auth/identity' import { getToken } from '../auth/secrets' import { renderReviewPrompt } from '../cc/prompts' -import { isMailboxIssue } from '../cc/sessionHandoff' import { detectRepo } from '../git/remote' import { deleteWebhook, getIssue, getPullRequest } from '../gitea/api' import { cleanCommentSummary, systemCommentText } from '../gitea/issueComment' @@ -577,15 +576,6 @@ class WebhookCoordinator { return } - if (isMailboxIssue({ title: event.title, body: event.body })) { - logger.add({ - level: 'info', - source: 'webhook', - message: `issue #${event.issueNumber} 是会话交接 mailbox,跳过看板`, - }) - return - } - const nonceMatch = event.body.match(//i) const nonce = nonceMatch ? nonceMatch[1] : null // Promote the in-flight brainstorm terminal into the panel's diff --git a/vscode/webview-ui/src/components/BottomTabs.tsx b/vscode/webview-ui/src/components/BottomTabs.tsx index d75c491..aabb930 100644 --- a/vscode/webview-ui/src/components/BottomTabs.tsx +++ b/vscode/webview-ui/src/components/BottomTabs.tsx @@ -91,7 +91,7 @@ export interface BottomTabsProps { onImportNamedSession: (sessionId: string, name: string) => void pendingHandoffs: SessionHandoffPendingItem[] onSessionHandoff: (sessionId: string) => void - onAcceptSessionHandoff: (attachmentId: number) => void + onAcceptSessionHandoff: (key: string) => void } export function BottomTabs(props: BottomTabsProps) { diff --git a/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx b/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx index 8951cb5..0e58b29 100644 --- a/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx +++ b/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx @@ -29,7 +29,7 @@ interface ManagedSessionsPanelProps { onImport: (sessionId: string, name: string) => void pendingHandoffs: SessionHandoffPendingItem[] onHandoff: (sessionId: string) => void - onAcceptHandoff: (attachmentId: number) => void + onAcceptHandoff: (key: string) => void } function basename(p?: string): string { @@ -254,7 +254,7 @@ export function ManagedSessionsPanel({
待接收
    {pendingHandoffs.map(p => ( -
  • +
  • {p.name || p.sid.slice(0, 8)}
    @@ -265,7 +265,7 @@ export function ManagedSessionsPanel({