✨ feat(vscode): 会话交接改走阿里云 OSS 收件箱、换 xz 压缩,另加从 env 文件配置移交存储的 URI

- 会话包传到 OSS 上收件人的收件箱 spx-handoff/<owner>/<repo>/session/<收件人>/<sid>!<发送人>!<会话名>.tar.xz,对象私有;owner/repo/收件人转小写
- 待接收列表就是列自己的收件箱;同一会话再交给同一人先删旧包;接收后删包,包已被别处收走时提示并刷新
- 打包换成 xz 1 级,和工单移交共用 packHandoff / unpackHandoff,删掉 gzip 版
- 删掉邮箱工单整套(mailboxIssue、MAILBOX_*、isMailboxIssue 及看板 / webhook 里的过滤、附件名编解码)和不再用的 Gitea 附件、标签、建工单接口
- session-handoff/accept 与待接收项改用 OSS 对象 key 标识
- 没配移交存储时发送、接收、刷新都提示去配,刷新回空列表
- 进度提示照工单移交:同一条提示依次显示打包、上传、下载多少 MB、解包、装会话
- 新增 URI configure-handoff-store?envFile=<绝对路径>:读 OSS_ENDPOINT / OSS_BUCKET_NAME / OSS_ACCESS_KEY / OSS_SECRET_KEY 保存,和命令共用 saveHandoffStore;URI 入口按 path 统一分发,未知 path 报错
This commit is contained in:
cruldra committed 2026-10-08 07:17:26 +08:00
1 parent 81fe14f6dc
commit fcee4143d4
26 files changed
+505 -478

No files matched your search

