From c86939285db75af934e683fd9a16fe8025e2c487 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sun, 27 Sep 2026 18:46:38 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E7=A7=BB=E4=BA=A4?= =?UTF-8?q?=E5=8C=85=E4=B8=8D=E5=B8=A6=E5=AD=90=E4=BB=A3=E7=90=86=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/issue-handoff/spec.md | 2 +- vscode/src/cc/handoffBundle.test.ts | 14 +++++++++----- vscode/src/cc/handoffBundle.ts | 18 +++++++++++++++--- vscode/src/cc/handoffSize.test.ts | 2 +- vscode/src/cc/handoffSize.ts | 7 +++++-- vscode/src/panel/handlers/sessionHandoff.ts | 5 ++--- 6 files changed, 33 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/specs/issue-handoff/spec.md b/docs/superpowers/specs/issue-handoff/spec.md index 504b455..a841123 100644 --- a/docs/superpowers/specs/issue-handoff/spec.md +++ b/docs/superpowers/specs/issue-handoff/spec.md @@ -81,7 +81,7 @@ codex/YYYY/MM/DD/rollout-*.jsonl 审查会话(存在才有) 2. **worktree 守卫**(有 `worktreePath` 且目录存在时):`git status --porcelain` 必须为空;`git fetch origin ` 后 `HEAD` 必须等于 `origin/`。否则 toast「有未提交/未推送改动」并终止,什么都不改。 3. **会话大小**(关终端之前):统计将打进包的 jsonl、会话目录和 codex rollout。原始总字节不超过 Gitea 附件上限 100MB 则通过。超过则先压缩,压缩产出一旦超过 100MB 就停,toast 写明哪个会话多大,不关终端、不上传、不改指派。正式打包再卡一次同一上限;那次失败发生在关终端之后,按打包失败处理。 4. 关闭该工单全部终端(`terminalOrigin` 同 issueNumber + 名称匹配 `issue-N-冲突解决`),等 600ms 让 jsonl 刷盘。 -5. 在 scratch 临时目录收集会话包:同一卷上用硬链接收文件,跨卷才复制。每个 sid 在 `~/.claude/projects/*/` 里找 `.jsonl`(多份取最新 mtime),连同 `/` 一起拷入 `claude/`;`reviewSessionId` 在 `~/.codex/sessions` 里按文件名尾部 uuid 找到 rollout 拷入 `codex/`。找不到的会话不进清单,只 log。写 `handoff.json`,`tar -czf`。 +5. 在 scratch 临时目录收集会话包:同一卷上用硬链接收文件,跨卷才复制。每个 sid 在 `~/.claude/projects/*/` 里找 `.jsonl`(多份取最新 mtime),连同 `/` 一起拷入 `claude/`(跳过 `subagents/`,子代理对话不移交);`reviewSessionId` 在 `~/.codex/sessions` 里按文件名尾部 uuid 找到 rollout 拷入 `codex/`。找不到的会话不进清单,只 log。写 `handoff.json`,`tar -czf`。 6. 若工单已有同名旧附件(重复移交)先删。`POST …/assets?name=spx-handoff-issue-N.tgz` 上传。 7. `mergeIssueState(issueNumber, { handoffAttachmentId, handoffFrom: me })`。 8. `updateIssueAssignees(… [to])`。 diff --git a/vscode/src/cc/handoffBundle.test.ts b/vscode/src/cc/handoffBundle.test.ts index c1d1c70..78236e9 100644 --- a/vscode/src/cc/handoffBundle.test.ts +++ b/vscode/src/cc/handoffBundle.test.ts @@ -31,9 +31,11 @@ function senderRoots(): { projectsRoot: string, codexRoot: string } { const wt = path.join(projectsRoot, '-Users-chw-wt-x') mkdirSync(proj, { recursive: true }) mkdirSync(path.join(wt, IMPL, 'subagents'), { recursive: true }) + mkdirSync(path.join(wt, IMPL, 'tool-results'), { recursive: true }) writeFileSync(path.join(proj, `${BRAIN}.jsonl`), 'brain\n') writeFileSync(path.join(wt, `${IMPL}.jsonl`), 'impl\n') writeFileSync(path.join(wt, IMPL, 'subagents', 'agent-1.jsonl'), '{}\n') + writeFileSync(path.join(wt, IMPL, 'tool-results', 'out.txt'), 'tool\n') const day = path.join(codexRoot, '2026', '08', '26') mkdirSync(day, { recursive: true }) writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`), 'review\n') @@ -56,7 +58,8 @@ describe('stageHandoffBundle', () => { expect(manifest.sessions.testSessionId).toBe('missing-0000-4000-8000-000000000000') expect(manifest.createdAt).toBe('2026-08-26T00:00:00.000Z') expect(existsSync(path.join(staging, 'claude', `${IMPL}.jsonl`))).toBe(true) - expect(existsSync(path.join(staging, 'claude', IMPL, 'subagents', 'agent-1.jsonl'))).toBe(true) + expect(existsSync(path.join(staging, 'claude', IMPL, 'subagents', 'agent-1.jsonl'))).toBe(false) + expect(existsSync(path.join(staging, 'claude', IMPL, 'tool-results', 'out.txt'))).toBe(true) expect(existsSync(path.join(staging, 'codex', manifest.codex[0].relPath))).toBe(true) expect(parseHandoffManifest(readFileSync(path.join(staging, HANDOFF_MANIFEST_FILE), 'utf8'), 7)).toEqual(manifest) }) @@ -92,7 +95,8 @@ describe('pack → unpack → install', () => { await installHandoffBundle(extracted, parsed, { projectsRoot, codexRoot, workspaceProjectsDir, worktreeProjectsDir }) expect(readFileSync(path.join(workspaceProjectsDir, `${BRAIN}.jsonl`), 'utf8')).toBe('brain\n') expect(readFileSync(path.join(worktreeProjectsDir, `${IMPL}.jsonl`), 'utf8')).toBe('impl\n') - expect(existsSync(path.join(worktreeProjectsDir, IMPL, 'subagents', 'agent-1.jsonl'))).toBe(true) + expect(existsSync(path.join(worktreeProjectsDir, IMPL, 'subagents', 'agent-1.jsonl'))).toBe(false) + expect(readFileSync(path.join(worktreeProjectsDir, IMPL, 'tool-results', 'out.txt'), 'utf8')).toBe('tool\n') expect(existsSync(path.join(projectsRoot, '-stale', `${IMPL}.jsonl`))).toBe(false) expect(readFileSync(path.join(codexRoot, parsed.codex[0].relPath), 'utf8')).toBe('review\n') }) @@ -114,7 +118,7 @@ describe('pack → unpack → install', () => { // sender 会话文件里嵌入发送方的 worktree 路径(plain / file:// 两种形式) const wt = path.join(roots.projectsRoot, '-Users-chw-wt-x') writeFileSync(path.join(wt, `${IMPL}.jsonl`), `{"cwd":"${WORKTREE_FROM}"}\n{"cwd":"file://${WORKTREE_FROM}"}\n`) - writeFileSync(path.join(wt, IMPL, 'subagents', 'agent-1.jsonl'), `{"cwd":"${WORKTREE_FROM}"}\n`) + writeFileSync(path.join(wt, IMPL, 'tool-results', 'out.txt'), `{"cwd":"${WORKTREE_FROM}"}\n`) const day = path.join(roots.codexRoot, '2026', '08', '26') writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`), `{"cwd":"${WORKTREE_FROM}"}\n{"cwd":"file://${WORKTREE_FROM}"}\n`) @@ -140,9 +144,9 @@ describe('pack → unpack → install', () => { }) const installedJsonl = readFileSync(path.join(worktreeProjectsDir, `${IMPL}.jsonl`), 'utf8') - const installedSubagent = readFileSync(path.join(worktreeProjectsDir, IMPL, 'subagents', 'agent-1.jsonl'), 'utf8') + const installedTool = readFileSync(path.join(worktreeProjectsDir, IMPL, 'tool-results', 'out.txt'), 'utf8') const installedRollout = readFileSync(path.join(codexRoot, manifest.codex[0].relPath), 'utf8') - for (const content of [installedJsonl, installedSubagent, installedRollout]) { + for (const content of [installedJsonl, installedTool, installedRollout]) { expect(content).toContain(WORKTREE_TO) expect(content).not.toContain(WORKTREE_FROM) } diff --git a/vscode/src/cc/handoffBundle.ts b/vscode/src/cc/handoffBundle.ts index e1b9d5b..41cb769 100644 --- a/vscode/src/cc/handoffBundle.ts +++ b/vscode/src/cc/handoffBundle.ts @@ -1,8 +1,10 @@ import type { HandoffManifest, HandoffProfiles, HandoffSessions } from './handoffManifest' +import type { ClaudeSessionFiles } from './sessionBundle' import { promises as fsp } from 'node:fs' import * as path from 'node:path' import { CLAUDE_SESSION_FIELDS, HANDOFF_MANIFEST_FILE } from './handoffManifest' import { + codexRolloutRelPathFor, findClaudeSessionFiles, installClaudeSession, @@ -40,7 +42,12 @@ async function placeFile(src: string, dst: string): Promise { } } +/** 子代理对话。接手的人只恢复主会话,不打包。 */ +export const HANDOFF_SKIPPED_DIR = 'subagents' + async function placeTree(src: string, dst: string): Promise { + if (path.basename(src) === HANDOFF_SKIPPED_DIR) + return const st = await fsp.lstat(src) if (st.isDirectory()) { await fsp.mkdir(dst, { recursive: true }) @@ -51,6 +58,13 @@ async function placeTree(src: string, dst: string): Promise { await placeFile(src, dst) } +export async function stageClaudeSessionFiles(files: ClaudeSessionFiles, dstDir: string): Promise { + await fsp.mkdir(dstDir, { recursive: true }) + await placeFile(files.jsonl, path.join(dstDir, path.basename(files.jsonl))) + if (files.dir) + await placeTree(files.dir, path.join(dstDir, path.basename(files.dir))) +} + export async function stageHandoffBundle( src: HandoffSource, stagingDir: string, @@ -70,9 +84,7 @@ export async function stageHandoffBundle( const files = await findClaudeSessionFiles(id, roots.projectsRoot) if (!files) continue - await placeFile(files.jsonl, path.join(claudeDir, path.basename(files.jsonl))) - if (files.dir) - await placeTree(files.dir, path.join(claudeDir, path.basename(files.dir))) + await stageClaudeSessionFiles(files, claudeDir) claude.push({ id, kind }) } diff --git a/vscode/src/cc/handoffSize.test.ts b/vscode/src/cc/handoffSize.test.ts index 1f1ac88..6207f5f 100644 --- a/vscode/src/cc/handoffSize.test.ts +++ b/vscode/src/cc/handoffSize.test.ts @@ -72,7 +72,7 @@ describe('measureHandoffSources', () => { const items = await measureHandoffSources(src, roots) expect(items).toEqual([ { role: '头脑风暴', bytes: Buffer.byteLength('brain\n') }, - { role: '实施', bytes: Buffer.byteLength('impl\n') + Buffer.byteLength('{}\n') }, + { role: '实施', bytes: Buffer.byteLength('impl\n') }, { role: '审查', bytes: Buffer.byteLength('review\n') }, ]) }) diff --git a/vscode/src/cc/handoffSize.ts b/vscode/src/cc/handoffSize.ts index ae20dea..9c8bbe4 100644 --- a/vscode/src/cc/handoffSize.ts +++ b/vscode/src/cc/handoffSize.ts @@ -3,7 +3,7 @@ import { promises as fsp } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { formatByteSize, HANDOFF_ATTACHMENT_MAX_BYTES, HandoffArchiveTooLargeError, packHandoff } from './handoffArchive' -import { stageHandoffBundle } from './handoffBundle' +import { HANDOFF_SKIPPED_DIR, stageHandoffBundle } from './handoffBundle' import { CLAUDE_SESSION_FIELDS } from './handoffManifest' import { codexRolloutRelPathFor, findClaudeSessionFiles } from './sessionBundle' @@ -45,8 +45,11 @@ async function treeBytes(abs: string): Promise { if (!st.isDirectory()) return st.size let total = 0 - for (const name of await fsp.readdir(abs)) + for (const name of await fsp.readdir(abs)) { + if (name === HANDOFF_SKIPPED_DIR) + continue total += await treeBytes(path.join(abs, name)) + } return total } diff --git a/vscode/src/panel/handlers/sessionHandoff.ts b/vscode/src/panel/handlers/sessionHandoff.ts index a60524e..d59aca0 100644 --- a/vscode/src/panel/handlers/sessionHandoff.ts +++ b/vscode/src/panel/handlers/sessionHandoff.ts @@ -2,11 +2,11 @@ import type { KanbanWebviewPanel } from '../KanbanPanel' import { promises as fsp } from 'node:fs' import * as path from 'node:path' import { packHandoff, unpackHandoff } from '../../cc/handoffArchive' +import { stageClaudeSessionFiles } from '../../cc/handoffBundle' import { HANDOFF_MANIFEST_FILE } from '../../cc/handoffManifest' import { resolveProfilePath } from '../../cc/profiles' import { claudeProjectsRoot, - copyClaudeSessionFiles, findClaudeSessionFiles, installClaudeSession, rewritePathInFile, @@ -125,8 +125,7 @@ export async function handleSessionHandoffStart(panel: KanbanWebviewPanel, sessi let mailboxIndex: number try { const claudeDir = path.join(staging, 'claude') - await fsp.mkdir(claudeDir, { recursive: true }) - await copyClaudeSessionFiles(files, claudeDir) + await stageClaudeSessionFiles(files, claudeDir) 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)