+24 -6
View File
@@ -27,7 +27,7 @@
- **发送方**:当前持有 worktree / 会话的机器(同事)。
- **接管方**:目标 assignee 的机器(我)。
- **会话包**:tar.xz,OSS 对象 key 为 `spx-handoff/<owner>/<repo>/issue-<N>-<UTC 时间>.tar.xz`(时间形如 `20261008T010203Z`,同一工单多次移交不撞名)。
- **会话包**:tar.xz,OSS 对象 key 为 `spx-handoff/<owner>/<repo>/issue-<N>-<UTC 时间>.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=<env 文件绝对路径>`,读文件里的 `OSS_ENDPOINT`、`OSS_BUCKET_NAME`、`OSS_ACCESS_KEY`、`OSS_SECRET_KEY`(luffy-agent-platform 后端 `.env` 的写法,`KEY=value`,注释和别的变量跳过;endpoint 去掉 `https://`),四项齐了按命令同样的方式保存并提示「移交存储已保存:<bucket>(<endpoint>)」;参数不对、文件读不到、缺项只报错(只报变量名),什么都不改。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/<owner>/<repo>/session/<收件人>/`。会话包 key 为 `<收件箱><sid>!<发送人>!<会话名 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/<sid>.jsonl` + `claude/<sid>/`,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 + `<sid>/` 一起移动到 `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)走一遍 移交 → 接管 → 恢复实施会话 → 拖到完成。
## 不做
+3 -1
View File
@@ -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/<owner>/<repo>/session/<对方>/`;对方会话 tab 顶部「待接收」列出自己收件箱里的包,点「接收」下载装好会话、删掉 OSS 上的包。同一会话再交给同一人只保留最新一份;两边进度提示一步步显示打包 / 上传 / 下载了多少 MB。
**改动 tab**:PR 文件审阅。左侧文件树 + 统计(可过滤 `tests/`),右侧内联 diff(统一 / 分栏),「已查看」按文件变更指纹失效,每个文件可用 deepseek 生成一句话改动说明。
也可以从外部拉起会话:`vscode://clurdra.superpowers-vscode-clurdra/create-session?profile=<名>&name=<tab名>&prompt=<提示词>&cwd=<路径>`(参数需两层 urlencode)。
从 env 文件配置移交存储:`vscode://clurdra.superpowers-vscode-clurdra/configure-handoff-store?envFile=<env 文件绝对路径>`(参数同样两层 urlencode),读文件里的 `OSS_ENDPOINT` / `OSS_BUCKET_NAME` / `OSS_ACCESS_KEY` / `OSS_SECRET_KEY`,四项齐了就保存,缺项或读不到只报错、什么都不改。
## 会话与 profile
| 会话 | 终端名 | CLI | 工作目录 | profile |
+1 -14
View File
@@ -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'))
})
})
+1 -11
View File
@@ -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<vo
await fsp.mkdir(dstDir, { recursive: true })
await tarUnpack(['-xJf', archive, '-C', dstDir])
}
/** 会话交接(单个会话)的包:tgz。它挂在 Gitea 工单附件上,Gitea 的附件白名单里没有 .xz。 */
export async function packSessionHandoff(stagingDir: string, outFile: string): Promise<void> {
await tarPack(['-czf', outFile, '-C', stagingDir, '.'], outFile)
}
export async function unpackSessionHandoff(archive: string, dstDir: string): Promise<void> {
await fsp.mkdir(dstDir, { recursive: true })
await tarUnpack(['-xzf', archive, '-C', dstDir])
}
+50 -37
View File
@@ -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: '测试服初始化',
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: '测试服初始化' })
})
expect(name).toBe(`spx-handoff-session!${SID}!chw!cruldra!测试服初始化.tgz`)
expect(parseSessionHandoffAttachmentName(name)).toEqual({
sid: SID,
from: 'chw',
to: 'cruldra',
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()
})
})
+45 -42
View File
@@ -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 = '<!-- spx:session-mailbox -->'
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/<owner>/<repo>/session/<to>/`。
* 结尾带 `/`,列 `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:收件箱前缀后面跟 `<sid>!<from>!<会话名>.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<string, unknown>
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,
+3 -3
View File
@@ -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/<path> 外部入口:开 cc 会话 tab、从 env 文件配置移交存储。
registerUriHandler(context)
const treeView = window.createTreeView('superpowers.kanban', {
treeDataProvider: new EmptyTreeProvider(),
-155
View File
@@ -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<GiteaIssue[]> {
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<GiteaLabel[]> {
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<GiteaLabel> {
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<GiteaIssue> {
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<GiteaAttachment[]> {
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<GiteaAttachment> {
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/<uuid>),Gitea 的 web
* 鉴权组同样接受 `Authorization: token`。未登录时它会 302 到登录页返回 HTML,
* 所以除 ok 外还要拒掉 HTML 响应,别把登录页当 tgz 存下来。
*/
export async function downloadAttachment(opts: { token: string, url: string }): Promise<Buffer> {
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<void> {
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)
}
+1 -3
View File
@@ -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([
-38
View File
@@ -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<number | undefined> {
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<number> {
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
}
+27 -1
View File
@@ -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<void> {
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<HandoffBundleObject[]> {
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
}
+62
View File
@@ -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'] })
})
})
+57
View File
@@ -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<string, string>()
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'),
},
}
}
+11 -3
View File
@@ -55,7 +55,15 @@ export async function configureHandoffStore(ctx: ExtensionContext): Promise<void
const accessKeySecret = await ask({ title: '配置移交存储(4/4)', prompt: 'AccessKey Secret', value: keys.accessKeySecret, password: true })
if (!accessKeySecret)
return
await saveSettings(ctx, { handoffOssEndpoint: endpoint, handoffOssBucket: bucket })
await setHandoffOssKeys(ctx, { accessKeyId, accessKeySecret })
void window.showInformationMessage(`移交存储已保存:${bucket}(${endpoint})`)
await saveHandoffStore(ctx, { endpoint, bucket, accessKeyId, accessKeySecret })
}
/**
* 保存移交存储配置并提示保存好了:endpoint、bucket 进扩展设置,两个 AccessKey 进密钥存储。
* 「配置移交存储」命令和 `configure-handoff-store` URI 都走这里。
*/
export async function saveHandoffStore(ctx: ExtensionContext, store: HandoffStore): Promise<void> {
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})`)
}
+1 -1
View File
@@ -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') {
+2 -2
View File
@@ -99,7 +99,7 @@ export function scratchDir(prefix: string): Promise<string> {
}
/** 读移交存储配置;没配置就 toast 提示去跑「配置移交存储」,返回 undefined。 */
async function requireHandoffStore(panel: KanbanWebviewPanel): Promise<HandoffStore | undefined> {
export async function requireHandoffStore(panel: KanbanWebviewPanel): Promise<HandoffStore | undefined> {
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<HandoffSt
* 同一条进度 toast 随步骤改文案,直到流程结束才收掉。文案没变就不重发,
* 上传 / 下载的进度回调很密。
*/
function progressToast(panel: KanbanWebviewPanel, prefix: string): { say: (step: string) => void, dismiss: () => void } {
export function progressToast(panel: KanbanWebviewPanel, prefix: string): { say: (step: string) => void, dismiss: () => void } {
let id: string | undefined
let shown = ''
return {
+121 -116
View File
@@ -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<void> {
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<void> {
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<void> {
const finish = (): void => panel.postMessage({ type: 'session-handoff/done', sessionId })
let spinner: string | undefined
let progress: ReturnType<typeof progressToast> | 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<void>(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,58 +163,71 @@ 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<void> {
/**
* 接收方:只认我在这个仓库收件箱里的 key → 下载 → 解包、校验清单 → 发送方和我的
* 工作区路径不同就改写会话里的路径 → 装会话文件 → 记进会话 tab → 删掉 OSS 上的包。
* 删包之前失败,包还在列表里,可以重试。
*/
export async function handleSessionHandoffAccept(panel: KanbanWebviewPanel, key: string): Promise<void> {
const finish = (): void => panel.postMessage({ type: 'session-handoff/done', sessionId: '' })
let spinner: string | undefined
let progress: ReturnType<typeof progressToast> | 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)
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
}
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) {
@@ -230,6 +246,7 @@ export async function handleSessionHandoffAccept(panel: KanbanWebviewPanel, atta
})
}
say('写入会话列表')
const data = await readManagedSessions(ctx.workspaceRoot)
const existing = data.sessions.find(s => s.id === manifest.session.id)
const profilePath = resolveProfilePath(manifest.session.profilePath)
@@ -241,56 +258,44 @@ export async function handleSessionHandoffAccept(panel: KanbanWebviewPanel, atta
else {
data.sessions.push({
id: manifest.session.id,
name: manifest.session.name || named.name || manifest.session.id.slice(0, 8),
name: manifest.session.name || label,
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 })
})
try {
await deleteHandoffBundle(store, key)
}
finally {
await fsp.rm(archiveDir, { recursive: true, force: true }).catch(() => {})
await fsp.rm(extracted, { recursive: true, force: true }).catch(() => {})
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<SessionHandoffPendingItem[]> {
const mailboxIndex = await findMailboxIssue(ctx)
if (mailboxIndex === undefined)
return []
const assets = await listIssueAttachments({ ...ctx, index: mailboxIndex })
async function listPendingForMe(store: HandoffStore, ctx: RepoCtx): Promise<SessionHandoffPendingItem[]> {
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
}
+3 -3
View File
@@ -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 }
@@ -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=<env 文件的绝对路径>
*
* 读文件里 `OSS_ENDPOINT` / `OSS_BUCKET_NAME` / `OSS_ACCESS_KEY` / `OSS_SECRET_KEY` 四个变量,
* 齐了就和「配置移交存储」命令一样保存;参数不对、文件读不到、缺项都只报错,什么都不改。
* 提示和日志里只出现 bucket、endpoint 和变量名,AccessKey 不出现。
*/
export async function handleConfigureHandoffStoreUri(context: ExtensionContext, uri: Uri): Promise<void> {
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}`,
})
}
+3 -22
View File
@@ -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=<profile 名,不带 .json>
* &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<void> {
if (uri.path !== '/create-session') {
void window.showErrorMessage(`未知的 superpowers URI path: ${uri.path}`)
return
}
export async function handleCreateSessionUri(uri: Uri): Promise<void> {
const params = new URLSearchParams(uri.query)
const profileName = (params.get('profile') ?? '').trim()
const name = (params.get('name') ?? '').trim()
+33
View File
@@ -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<string, (context: ExtensionContext, uri: Uri) => Promise<void>>([
['/create-session', (_context, uri) => handleCreateSessionUri(uri)],
['/configure-handoff-store', handleConfigureHandoffStoreUri],
])
/**
* 扩展的外部 URI 入口 `vscode://clurdra.superpowers-vscode-clurdra/<path>?<参数>`:按 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)}`)
})
},
}))
}
-10
View File
@@ -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(/<!--\s*spx:nonce=([0-9a-f-]+)\s*-->/i)
const nonce = nonceMatch ? nonceMatch[1] : null
// Promote the in-flight brainstorm terminal into the panel's
@@ -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) {
@@ -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({
<div className="mb-1 text-[10px] text-[var(--vscode-descriptionForeground)]">待接收</div>
<ul className="flex flex-col gap-1">
{pendingHandoffs.map(p => (
<li key={p.attachmentId} className="flex items-center gap-2">
<li key={p.key} className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-xs">{p.name || p.sid.slice(0, 8)}</div>
<div className="truncate text-[10px] text-[var(--vscode-descriptionForeground)]">
@@ -265,7 +265,7 @@ export function ManagedSessionsPanel({
</div>
<button
type="button"
onClick={() => onAcceptHandoff(p.attachmentId)}
onClick={() => onAcceptHandoff(p.key)}
className="shrink-0 rounded bg-[var(--vscode-button-background)] px-2 py-1 text-xs text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)]"
>
接收
@@ -34,7 +34,7 @@ export interface UseManagedSessionsResult {
sessionHandoffUsers: string[] | null
startSessionHandoff: (sessionId: string) => void
submitSessionHandoff: (sessionId: string, to: string) => void
acceptSessionHandoff: (attachmentId: number) => void
acceptSessionHandoff: (key: string) => void
refreshSessionHandoffs: () => void
}
@@ -102,8 +102,8 @@ export function useManagedSessions(): UseManagedSessionsResult {
postMessage({ type: 'session-handoff/start', sessionId, to })
}, [])
const acceptSessionHandoff = useCallback((attachmentId: number): void => {
postMessage({ type: 'session-handoff/accept', attachmentId })
const acceptSessionHandoff = useCallback((key: string): void => {
postMessage({ type: 'session-handoff/accept', key })
}, [])
const refreshSessionHandoffs = useCallback((): void => {
+3 -3
View File
@@ -52,11 +52,11 @@ export interface IssueChatComment {
systemText?: string
}
/** 我收件箱里一份待接收的会话包,`key` 是它在移交存储上的对象 key。 */
export interface SessionHandoffPendingItem {
attachmentId: number
key: string
sid: string
from: string
to: string
name: string
}
@@ -262,7 +262,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 }