Files

2515 lines
98 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 工单移交(handoff)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 同事在 A 机把工单(worktree + claude/codex 会话)打包挂到 Gitea 工单附件并重新指派,我在 B 机一键接管:重建 worktree、装回会话文件、写本机字段,之后 resume / 合并 / 解冲突照常。
**Architecture:** 会话包 `spx-handoff-issue-<N>.tgz``handoff.json` 清单 + `claude/` + `codex/`)作为 Gitea 工单附件传输;共享 state JSON 新增两个字符串字段 `handoffAttachmentId` / `handoffFrom` 标记「待接管」;接管方用 `git worktree add -B <branch> <path> origin/<branch>` 重建 worktree,再把会话文件装到对应 `~/.claude/projects/<encoded cwd>/` 目录(先删本机所有同 sid 副本)。纯 fs / 纯决策逻辑拆成小模块配 vitest;副作用编排放 `panel/handlers/handoffFlow.ts`
**Tech Stack:** TypeScript、VS Code Extension API、Node 22 全局 `fetch`/`FormData`/`Blob``tar` CLI、React 19 + Tailwind 4webview)、Vitest、Gospx 的 schema 副本)。
**Spec:** `docs/superpowers/specs/issue-handoff/spec.md`
## Global Constraints
- 所有命令在 `/home/cruldra/Sources/superwork/vscode` 下执行(`pnpm test` / `pnpm lint` / `pnpm typecheck`Go 用 `cd cli && make test`)。
- 代码文件用 Serena MCP 工具读写(`mcp__serena__find_symbol` / `replace_symbol_body` / `insert_after_symbol` / `replace_content`);非代码文件(md / json)才用 Read/Edit。
- 提交信息格式:emoji + `type(scope)` + 中文描述(参考 `git log`,例:`✨ feat(vscode): …``♻️ refactor(vscode): …``🐛 fix(vscode): …`)。提交信息末尾加一行 `Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph`
- 注释只讲「为什么 / 坑」,不复述代码,不留历史痕迹(不写「原来是…现在改成…」)。
- `src/panel/messages.ts``webview-ui/src/lib/messages.ts``src/gitea/types.ts``webview-ui/src/types.ts` 是手工镜像,改一处必改另一处。
- state JSON 清空字段用 `''` 墓碑;`issue/patch` 清空字段用 `null``undefined` 会被 postMessage 丢掉)。
- 移交只支持 Gitea 工单(`issue.source !== 'youtrack'`)。
- 仅 POSIX:打包/解包直接 `execFile('tar', …)`
- 会话 id 与 `~/.claude/projects` 目录编码:`encodeCwdForProjectsDir(abs) = abs.replace(/[/.]/g, '-')``projectsDirFor(abs) = ~/.claude/projects/<encoded>`(已有,`src/cc/sessionWatcher.ts`)。
---
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `schemas/state-json.schema.json` | 改 | 新增 `handoffAttachmentId` / `handoffFrom` |
| `cli/cmd/spx/main.go` | 改 | `knownStateFields` 加两项;`make sync-schema` 同步 schema 副本 |
| `src/gitea/stateJson.ts``src/youtrack/stateComment.ts` | 改 | `KNOWN_STATE_FIELDS` 加两项 |
| `src/gitea/types.ts``webview-ui/src/types.ts` | 改 | `Issue``handoffAttachmentId?``handoffFrom?``assignees?` |
| `src/gitea/issueLoader.ts` | 改 | 解析并透出上述字段 |
| `src/panel/messages.ts``webview-ui/src/lib/messages.ts` | 改 | 新消息 + `issue/patch` 字段 + `issues/update.me` |
| `src/git/worktreePath.ts`(新) | 新 | `deriveSlug` / `slugFromBranch` / `resolveWorktreeDir` / `DEFAULT_WORKTREE_TEMPLATE`(从 `sessions.ts` 抽出) |
| `src/git/worktree.ts` | 改 | 导出 `runGit``findLiveWorktreeForBranch`;新增 `ensureWorktreeFromRemote` |
| `src/cc/sessionBundle.ts`(新) | 新 | claude/codex 会话文件查找、安装(去重)、codex rollout 定位 |
| `src/cc/handoffManifest.ts`(新) | 新 | 清单类型、附件名、`parseHandoffManifest` |
| `src/cc/handoffBundle.ts`(新) | 新 | `stageHandoffBundle` / `installHandoffBundle`(组合 sessionBundle + manifest |
| `src/cc/handoffArchive.ts`(新) | 新 | `packHandoff` / `unpackHandoff`tar |
| `src/gitea/api.ts` | 改 | assignees 列表、附件 list/upload/get/download/delete |
| `src/panel/handlers/handoff.ts`(新) | 新 | 纯决策与 payload`canStartHandoff``canAcceptHandoff``checkWorktreePushed`、四个 payload 函数 |
| `src/panel/handlers/handoffFlow.ts`(新) | 新 | `handleHandoffUsers` / `handleHandoffStart` / `handleHandoffAccept` |
| `src/panel/KanbanPanel.ts` | 改 | 三条消息分发;`issues/update``me` |
| `src/panel/handlers/issues.ts` | 改 | 完成列 jsonl 拷贝改为 `installClaudeSession` 搬迁 |
| `webview-ui/src/components/HandoffModal.tsx`(新) | 新 | 选人弹窗 |
| `webview-ui/src/hooks/useIssues.ts``App.tsx``BottomTabs.tsx``IssueDetailPanel.tsx``IssueCard.tsx` | 改 | 按钮、角标、状态、消息 |
| `README.md``package.json` | 改 | 文档、版本号 |
---
### Task 1: state JSON 字段 + Issue 类型 + loader
**Files:**
- Modify: `schemas/state-json.schema.json`
- Modify: `cli/cmd/spx/main.go:391-411`
- Modify: `src/gitea/stateJson.ts:22`
- Modify: `src/youtrack/stateComment.ts:23`
- Modify: `src/gitea/types.ts:10-99`
- Modify: `webview-ui/src/types.ts:3+`
- Modify: `src/gitea/issueLoader.ts:70-203, 293-397`
- Modify: `src/panel/messages.ts:43,45`
- Modify: `webview-ui/src/lib/messages.ts`(同两行)
**Interfaces:**
- Produces: `Issue.handoffAttachmentId?: string``Issue.handoffFrom?: string``Issue.assignees?: string[]``issues/update` 消息多 `me?: string``issue/patch` 支持 `handoffAttachmentId?: string | null``handoffFrom?: string | null``assignees?: string[]``sessionId?: string | null``testSessionId?: string | null`
- [ ] **Step 1: schema 加字段**
`schemas/state-json.schema.json``properties` 末尾(`autoReview` 之后)追加:
```json
"handoffAttachmentId": {
"type": "string",
"description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空。",
"minLength": 1
},
"handoffFrom": {
"type": "string",
"description": "发起移交的 Gitea login。",
"minLength": 1
}
```
- [ ] **Step 2: Go 侧同步**
`cli/cmd/spx/main.go``knownStateFields` 加:
```go
"handoffAttachmentId": {},
"handoffFrom": {},
```
运行:`cd cli && make sync-schema && make test`
Expected: `ok .../internal/state`schema_test 通过,副本一致)
- [ ] **Step 3: TS 两处 KNOWN_STATE_FIELDS**
`src/gitea/stateJson.ts:22``src/youtrack/stateComment.ts:23` 的数组末尾各加 `'handoffAttachmentId', 'handoffFrom'`
- [ ] **Step 4: Issue 类型(两份镜像)**
`src/gitea/types.ts``Issue``autoReview?: boolean` 之后加:
```ts
/** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */
assignees?: string[]
/** 移交会话包的 Gitea 附件 id(字符串);存在即「待接管」。 */
handoffAttachmentId?: string
/** 发起移交的 Gitea login。 */
handoffFrom?: string
```
`webview-ui/src/types.ts``Issue` 加同样三个字段。
- [ ] **Step 5: issueLoader 解析**
`src/gitea/issueLoader.ts` `parseColumnFromComments`
- 返回类型加 `handoffAttachmentId?: string``handoffFrom?: string`
- `obj` 断言类型加 `handoffAttachmentId?: unknown``handoffFrom?: unknown`
-`const autoReview = …` 后加:
```ts
const handoffAttachmentId = typeof obj.handoffAttachmentId === 'string' && obj.handoffAttachmentId.length > 0
? obj.handoffAttachmentId
: undefined
const handoffFrom = typeof obj.handoffFrom === 'string' && obj.handoffFrom.length > 0
? obj.handoffFrom
: undefined
```
- 返回对象加 `handoffAttachmentId, handoffFrom`
`buildIssue`:解构处加 `handoffAttachmentId, handoffFrom`;返回对象 `htmlUrl` 之前加:
```ts
...(handoffAttachmentId ? { handoffAttachmentId } : {}),
...(handoffFrom ? { handoffFrom } : {}),
assignees: (issue.assignees ?? []).map(a => a.login),
```
- [ ] **Step 6: 消息类型(两份镜像)**
`src/panel/messages.ts:43` 改为:
```ts
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string }
```
`:45``issue/patch` patch 对象:`sessionId?: string``sessionId?: string | null``testSessionId?: string``testSessionId?: string | null`,并在末尾追加 `, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null`
`webview-ui/src/lib/messages.ts` 同两行做同样修改。
- [ ] **Step 7: KanbanPanel 发 `me`**
`src/panel/KanbanPanel.ts` `loadAndPush``this.postMessage({ type: 'issues/update', … })` 之前加 `const me = await loginForToken(host, token)`import `loginForToken` from `'../auth/identity'`),消息里加 `me`
- [ ] **Step 8: 校验并提交**
Run: `pnpm typecheck && pnpm lint && pnpm test`
Expected: 全部通过。
```bash
git add schemas cli src webview-ui
git commit -m "✨ feat(vscode): state JSON 新增 handoffAttachmentId/handoffFromIssue 透出 assignees"
```
---
### Task 2: `git/worktreePath.ts`(抽出 slug / 模板解析)
**Files:**
- Create: `src/git/worktreePath.ts`
- Create: `src/git/worktreePath.test.ts`
- Modify: `src/panel/handlers/sessions.ts:636-680`(删除私有 `deriveSlug` / `resolveWorktreeDir`,改 import)、`:768-770`
**Interfaces:**
- Produces:
- `export const DEFAULT_WORKTREE_TEMPLATE = '~/Sources/worktree/$project_name/$feature_name'`
- `export function deriveSlug(planFile: string): string`
- `export function slugFromBranch(branch: string): string`
- `export function resolveWorktreeDir(template: string, workspaceRoot: string, slug: string): string`
- [ ] **Step 1: 写测试**
`src/git/worktreePath.test.ts`
```ts
import * as os from 'node:os'
import * as path from 'node:path'
import { describe, expect, it } from 'vitest'
import { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir, slugFromBranch } from './worktreePath'
describe('deriveSlug', () => {
it('取 plans/ 后一段', () => {
expect(deriveSlug('docs/superpowers/plans/foo-bar/plan.md')).toBe('foo-bar')
})
it('没有 plans/specs 锚点时退回父目录名', () => {
expect(deriveSlug('docs/x/baz/plan.md')).toBe('baz')
})
})
describe('slugFromBranch', () => {
it('去掉 feature/ 前缀', () => {
expect(slugFromBranch('feature/foo-bar')).toBe('foo-bar')
})
it('无前缀原样返回', () => {
expect(slugFromBranch('hotfix-1')).toBe('hotfix-1')
})
})
describe('resolveWorktreeDir', () => {
it('展开三个占位符与 ~', () => {
const out = resolveWorktreeDir(DEFAULT_WORKTREE_TEMPLATE, '/w/proj', 'slug')
expect(out).toBe(path.join(os.homedir(), 'Sources', 'worktree', 'proj', 'slug'))
})
it('$project_root 展开为工作区绝对路径', () => {
expect(resolveWorktreeDir('$project_root/.wt/$feature_name', '/w/proj', 's')).toBe('/w/proj/.wt/s')
})
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm test src/git/worktreePath.test.ts`
Expected: FAIL(模块不存在)
- [ ] **Step 3: 实现**
`src/git/worktreePath.ts`
```ts
import { createHash } from 'node:crypto'
import * as os from 'node:os'
import * as path from 'node:path'
export const DEFAULT_WORKTREE_TEMPLATE = '~/Sources/worktree/$project_name/$feature_name'
/**
* spec/plan 以目录形式存放(docs/superpowers/{specs,plans}/<slug>/…),slug 取
* `plans`/`specs` 后一段。兜底父目录名,再兜底路径 hash,保证目录名不为空。
*/
export function deriveSlug(planFile: string): string {
const segments = planFile.split('/').filter(Boolean)
const anchor = segments.findIndex(s => s === 'plans' || s === 'specs')
if (anchor >= 0 && segments[anchor + 1])
return segments[anchor + 1]
const parent = path.basename(path.dirname(planFile))
if (parent && parent !== '.' && parent !== '/')
return parent
return createHash('sha256').update(planFile).digest('hex').slice(0, 8)
}
/** 实施分支固定形如 `feature/<slug>`;接管方只有分支名,从这里反推 worktree 目录名。 */
export function slugFromBranch(branch: string): string {
return branch.startsWith('feature/') ? branch.slice('feature/'.length) : branch
}
/**
* 把 worktreeDirectory 模板展开成绝对路径。`~` 在这里展开:git/execFile 不经 shell
* 不会自己做 tilde 展开。
*/
export function resolveWorktreeDir(template: string, workspaceRoot: string, slug: string): string {
let p = template
.split('$project_name')
.join(path.basename(workspaceRoot))
.split('$project_root')
.join(workspaceRoot)
.split('$feature_name')
.join(slug)
if (p.startsWith('~'))
p = path.join(os.homedir(), p.slice(1))
return path.resolve(p)
}
```
- [ ] **Step 4: sessions.ts 改用新模块**
删除 `src/panel/handlers/sessions.ts` 中私有的 `deriveSlug``resolveWorktreeDir``:636-680`),加 `import { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir } from '../../git/worktreePath'``:768-770``|| '~/Sources/worktree/$project_name/$feature_name'` 改为 `|| DEFAULT_WORKTREE_TEMPLATE`。若 `createHash`/`os` 在 sessions.ts 里因此无引用,删掉对应 import。
- [ ] **Step 5: 跑测试 + 提交**
Run: `pnpm test src/git/worktreePath.test.ts && pnpm typecheck && pnpm lint`
Expected: PASS
```bash
git add src/git/worktreePath.ts src/git/worktreePath.test.ts src/panel/handlers/sessions.ts
git commit -m "♻️ refactor(vscode): 抽出 worktreePath 模块(slug / 目录模板解析)"
```
---
### Task 3: `ensureWorktreeFromRemote`
**Files:**
- Modify: `src/git/worktree.ts:37, 97, 110`
- Create: `test/git/worktreeFromRemote.test.ts`
**Interfaces:**
- Produces:
- `export function runGit(workspaceRoot: string, args: string[], failLabel?: string): Promise<string>`(改 export
- `export async function findLiveWorktreeForBranch(workspaceRoot: string, branch: string): Promise<string | undefined>`(改 export
- `export async function ensureWorktreeFromRemote(opts: WorktreeOpts): Promise<string>`
- [ ] **Step 1: 写集成测试(真 git,仿 `test/git/worktreeEnsure.test.ts`**
`test/git/worktreeFromRemote.test.ts`
```ts
import { execFileSync } from 'node:child_process'
import { existsSync, promises as fsp, rmSync, writeFileSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { ensureWorktreeFromRemote } from '../../src/git/worktree'
const gitEnv: NodeJS.ProcessEnv = {
...process.env,
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_TERMINAL_PROMPT: '0',
GIT_AUTHOR_NAME: 'remote-test',
GIT_AUTHOR_EMAIL: 'remote@test.local',
GIT_COMMITTER_NAME: 'remote-test',
GIT_COMMITTER_EMAIL: 'remote@test.local',
}
function git(cwd: string, args: string[]): string {
return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env: gitEnv }).trim()
}
interface Fixture { root: string, origin: string, repo: string, templatePath: string }
/** originbare)← 同事的 clone push feature 分支;repo 是接管方的 clone,只有 main。 */
async function setup(): Promise<Fixture> {
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-remote-'))
const origin = path.join(root, 'origin.git')
git(root, ['init', '--bare', '-b', 'main', origin])
const peer = path.join(root, 'peer')
git(root, ['clone', origin, peer])
writeFileSync(path.join(peer, 'README.md'), 'hello\n')
git(peer, ['add', 'README.md'])
git(peer, ['commit', '-m', 'init'])
git(peer, ['push', '-u', 'origin', 'main'])
git(peer, ['checkout', '-b', 'feature/x'])
writeFileSync(path.join(peer, 'x.txt'), 'x\n')
git(peer, ['add', 'x.txt'])
git(peer, ['commit', '-m', 'feat x'])
git(peer, ['push', '-u', 'origin', 'feature/x'])
const repo = path.join(root, 'repo')
git(root, ['clone', origin, repo])
const templatePath = path.join(root, 'wt', 'x')
return { root, origin, repo, templatePath }
}
let fx: Fixture | undefined
afterEach(() => {
if (fx)
rmSync(fx.root, { recursive: true, force: true })
fx = undefined
})
describe('ensureWorktreeFromRemote', () => {
it('从 origin 分支建 worktree,本地分支跟踪远端', async () => {
fx = await setup()
const out = await ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x' })
expect(out).toBe(fx.templatePath)
expect(existsSync(path.join(fx.templatePath, 'x.txt'))).toBe(true)
expect(git(fx.templatePath, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x')
expect(git(fx.templatePath, ['rev-parse', '--abbrev-ref', '@{u}'])).toBe('origin/feature/x')
})
it('该分支已有 live worktree 时直接复用', async () => {
fx = await setup()
const first = await ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x' })
const other = path.join(fx.root, 'wt', 'other')
const second = await ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: other, branch: 'feature/x' })
expect(second).toBe(first)
expect(existsSync(other)).toBe(false)
})
it('模板路径被非 worktree 目录占着 → 删掉重建', async () => {
fx = await setup()
await fsp.mkdir(fx.templatePath, { recursive: true })
writeFileSync(path.join(fx.templatePath, 'junk'), '')
await ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x' })
expect(existsSync(path.join(fx.templatePath, 'junk'))).toBe(false)
expect(existsSync(path.join(fx.templatePath, 'x.txt'))).toBe(true)
})
it('远端没有该分支 → 抛错', async () => {
fx = await setup()
await expect(
ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/none' }),
).rejects.toThrow(/feature\/none/)
})
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm test test/git/worktreeFromRemote.test.ts`
Expected: FAIL`ensureWorktreeFromRemote` 不存在)
- [ ] **Step 3: 实现**
`src/git/worktree.ts`:把 `function runGit``async function findLiveWorktreeForBranch` 改为 `export`。在 `ensureWorktree` 之后新增:
```ts
/**
* 接管方重建 worktree:分支已在 origin 上,本机从远端 checkout。
* `-B` 让本地同名残留分支直接对齐远端(发送方移交前已校验全部 push)。
*/
export async function ensureWorktreeFromRemote(opts: WorktreeOpts): Promise<string> {
const { workspaceRoot, branch } = opts
const worktreePath = path.resolve(opts.worktreePath)
await runGit(workspaceRoot, ['fetch', 'origin', branch], `拉取远端分支 ${branch}`)
// ① 已经有 live worktree → 复用,不动模板路径
const live = await findLiveWorktreeForBranch(workspaceRoot, branch)
if (live)
return live
// ② 模板路径被垃圾目录占着 → kill + 删,否则 add 撞路径
if (await pathExists(worktreePath)) {
killProcessesUsingWorktree(worktreePath)
await removeWorktreeDir(workspaceRoot, worktreePath)
}
await fsp.mkdir(path.dirname(worktreePath), { recursive: true })
await runGit(
workspaceRoot,
['worktree', 'add', '-B', branch, worktreePath, `origin/${branch}`],
'git worktree add',
)
return worktreePath
}
```
`removeWorktreeDir``rm -rf` + `git worktree prune`,对非 worktree 的垃圾目录同样适用;它拒绝少于 3 段的浅路径,测试里的模板路径在 `/tmp/sw-remote-*/wt/x` 下满足要求。
- [ ] **Step 4: 跑测试 + 提交**
Run: `pnpm test test/git/worktreeFromRemote.test.ts && pnpm typecheck && pnpm lint`
Expected: PASS
```bash
git add src/git/worktree.ts test/git/worktreeFromRemote.test.ts
git commit -m "✨ feat(vscode): ensureWorktreeFromRemote 从远端分支重建 worktree"
```
---
### Task 4: `cc/sessionBundle.ts`(会话文件查找 / 安装 / codex 定位)
**Files:**
- Create: `src/cc/sessionBundle.ts`
- Create: `src/cc/sessionBundle.test.ts`
**Interfaces:**
- Produces:
```ts
export interface ClaudeSessionFiles { projectsDir: string, jsonl: string, dir?: string }
export function claudeProjectsRoot(): string // ~/.claude/projects
export async function listClaudeSessionCopies(sid: string, projectsRoot: string): Promise<ClaudeSessionFiles[]>
export async function findClaudeSessionFiles(sid: string, projectsRoot: string): Promise<ClaudeSessionFiles | undefined> // 多份取 jsonl mtime 最新
export async function copyClaudeSessionFiles(files: ClaudeSessionFiles, dstDir: string): Promise<void>
export async function installClaudeSession(opts: { sid: string, srcDir: string, dstProjectsDir: string, projectsRoot: string }): Promise<void>
export async function codexRolloutRelPathFor(sid: string, codexRoot: string): Promise<string | undefined>
export async function installCodexSession(relPath: string, srcFile: string, codexRoot: string): Promise<void>
```
`installClaudeSession` 语义:把 `srcDir/<sid>.jsonl`+ `srcDir/<sid>/`)拷到 `dstProjectsDir`,然后删除 `projectsRoot` 下所有 `projectsDir !== dstProjectsDir` 的副本。srcDir 本身在 projectsRoot 下时(完成列搬迁)也成立。
- [ ] **Step 1: 写测试**
`src/cc/sessionBundle.test.ts`
```ts
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
codexRolloutRelPathFor,
findClaudeSessionFiles,
installClaudeSession,
installCodexSession,
listClaudeSessionCopies,
} from './sessionBundle'
const tempDirs: string[] = []
afterEach(() => {
while (tempDirs.length > 0) {
const d = tempDirs.pop()
if (d)
rmSync(d, { recursive: true, force: true })
}
})
function tempDir(prefix: string): string {
const d = mkdtempSync(path.join(os.tmpdir(), prefix))
tempDirs.push(d)
return d
}
const SID = '11111111-2222-4333-8444-555555555555'
function writeSession(projectsRoot: string, encoded: string, sid: string, body: string, withDir = false): string {
const dir = path.join(projectsRoot, encoded)
mkdirSync(dir, { recursive: true })
writeFileSync(path.join(dir, `${sid}.jsonl`), body)
if (withDir) {
mkdirSync(path.join(dir, sid, 'subagents'), { recursive: true })
writeFileSync(path.join(dir, sid, 'subagents', 'agent-a.jsonl'), '{}\n')
}
return dir
}
describe('listClaudeSessionCopies / findClaudeSessionFiles', () => {
it('列出所有 project 目录下的同 sid 副本,含子目录', async () => {
const root = tempDir('cc-projects-')
const a = writeSession(root, '-home-a-proj', SID, 'a\n', true)
const b = writeSession(root, '-home-a-wt', SID, 'b\n')
const copies = await listClaudeSessionCopies(SID, root)
expect(copies.map(c => c.projectsDir).sort()).toEqual([a, b].sort())
expect(copies.find(c => c.projectsDir === a)?.dir).toBe(path.join(a, SID))
expect(copies.find(c => c.projectsDir === b)?.dir).toBeUndefined()
})
it('多份副本取 jsonl mtime 最新的', async () => {
const root = tempDir('cc-projects-')
const old = writeSession(root, '-old', SID, 'old\n')
const fresh = writeSession(root, '-fresh', SID, 'fresh\n')
utimesSync(path.join(old, `${SID}.jsonl`), new Date(0), new Date(0))
const found = await findClaudeSessionFiles(SID, root)
expect(found?.projectsDir).toBe(fresh)
})
it('projectsRoot 不存在 → 空 / undefined', async () => {
expect(await listClaudeSessionCopies(SID, '/nonexistent/x')).toEqual([])
expect(await findClaudeSessionFiles(SID, '/nonexistent/x')).toBeUndefined()
})
})
describe('installClaudeSession', () => {
it('从外部目录装入目标 project 目录,并清掉本机其它副本', async () => {
const root = tempDir('cc-projects-')
const stale = writeSession(root, '-stale', SID, 'stale\n', true)
const src = tempDir('cc-src-')
mkdirSync(path.join(src, SID, 'tool-results'), { recursive: true })
writeFileSync(path.join(src, `${SID}.jsonl`), 'new\n')
writeFileSync(path.join(src, SID, 'tool-results', 'r.txt'), 'r')
const dst = path.join(root, '-home-me-wt')
await installClaudeSession({ sid: SID, srcDir: src, dstProjectsDir: dst, projectsRoot: root })
expect(readFileSync(path.join(dst, `${SID}.jsonl`), 'utf8')).toBe('new\n')
expect(existsSync(path.join(dst, SID, 'tool-results', 'r.txt'))).toBe(true)
expect(existsSync(path.join(stale, `${SID}.jsonl`))).toBe(false)
expect(existsSync(path.join(stale, SID))).toBe(false)
})
it('源目录就在 projectsRoot 下(搬迁):目标保留,源删除', async () => {
const root = tempDir('cc-projects-')
const from = writeSession(root, '-wt', SID, 'x\n', true)
const to = path.join(root, '-proj')
await installClaudeSession({ sid: SID, srcDir: from, dstProjectsDir: to, projectsRoot: root })
expect(readFileSync(path.join(to, `${SID}.jsonl`), 'utf8')).toBe('x\n')
expect(existsSync(path.join(to, SID, 'subagents', 'agent-a.jsonl'))).toBe(true)
expect(existsSync(path.join(from, `${SID}.jsonl`))).toBe(false)
})
it('源就是目标 → 幂等不报错', async () => {
const root = tempDir('cc-projects-')
const dir = writeSession(root, '-proj', SID, 'x\n')
await installClaudeSession({ sid: SID, srcDir: dir, dstProjectsDir: dir, projectsRoot: root })
expect(readFileSync(path.join(dir, `${SID}.jsonl`), 'utf8')).toBe('x\n')
})
it('源缺 jsonl → 抛错', async () => {
const root = tempDir('cc-projects-')
const src = tempDir('cc-src-')
await expect(installClaudeSession({ sid: SID, srcDir: src, dstProjectsDir: path.join(root, '-x'), projectsRoot: root })).rejects.toThrow(SID)
})
})
describe('codex', () => {
const UUID = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a'
it('按文件名尾部 uuid 定位 rollout,返回相对路径', async () => {
const codexRoot = tempDir('codex-')
const day = path.join(codexRoot, '2026', '08', '26')
mkdirSync(day, { recursive: true })
writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${UUID}.jsonl`), '{}\n')
expect(await codexRolloutRelPathFor(UUID.toUpperCase(), codexRoot)).toBe(path.join('2026', '08', '26', `rollout-2026-08-26T01-02-03-${UUID}.jsonl`))
expect(await codexRolloutRelPathFor('00000000-0000-4000-8000-000000000000', codexRoot)).toBeUndefined()
})
it('installCodexSession 按 relPath 落到 codexRoot 下并覆盖', async () => {
const codexRoot = tempDir('codex-')
const src = tempDir('codex-src-')
const rel = path.join('2026', '08', '26', `rollout-2026-08-26T01-02-03-${UUID}.jsonl`)
writeFileSync(path.join(src, 'r.jsonl'), 'new\n')
await installCodexSession(rel, path.join(src, 'r.jsonl'), codexRoot)
expect(readFileSync(path.join(codexRoot, rel), 'utf8')).toBe('new\n')
})
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm test src/cc/sessionBundle.test.ts`
Expected: FAIL(模块不存在)
- [ ] **Step 3: 实现**
`src/cc/sessionBundle.ts`
```ts
/**
* claude / codex 会话文件的搬运原语。
*
* claude 会话 = `<projectsDir>/<sid>.jsonl` + 可选 `<projectsDir>/<sid>/`
* subagents、tool-results),两者必须一起搬。新版 claude 在任意目录
* `--resume` 时会搜全部 project 目录,同一 sid 出现两份就拒绝 resume——
* 所以「安装」总是先拷再删本机其它副本。
*/
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { ROLLOUT_UUID_REGEX } from './codexSessionWatcher'
export interface ClaudeSessionFiles {
projectsDir: string
jsonl: string
dir?: string
}
export function claudeProjectsRoot(): string {
return path.join(os.homedir(), '.claude', 'projects')
}
async function exists(p: string): Promise<boolean> {
try {
await fsp.stat(p)
return true
}
catch {
return false
}
}
async function sessionFilesIn(projectsDir: string, sid: string): Promise<ClaudeSessionFiles | undefined> {
const jsonl = path.join(projectsDir, `${sid}.jsonl`)
if (!(await exists(jsonl)))
return undefined
const dir = path.join(projectsDir, sid)
return (await exists(dir)) ? { projectsDir, jsonl, dir } : { projectsDir, jsonl }
}
export async function listClaudeSessionCopies(sid: string, projectsRoot: string): Promise<ClaudeSessionFiles[]> {
let entries: string[]
try {
entries = await fsp.readdir(projectsRoot)
}
catch {
return []
}
const out: ClaudeSessionFiles[] = []
for (const name of entries) {
const found = await sessionFilesIn(path.join(projectsRoot, name), sid)
if (found)
out.push(found)
}
return out
}
export async function findClaudeSessionFiles(sid: string, projectsRoot: string): Promise<ClaudeSessionFiles | undefined> {
const copies = await listClaudeSessionCopies(sid, projectsRoot)
if (copies.length === 0)
return undefined
const stamped = await Promise.all(copies.map(async c => ({ c, mtime: (await fsp.stat(c.jsonl)).mtimeMs })))
stamped.sort((a, b) => b.mtime - a.mtime)
return stamped[0].c
}
export async function copyClaudeSessionFiles(files: ClaudeSessionFiles, dstDir: string): Promise<void> {
await fsp.mkdir(dstDir, { recursive: true })
await fsp.copyFile(files.jsonl, path.join(dstDir, path.basename(files.jsonl)))
if (files.dir)
await fsp.cp(files.dir, path.join(dstDir, path.basename(files.dir)), { recursive: true, force: true })
}
async function removeClaudeSessionFiles(files: ClaudeSessionFiles): Promise<void> {
await fsp.rm(files.jsonl, { force: true })
if (files.dir)
await fsp.rm(files.dir, { recursive: true, force: true })
}
export async function installClaudeSession(opts: {
sid: string
srcDir: string
dstProjectsDir: string
projectsRoot: string
}): Promise<void> {
const src = await sessionFilesIn(path.resolve(opts.srcDir), opts.sid)
if (!src)
throw new Error(`源目录缺少会话文件 ${opts.sid}.jsonl${opts.srcDir}`)
const dst = path.resolve(opts.dstProjectsDir)
if (src.projectsDir !== dst)
await copyClaudeSessionFiles(src, dst)
for (const copy of await listClaudeSessionCopies(opts.sid, opts.projectsRoot)) {
if (path.resolve(copy.projectsDir) !== dst)
await removeClaudeSessionFiles(copy)
}
}
/** codex rollout 全局存放,按文件名尾部 uuid 定位;返回相对 codexRoot 的路径。 */
export async function codexRolloutRelPathFor(sid: string, codexRoot: string): Promise<string | undefined> {
const want = sid.toLowerCase()
let entries: string[]
try {
entries = await fsp.readdir(codexRoot, { recursive: true })
}
catch {
return undefined
}
for (const entry of entries) {
const m = path.basename(entry).match(ROLLOUT_UUID_REGEX)
if (m && m[1].toLowerCase() === want)
return entry
}
return undefined
}
export async function installCodexSession(relPath: string, srcFile: string, codexRoot: string): Promise<void> {
const dst = path.join(codexRoot, relPath)
await fsp.mkdir(path.dirname(dst), { recursive: true })
await fsp.copyFile(srcFile, dst)
}
```
- [ ] **Step 4: 跑测试 + 提交**
Run: `pnpm test src/cc/sessionBundle.test.ts && pnpm typecheck && pnpm lint`
Expected: PASS
```bash
git add src/cc/sessionBundle.ts src/cc/sessionBundle.test.ts
git commit -m "✨ feat(vscode): sessionBundle 会话文件查找/安装/去重原语"
```
---
### Task 5: `cc/handoffManifest.ts`
**Files:**
- Create: `src/cc/handoffManifest.ts`
- Create: `src/cc/handoffManifest.test.ts`
**Interfaces:**
- Produces:
```ts
export type ClaudeSessionKind = 'brainstorm' | 'implement' | 'test'
export const CLAUDE_SESSION_FIELDS: ReadonlyArray<{ field: 'sessionId' | 'implementSessionId' | 'testSessionId', kind: ClaudeSessionKind }>
export interface HandoffSessions { sessionId?: string, implementSessionId?: string, testSessionId?: string, reviewSessionId?: string }
export interface HandoffProfiles { profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string }
export interface HandoffManifest {
version: 1, issue: number, from: string, createdAt: string, branch?: string,
sessions: HandoffSessions, profiles: HandoffProfiles,
claude: Array<{ id: string, kind: ClaudeSessionKind }>,
codex: Array<{ id: string, relPath: string }>
}
export const HANDOFF_MANIFEST_FILE = 'handoff.json'
export function handoffAttachmentName(issueNumber: number): string // `spx-handoff-issue-${n}.tgz`
export function parseHandoffManifest(json: string, expectedIssue: number): HandoffManifest // 不合法抛 Error(中文)
```
- [ ] **Step 1: 写测试**
`src/cc/handoffManifest.test.ts`
```ts
import { describe, expect, it } from 'vitest'
import { handoffAttachmentName, parseHandoffManifest } from './handoffManifest'
const good = {
version: 1,
issue: 42,
from: 'chw',
createdAt: '2026-08-26T00:00:00Z',
branch: 'feature/x',
sessions: { implementSessionId: 'a' },
profiles: {},
claude: [{ id: 'a', kind: 'implement' }],
codex: [],
}
describe('handoffAttachmentName', () => {
it('固定命名', () => {
expect(handoffAttachmentName(42)).toBe('spx-handoff-issue-42.tgz')
})
})
describe('parseHandoffManifest', () => {
it('合法清单原样返回', () => {
expect(parseHandoffManifest(JSON.stringify(good), 42)).toEqual(good)
})
it('issue 不匹配 → 抛错', () => {
expect(() => parseHandoffManifest(JSON.stringify(good), 43)).toThrow(/42/)
})
it('version 不是 1 → 抛错', () => {
expect(() => parseHandoffManifest(JSON.stringify({ ...good, version: 2 }), 42)).toThrow(/version/)
})
it('claude 条目 kind 非法 → 抛错', () => {
expect(() => parseHandoffManifest(JSON.stringify({ ...good, claude: [{ id: 'a', kind: 'x' }] }), 42)).toThrow(/claude/)
})
it('不是 JSON → 抛错', () => {
expect(() => parseHandoffManifest('nope', 42)).toThrow()
})
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm test src/cc/handoffManifest.test.ts`
Expected: FAIL
- [ ] **Step 3: 实现**
`src/cc/handoffManifest.ts`
```ts
export type ClaudeSessionKind = 'brainstorm' | 'implement' | 'test'
export const CLAUDE_SESSION_FIELDS = [
{ field: 'sessionId', kind: 'brainstorm' },
{ field: 'implementSessionId', kind: 'implement' },
{ field: 'testSessionId', kind: 'test' },
] as const satisfies ReadonlyArray<{ field: 'sessionId' | 'implementSessionId' | 'testSessionId', kind: ClaudeSessionKind }>
export interface HandoffSessions {
sessionId?: string
implementSessionId?: string
testSessionId?: string
reviewSessionId?: string
}
export interface HandoffProfiles {
profilePath?: string
brainstormProfilePath?: string
testProfilePath?: string
}
export interface HandoffManifest {
version: 1
issue: number
from: string
createdAt: string
branch?: string
sessions: HandoffSessions
profiles: HandoffProfiles
claude: Array<{ id: string, kind: ClaudeSessionKind }>
codex: Array<{ id: string, relPath: string }>
}
export const HANDOFF_MANIFEST_FILE = 'handoff.json'
export function handoffAttachmentName(issueNumber: number): string {
return `spx-handoff-issue-${issueNumber}.tgz`
}
const KINDS: ReadonlySet<string> = new Set<ClaudeSessionKind>(['brainstorm', 'implement', 'test'])
function optString(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined
}
export function parseHandoffManifest(json: string, expectedIssue: number): HandoffManifest {
const raw = JSON.parse(json) as Record<string, unknown>
if (!raw || typeof raw !== 'object')
throw new Error('handoff.json 不是对象')
if (raw.version !== 1)
throw new Error(`handoff.json version 不支持:${String(raw.version)}`)
if (raw.issue !== expectedIssue)
throw new Error(`handoff.json 属于工单 #${String(raw.issue)},不是 #${expectedIssue}`)
const from = optString(raw.from)
const createdAt = optString(raw.createdAt)
if (!from || !createdAt)
throw new Error('handoff.json 缺少 from / createdAt')
const sessionsRaw = (raw.sessions ?? {}) as Record<string, unknown>
const profilesRaw = (raw.profiles ?? {}) as Record<string, unknown>
const claudeRaw = Array.isArray(raw.claude) ? raw.claude : []
const codexRaw = Array.isArray(raw.codex) ? raw.codex : []
const claude = claudeRaw.map((e) => {
const o = e as Record<string, unknown>
const id = optString(o.id)
const kind = optString(o.kind)
if (!id || !kind || !KINDS.has(kind))
throw new Error(`handoff.json claude 条目非法:${JSON.stringify(e)}`)
return { id, kind: kind as ClaudeSessionKind }
})
const codex = codexRaw.map((e) => {
const o = e as Record<string, unknown>
const id = optString(o.id)
const relPath = optString(o.relPath)
if (!id || !relPath || relPath.startsWith('/') || relPath.includes('..'))
throw new Error(`handoff.json codex 条目非法:${JSON.stringify(e)}`)
return { id, relPath }
})
const sessions: HandoffSessions = {}
for (const k of ['sessionId', 'implementSessionId', 'testSessionId', 'reviewSessionId'] as const) {
const v = optString(sessionsRaw[k])
if (v)
sessions[k] = v
}
const profiles: HandoffProfiles = {}
for (const k of ['profilePath', 'brainstormProfilePath', 'testProfilePath'] as const) {
const v = optString(profilesRaw[k])
if (v)
profiles[k] = v
}
return {
version: 1,
issue: expectedIssue,
from,
createdAt,
...(optString(raw.branch) ? { branch: optString(raw.branch) } : {}),
sessions,
profiles,
claude,
codex,
}
}
```
- [ ] **Step 4: 跑测试 + 提交**
Run: `pnpm test src/cc/handoffManifest.test.ts && pnpm typecheck && pnpm lint`
Expected: PASS
```bash
git add src/cc/handoffManifest.ts src/cc/handoffManifest.test.ts
git commit -m "✨ feat(vscode): handoff 清单类型与校验"
```
---
### Task 6: `cc/handoffArchive.ts` + `cc/handoffBundle.ts`
**Files:**
- Create: `src/cc/handoffArchive.ts`
- Create: `src/cc/handoffBundle.ts`
- Create: `src/cc/handoffBundle.test.ts`
**Interfaces:**
- Consumes: Task 4 全部、Task 5 类型。
- Produces:
```ts
// handoffArchive.ts
export async function packHandoff(stagingDir: string, outFile: string): Promise<void>
export async function unpackHandoff(archive: string, dstDir: string): Promise<void>
// handoffBundle.ts
export interface HandoffSource { issueNumber: number, from: string, branch?: string, sessions: HandoffSessions, profiles: HandoffProfiles }
export interface HandoffRoots { projectsRoot: string, codexRoot: string }
export async function stageHandoffBundle(src: HandoffSource, stagingDir: string, roots: HandoffRoots, now?: Date): Promise<HandoffManifest>
export interface HandoffInstallTargets extends HandoffRoots { workspaceProjectsDir: string, worktreeProjectsDir?: string }
export async function installHandoffBundle(extractedDir: string, manifest: HandoffManifest, targets: HandoffInstallTargets): Promise<void>
```
`stageHandoffBundle`:在 `stagingDir` 下生成 `claude/`、`codex/`、`handoff.json`,返回清单(找不到的会话不进 `claude`/`codex` 列表,但 `sessions` 仍原样记录 id)。
`installHandoffBundle`brainstorm → `workspaceProjectsDir`implement/test → `worktreeProjectsDir ?? workspaceProjectsDir`codex → `codexRoot/<relPath>`。
- [ ] **Step 1: 写测试(stage → pack → unpack → install 全链路)**
`src/cc/handoffBundle.test.ts`
```ts
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { packHandoff, unpackHandoff } from './handoffArchive'
import { installHandoffBundle, stageHandoffBundle } from './handoffBundle'
import { HANDOFF_MANIFEST_FILE, parseHandoffManifest } from './handoffManifest'
const tempDirs: string[] = []
afterEach(() => {
while (tempDirs.length > 0) {
const d = tempDirs.pop()
if (d)
rmSync(d, { recursive: true, force: true })
}
})
function tempDir(prefix: string): string {
const d = mkdtempSync(path.join(os.tmpdir(), prefix))
tempDirs.push(d)
return d
}
const BRAIN = 'aaaaaaaa-0000-4000-8000-000000000001'
const IMPL = 'bbbbbbbb-0000-4000-8000-000000000002'
const REVIEW = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a'
function senderRoots(): { projectsRoot: string, codexRoot: string } {
const projectsRoot = tempDir('sender-projects-')
const codexRoot = tempDir('sender-codex-')
const proj = path.join(projectsRoot, '-Users-chw-proj')
const wt = path.join(projectsRoot, '-Users-chw-wt-x')
mkdirSync(proj, { recursive: true })
mkdirSync(path.join(wt, IMPL, 'subagents'), { 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')
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')
return { projectsRoot, codexRoot }
}
describe('stageHandoffBundle', () => {
it('收集存在的会话,缺失的不进列表', async () => {
const roots = senderRoots()
const staging = tempDir('staging-')
const manifest = await stageHandoffBundle({
issueNumber: 7,
from: 'chw',
branch: 'feature/x',
sessions: { sessionId: BRAIN, implementSessionId: IMPL, testSessionId: 'missing-0000-4000-8000-000000000000', reviewSessionId: REVIEW },
profiles: { profilePath: '/Users/chw/p.json' },
}, staging, roots, new Date('2026-08-26T00:00:00Z'))
expect(manifest.claude).toEqual([{ id: BRAIN, kind: 'brainstorm' }, { id: IMPL, kind: 'implement' }])
expect(manifest.codex).toEqual([{ id: REVIEW, relPath: path.join('2026', '08', '26', `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`) }])
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, 'codex', manifest.codex[0].relPath))).toBe(true)
expect(parseHandoffManifest(readFileSync(path.join(staging, HANDOFF_MANIFEST_FILE), 'utf8'), 7)).toEqual(manifest)
})
})
describe('pack → unpack → install', () => {
it('接管方装到对应 project 目录与 codex 目录', async () => {
const roots = senderRoots()
const staging = tempDir('staging-')
const manifest = await stageHandoffBundle({
issueNumber: 7,
from: 'chw',
branch: 'feature/x',
sessions: { sessionId: BRAIN, implementSessionId: IMPL, reviewSessionId: REVIEW },
profiles: {},
}, staging, roots)
const archive = path.join(tempDir('archive-'), 'spx-handoff-issue-7.tgz')
await packHandoff(staging, archive)
expect(existsSync(archive)).toBe(true)
const extracted = tempDir('extracted-')
await unpackHandoff(archive, extracted)
const parsed = parseHandoffManifest(readFileSync(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'), 7)
expect(parsed).toEqual(manifest)
const projectsRoot = tempDir('receiver-projects-')
const codexRoot = tempDir('receiver-codex-')
const workspaceProjectsDir = path.join(projectsRoot, '-home-me-proj')
const worktreeProjectsDir = path.join(projectsRoot, '-home-me-wt-x')
// 本机残留的同 sid 副本必须被清掉
mkdirSync(path.join(projectsRoot, '-stale'), { recursive: true })
writeFileSync(path.join(projectsRoot, '-stale', `${IMPL}.jsonl`), 'stale\n')
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(projectsRoot, '-stale', `${IMPL}.jsonl`))).toBe(false)
expect(readFileSync(path.join(codexRoot, parsed.codex[0].relPath), 'utf8')).toBe('review\n')
})
it('无 worktreeProjectsDir 时实施会话也装到工作区目录', async () => {
const roots = senderRoots()
const staging = tempDir('staging-')
const manifest = await stageHandoffBundle({ issueNumber: 7, from: 'chw', sessions: { implementSessionId: IMPL }, profiles: {} }, staging, roots)
const projectsRoot = tempDir('receiver-projects-')
const workspaceProjectsDir = path.join(projectsRoot, '-home-me-proj')
await installHandoffBundle(staging, manifest, { projectsRoot, codexRoot: tempDir('receiver-codex-'), workspaceProjectsDir })
expect(existsSync(path.join(workspaceProjectsDir, `${IMPL}.jsonl`))).toBe(true)
})
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm test src/cc/handoffBundle.test.ts`
Expected: FAIL
- [ ] **Step 3: 实现 handoffArchive.ts**
```ts
import { execFile } from 'node:child_process'
import { promises as fsp } from 'node:fs'
import * as path from 'node:path'
function tar(args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
execFile('tar', args, { timeout: 120_000 }, (err, _stdout, stderr) => {
if (err) {
reject(new Error(`tar ${args[0]} 失败: ${(stderr ?? '').toString().trim() || err.message}`))
return
}
resolve()
})
})
}
export async function packHandoff(stagingDir: string, outFile: string): Promise<void> {
await fsp.mkdir(path.dirname(outFile), { recursive: true })
await tar(['-czf', outFile, '-C', stagingDir, '.'])
}
export async function unpackHandoff(archive: string, dstDir: string): Promise<void> {
await fsp.mkdir(dstDir, { recursive: true })
await tar(['-xzf', archive, '-C', dstDir])
}
```
- [ ] **Step 4: 实现 handoffBundle.ts**
```ts
import type { HandoffManifest, HandoffProfiles, HandoffSessions } from './handoffManifest'
import { promises as fsp } from 'node:fs'
import * as path from 'node:path'
import { CLAUDE_SESSION_FIELDS, HANDOFF_MANIFEST_FILE } from './handoffManifest'
import {
codexRolloutRelPathFor,
copyClaudeSessionFiles,
findClaudeSessionFiles,
installClaudeSession,
installCodexSession,
} from './sessionBundle'
export interface HandoffSource {
issueNumber: number
from: string
branch?: string
sessions: HandoffSessions
profiles: HandoffProfiles
}
export interface HandoffRoots {
projectsRoot: string
codexRoot: string
}
/** 把本机会话文件收进 stagingDirclaude/、codex/、handoff.json),返回清单。找不到的会话只从文件列表缺席,id 仍记录。 */
export async function stageHandoffBundle(
src: HandoffSource,
stagingDir: string,
roots: HandoffRoots,
now: Date = new Date(),
): Promise<HandoffManifest> {
const claudeDir = path.join(stagingDir, 'claude')
const codexDir = path.join(stagingDir, 'codex')
await fsp.mkdir(claudeDir, { recursive: true })
await fsp.mkdir(codexDir, { recursive: true })
const claude: HandoffManifest['claude'] = []
for (const { field, kind } of CLAUDE_SESSION_FIELDS) {
const id = src.sessions[field]
if (!id)
continue
const files = await findClaudeSessionFiles(id, roots.projectsRoot)
if (!files)
continue
await copyClaudeSessionFiles(files, claudeDir)
claude.push({ id, kind })
}
const codex: HandoffManifest['codex'] = []
if (src.sessions.reviewSessionId) {
const relPath = await codexRolloutRelPathFor(src.sessions.reviewSessionId, roots.codexRoot)
if (relPath) {
const dst = path.join(codexDir, relPath)
await fsp.mkdir(path.dirname(dst), { recursive: true })
await fsp.copyFile(path.join(roots.codexRoot, relPath), dst)
codex.push({ id: src.sessions.reviewSessionId, relPath })
}
}
const manifest: HandoffManifest = {
version: 1,
issue: src.issueNumber,
from: src.from,
createdAt: now.toISOString(),
...(src.branch ? { branch: src.branch } : {}),
sessions: src.sessions,
profiles: src.profiles,
claude,
codex,
}
await fsp.writeFile(path.join(stagingDir, HANDOFF_MANIFEST_FILE), JSON.stringify(manifest, null, 2))
return manifest
}
export interface HandoffInstallTargets extends HandoffRoots {
workspaceProjectsDir: string
worktreeProjectsDir?: string
}
export async function installHandoffBundle(
extractedDir: string,
manifest: HandoffManifest,
targets: HandoffInstallTargets,
): Promise<void> {
const claudeDir = path.join(extractedDir, 'claude')
for (const { id, kind } of manifest.claude) {
const dstProjectsDir = kind === 'brainstorm'
? targets.workspaceProjectsDir
: (targets.worktreeProjectsDir ?? targets.workspaceProjectsDir)
await installClaudeSession({ sid: id, srcDir: claudeDir, dstProjectsDir, projectsRoot: targets.projectsRoot })
}
for (const { relPath } of manifest.codex)
await installCodexSession(relPath, path.join(extractedDir, 'codex', relPath), targets.codexRoot)
}
```
- [ ] **Step 5: 跑测试 + 提交**
Run: `pnpm test src/cc/handoffBundle.test.ts && pnpm typecheck && pnpm lint`
Expected: PASS
```bash
git add src/cc/handoffArchive.ts src/cc/handoffBundle.ts src/cc/handoffBundle.test.ts
git commit -m "✨ feat(vscode): handoff 会话包 stage/pack/unpack/install"
```
---
### Task 7: Gitea APIassignees 列表 + 附件
**Files:**
- Modify: `src/gitea/api.ts`(末尾追加)
**Interfaces:**
- Produces:
```ts
export interface GiteaAttachment { id: number, name: string, size: number, uuid: string, browser_download_url: string, created_at: string }
export async function listRepoAssignees(opts: { host, token, owner, repo }): Promise<GiteaUser[]>
export async function listIssueAttachments(opts: { host, token, owner, repo, index: number }): Promise<GiteaAttachment[]>
export async function uploadIssueAttachment(opts: { host, token, owner, repo, index: number, name: string, data: Buffer }): Promise<GiteaAttachment>
export async function getIssueAttachment(opts: { host, token, owner, repo, index: number, attachmentId: number }): Promise<GiteaAttachment>
export async function downloadAttachment(opts: { token: string, url: string }): Promise<Buffer>
export async function deleteIssueAttachment(opts: { host, token, owner, repo, index: number, attachmentId: number }): Promise<void>
```
- [ ] **Step 1: 实现**
在 `src/gitea/api.ts` 末尾追加(`Buffer` 从 `'node:buffer'` import`FormData`/`Blob` 用 Node 22 全局):
```ts
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
token: string
owner: string
repo: string
}): Promise<GiteaUser[]> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/assignees`,
{ headers: authHeaders(opts.token) },
)
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()
form.append('attachment', new Blob([opts.data], { type: 'application/gzip' }), opts.name)
// 不能带 Content-Typemultipart 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
}
export async function getIssueAttachment(opts: {
host: string
token: string
owner: string
repo: string
index: number
attachmentId: number
}): Promise<GiteaAttachment> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets/${opts.attachmentId}`,
{ headers: authHeaders(opts.token) },
)
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)
}
```
- [ ] **Step 2: 用真实 Gitea 验证下载鉴权(一次性脚本,放 scratchpad,不入库)**
在本仓库自己的 Gitea`gitea.ailoveworld.cn``git remote -v` 取 owner/repo)上挑一个已关闭的测试工单 N,token 从 `~/.config/tea/config.yml` 读(若 token 走 keyring,取 `GITEA_TOKEN` 环境变量):
```bash
T=<token>; O=<owner>; R=<repo>; N=<issue>
echo hi > /tmp/claude-1000/-home-cruldra-Sources-superwork/62cd8318-2051-42ed-9511-9c135e5f9c93/scratchpad/t.txt
curl -s -H "Authorization: token $T" -F "attachment=@/tmp/claude-1000/-home-cruldra-Sources-superwork/62cd8318-2051-42ed-9511-9c135e5f9c93/scratchpad/t.txt" \
"https://gitea.ailoveworld.cn/api/v1/repos/$O/$R/issues/$N/assets?name=t.txt" | tee /dev/stderr | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['id'], d['browser_download_url'])"
# 用输出的 URL
curl -sL -o /dev/null -w '%{http_code} %{content_type}\n' -H "Authorization: token $T" <browser_download_url>
# 期望 200 且 content_type 不是 text/html;然后删掉测试附件:
curl -s -X DELETE -H "Authorization: token $T" "https://gitea.ailoveworld.cn/api/v1/repos/$O/$R/issues/$N/assets/<id>"
```
Expected: `200 text/plain…`。若返回 HTML / 302 登录页,改 `downloadAttachment` 为请求 `https://<host>/attachments/<uuid>` 并加 `?token=` 方案前先把结果报告给用户再定。
- [ ] **Step 3: typecheck + 提交**
Run: `pnpm typecheck && pnpm lint`
Expected: PASS
```bash
git add src/gitea/api.ts
git commit -m "✨ feat(vscode): Gitea API 增加 assignees 列表与工单附件读写"
```
---
### Task 8: `panel/handlers/handoff.ts`(纯决策 + payload
**Files:**
- Create: `src/panel/handlers/handoff.ts`
- Create: `src/panel/handlers/handoff.test.ts`
**Interfaces:**
- Produces:
```ts
export function canStartHandoff(issue: { source?: 'gitea' | 'youtrack', column: IssueColumn, handoffAttachmentId?: string }): boolean
export function canAcceptHandoff(issue: { source?: 'gitea' | 'youtrack', handoffAttachmentId?: string, assignees?: string[] }, me: string | undefined): boolean
export function checkWorktreePushed(input: { statusPorcelain: string, localHead: string, remoteHead: string }): { ok: true } | { ok: false, reason: string }
export function handoffStartedStateExtra(attachmentId: number, from: string): Record<string, unknown>
export function handoffStartedUiPatch(attachmentId: number, from: string, to: string): Record<string, unknown>
export function handoffAcceptedStateExtra(local: { sessions: HandoffSessions, profiles: HandoffProfiles, worktreePath?: string }): Record<string, unknown>
export function handoffAcceptedUiPatch(local: { sessions: HandoffSessions, profiles: HandoffProfiles, worktreePath?: string, worktreeExists: boolean }): Record<string, unknown>
```
- [ ] **Step 1: 写测试**
`src/panel/handlers/handoff.test.ts`
```ts
import { describe, expect, it } from 'vitest'
import {
canAcceptHandoff,
canStartHandoff,
checkWorktreePushed,
handoffAcceptedStateExtra,
handoffAcceptedUiPatch,
handoffStartedStateExtra,
handoffStartedUiPatch,
} from './handoff'
describe('canStartHandoff', () => {
it('gitea 工单、非 done、无待接管 → true', () => {
expect(canStartHandoff({ column: 'in-progress' })).toBe(true)
expect(canStartHandoff({ column: 'todo', source: 'gitea' })).toBe(true)
})
it('done / youtrack / 已在移交中 → false', () => {
expect(canStartHandoff({ column: 'done' })).toBe(false)
expect(canStartHandoff({ column: 'review', source: 'youtrack' })).toBe(false)
expect(canStartHandoff({ column: 'review', handoffAttachmentId: '9' })).toBe(false)
})
})
describe('canAcceptHandoff', () => {
it('有附件且 assignee 含我 → true', () => {
expect(canAcceptHandoff({ handoffAttachmentId: '9', assignees: ['me', 'x'] }, 'me')).toBe(true)
})
it('无附件 / 不是我的 / 身份未知 / youtrack → false', () => {
expect(canAcceptHandoff({ assignees: ['me'] }, 'me')).toBe(false)
expect(canAcceptHandoff({ handoffAttachmentId: '9', assignees: ['x'] }, 'me')).toBe(false)
expect(canAcceptHandoff({ handoffAttachmentId: '9', assignees: ['me'] }, undefined)).toBe(false)
expect(canAcceptHandoff({ source: 'youtrack', handoffAttachmentId: '9', assignees: ['me'] }, 'me')).toBe(false)
})
})
describe('checkWorktreePushed', () => {
it('干净且 HEAD 与远端一致 → ok', () => {
expect(checkWorktreePushed({ statusPorcelain: '', localHead: 'abc', remoteHead: 'abc' })).toEqual({ ok: true })
})
it('有未提交改动 → 拒绝', () => {
const r = checkWorktreePushed({ statusPorcelain: ' M a.ts\n', localHead: 'abc', remoteHead: 'abc' })
expect(r.ok).toBe(false)
if (!r.ok)
expect(r.reason).toMatch(/未提交/)
})
it('本地领先远端 → 拒绝', () => {
const r = checkWorktreePushed({ statusPorcelain: '', localHead: 'abc', remoteHead: 'def' })
expect(r.ok).toBe(false)
if (!r.ok)
expect(r.reason).toMatch(/push/)
})
})
describe('payloads', () => {
it('移交落盘:两个共享字段 + 本机字段全部墓碑', () => {
expect(handoffStartedStateExtra(9, 'chw')).toEqual({
handoffAttachmentId: '9',
handoffFrom: 'chw',
sessionId: '',
implementSessionId: '',
reviewSessionId: '',
testSessionId: '',
profilePath: '',
brainstormProfilePath: '',
testProfilePath: '',
worktreePath: '',
prDiffFile: '',
})
})
it('移交 UI patch 用 null 清字段并更新 assignees', () => {
expect(handoffStartedUiPatch(9, 'chw', 'me')).toEqual({
handoffAttachmentId: '9',
handoffFrom: 'chw',
assignees: ['me'],
sessionId: null,
implementSessionId: null,
reviewSessionId: null,
testSessionId: null,
worktreePath: null,
worktreeExists: false,
})
})
it('接管落盘:清两个共享字段,写本机 sid / profile / worktree', () => {
expect(handoffAcceptedStateExtra({
sessions: { implementSessionId: 'i', reviewSessionId: 'r' },
profiles: { profilePath: '/p.json' },
worktreePath: '/wt',
})).toEqual({
handoffAttachmentId: '',
handoffFrom: '',
implementSessionId: 'i',
reviewSessionId: 'r',
profilePath: '/p.json',
worktreePath: '/wt',
})
})
it('接管 UI patch', () => {
expect(handoffAcceptedUiPatch({
sessions: { sessionId: 's' },
profiles: {},
worktreePath: '/wt',
worktreeExists: true,
})).toEqual({
handoffAttachmentId: null,
handoffFrom: null,
sessionId: 's',
worktreePath: '/wt',
worktreeExists: true,
})
})
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm test src/panel/handlers/handoff.test.ts`
Expected: FAIL
- [ ] **Step 3: 实现**
`src/panel/handlers/handoff.ts`
```ts
import type { HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest'
import type { IssueColumn } from '../../gitea/types'
import { LOCAL_STATE_FIELDS } from '../../issues/localState'
export function canStartHandoff(issue: {
source?: 'gitea' | 'youtrack'
column: IssueColumn
handoffAttachmentId?: string
}): boolean {
return issue.source !== 'youtrack' && issue.column !== 'done' && !issue.handoffAttachmentId
}
export function canAcceptHandoff(issue: {
source?: 'gitea' | 'youtrack'
handoffAttachmentId?: string
assignees?: string[]
}, me: string | undefined): boolean {
return issue.source !== 'youtrack'
&& !!issue.handoffAttachmentId
&& me !== undefined
&& (issue.assignees ?? []).includes(me)
}
/** 移交前 worktree 必须「干净 + 全部 push」,否则接管方从远端重建会丢工作。 */
export function checkWorktreePushed(input: {
statusPorcelain: string
localHead: string
remoteHead: string
}): { ok: true } | { ok: false, reason: string } {
if (input.statusPorcelain.trim().length > 0)
return { ok: false, reason: '工作区有未提交改动,请先提交并 push' }
if (input.localHead.trim() !== input.remoteHead.trim())
return { ok: false, reason: '本地分支与远端不一致,请先 push' }
return { ok: true }
}
export function handoffStartedStateExtra(attachmentId: number, from: string): Record<string, unknown> {
const extra: Record<string, unknown> = { handoffAttachmentId: String(attachmentId), handoffFrom: from }
for (const f of LOCAL_STATE_FIELDS)
extra[f] = ''
return extra
}
export function handoffStartedUiPatch(attachmentId: number, from: string, to: string): Record<string, unknown> {
return {
handoffAttachmentId: String(attachmentId),
handoffFrom: from,
assignees: [to],
sessionId: null,
implementSessionId: null,
reviewSessionId: null,
testSessionId: null,
worktreePath: null,
worktreeExists: false,
}
}
function definedEntries(o: Record<string, string | undefined>): Record<string, string> {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(o)) {
if (v)
out[k] = v
}
return out
}
export function handoffAcceptedStateExtra(local: {
sessions: HandoffSessions
profiles: HandoffProfiles
worktreePath?: string
}): Record<string, unknown> {
return {
handoffAttachmentId: '',
handoffFrom: '',
...definedEntries(local.sessions),
...definedEntries(local.profiles),
...(local.worktreePath ? { worktreePath: local.worktreePath } : {}),
}
}
export function handoffAcceptedUiPatch(local: {
sessions: HandoffSessions
profiles: HandoffProfiles
worktreePath?: string
worktreeExists: boolean
}): Record<string, unknown> {
return {
handoffAttachmentId: null,
handoffFrom: null,
...definedEntries(local.sessions),
...definedEntries(local.profiles),
...(local.worktreePath ? { worktreePath: local.worktreePath } : {}),
worktreeExists: local.worktreeExists,
}
}
```
- [ ] **Step 4: 跑测试 + 提交**
Run: `pnpm test src/panel/handlers/handoff.test.ts && pnpm typecheck && pnpm lint`
Expected: PASS
```bash
git add src/panel/handlers/handoff.ts src/panel/handlers/handoff.test.ts
git commit -m "✨ feat(vscode): handoff 纯决策与 state/UI payload"
```
---
### Task 9: 消息类型 + `handoffFlow.ts` + KanbanPanel 分发
**Files:**
- Modify: `src/panel/messages.ts`、`webview-ui/src/lib/messages.ts`
- Create: `src/panel/handlers/handoffFlow.ts`
- Modify: `src/panel/KanbanPanel.ts:335+`
**Interfaces:**
- Consumes: Task 3 `ensureWorktreeFromRemote`Task 2 `resolveWorktreeDir` / `slugFromBranch` / `DEFAULT_WORKTREE_TEMPLATE`Task 6 `stageHandoffBundle` / `installHandoffBundle` / `packHandoff` / `unpackHandoff`Task 5 `handoffAttachmentName` / `parseHandoffManifest` / `HANDOFF_MANIFEST_FILE`Task 4 `claudeProjectsRoot`Task 7 APITask 8 决策。
- Produces:
- 消息:`WebviewToExtension` 加 `{ type: 'handoff/users', issueNumber: number }`、`{ type: 'handoff/start', issueNumber: number, to: string }`、`{ type: 'handoff/accept', issueNumber: number }``ExtensionToWebview` 加 `{ type: 'handoff/users-result', issueNumber: number, users: string[] }`、`{ type: 'handoff/done', issueNumber: number }`。
- `export async function handleHandoffUsers(panel, issueNumber): Promise<void>`
- `export async function handleHandoffStart(panel, issueNumber, to): Promise<void>`
- `export async function handleHandoffAccept(panel, issueNumber): Promise<void>`
- [ ] **Step 1: 消息类型(两份镜像)**
`src/panel/messages.ts` `ExtensionToWebview` 在 `issue/pr-diff-summary-done` 之后加:
```ts
| { type: 'handoff/users-result', issueNumber: number, users: string[] }
| { type: 'handoff/done', issueNumber: number }
```
`WebviewToExtension` 在 `column/change` 之后加:
```ts
| { type: 'handoff/users', issueNumber: number }
| { type: 'handoff/start', issueNumber: number, to: string }
| { type: 'handoff/accept', issueNumber: number }
```
`webview-ui/src/lib/messages.ts` 同样加。
- [ ] **Step 2: 写 `handoffFlow.ts`**
`src/panel/handlers/handoffFlow.ts`
```ts
import type { HandoffManifest, HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { execFile } from 'node:child_process'
import * as fs from 'node:fs'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { window, workspace } from 'vscode'
import { loginForToken } from '../../auth/identity'
import { getToken } from '../../auth/secrets'
import { packHandoff, unpackHandoff } from '../../cc/handoffArchive'
import { installHandoffBundle, stageHandoffBundle } from '../../cc/handoffBundle'
import { HANDOFF_MANIFEST_FILE, handoffAttachmentName, parseHandoffManifest } from '../../cc/handoffManifest'
import { claudeProjectsRoot } from '../../cc/sessionBundle'
import { projectsDirFor } from '../../cc/sessionWatcher'
import { detectRepo } from '../../git/remote'
import { ensureWorktreeFromRemote, killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir, slugFromBranch } from '../../git/worktreePath'
import {
deleteIssueAttachment,
downloadAttachment,
getIssueAttachment,
listIssueAttachments,
listRepoAssignees,
updateIssueAssignees,
uploadIssueAttachment,
} from '../../gitea/api'
import { logger } from '../../logging/logger'
import { defaultCodexSessionsDir } from '../../sessions/codexSessions'
import { getSettings } from '../../settings/store'
import { makeNonce } from '../KanbanPanel'
import { cleanupFeatureBranch } from './branchCleanup'
import {
checkWorktreePushed,
handoffAcceptedStateExtra,
handoffAcceptedUiPatch,
handoffStartedStateExtra,
handoffStartedUiPatch,
} from './handoff'
interface RepoCtx {
workspaceRoot: string
host: string
owner: string
repo: string
token: string
me: string
}
function toast(panel: KanbanWebviewPanel, level: 'info' | 'success' | 'error', message: string, extra?: { id?: string, spinner?: boolean, dismissOnTimer?: number }): string {
const id = extra?.id ?? makeNonce()
panel.postMessage({ type: 'toast/show', id, level, message, spinner: extra?.spinner, dismissOnTimer: extra?.dismissOnTimer ?? 6000 })
return id
}
function dismiss(panel: KanbanWebviewPanel, id: string): void {
panel.postMessage({ type: 'toast/dismiss', id })
}
async function resolveRepoCtx(panel: KanbanWebviewPanel): Promise<RepoCtx | undefined> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
toast(panel, 'error', '请先打开一个工作区文件夹')
return undefined
}
const remote = await detectRepo(workspaceRoot)
if (!remote) {
toast(panel, 'error', '当前工作区没有 Gitea 远程仓库')
return undefined
}
const token = await getToken(panel.context, remote.host)
if (!token) {
toast(panel, 'error', '请先完成 Gitea 配置')
return undefined
}
const me = await loginForToken(remote.host, token)
if (!me) {
toast(panel, 'error', '无法解析当前 Gitea 身份,token 是否失效?')
return undefined
}
return { workspaceRoot, host: remote.host, owner: remote.owner, repo: remote.repo, token, me }
}
function git(cwd: string, args: string[]): Promise<{ ok: boolean, stdout: string, stderr: string }> {
return new Promise((resolve) => {
execFile('git', ['-C', cwd, ...args], { timeout: 30_000, encoding: 'utf8' }, (err, stdout, stderr) => {
resolve({ ok: !err, stdout: (stdout ?? '').toString(), stderr: (stderr ?? '').toString() })
})
})
}
function str(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined
}
function scratchDir(prefix: string): Promise<string> {
return fsp.mkdtemp(path.join(os.tmpdir(), prefix))
}
export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
try {
const users = await listRepoAssignees(ctx)
panel.postMessage({
type: 'handoff/users-result',
issueNumber,
users: users.map(u => u.login).filter(l => l !== ctx.me).sort(),
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast(panel, 'error', `读取可指派用户失败:${message}`)
panel.postMessage({ type: 'handoff/users-result', issueNumber, users: [] })
}
}
/**
* 发送方:校验 worktree 已 push → 关终端 → 打包上传 → 写共享字段 → 重新指派 →
* 本机清理。远端三步(上传 / 写状态 / 指派)任一失败都回滚前面的远端改动;
* 本机清理失败只告警,移交在远端已经生效。
*/
export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise<void> {
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
const { workspaceRoot, host, owner, repo, token, me } = ctx
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
let state: Record<string, unknown>
try {
state = await panel.readIssueState(issueNumber)
}
catch (err) {
toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`)
finish()
return
}
const branch = str(state.branch)
const worktreePath = str(state.worktreePath)
const sessions: HandoffSessions = {
...(str(state.sessionId) ? { sessionId: str(state.sessionId) } : {}),
...(str(state.implementSessionId) ? { implementSessionId: str(state.implementSessionId) } : {}),
...(str(state.testSessionId) ? { testSessionId: str(state.testSessionId) } : {}),
...(str(state.reviewSessionId) ? { reviewSessionId: str(state.reviewSessionId) } : {}),
}
const profiles: HandoffProfiles = {
...(str(state.profilePath) ? { profilePath: str(state.profilePath) } : {}),
...(str(state.brainstormProfilePath) ? { brainstormProfilePath: str(state.brainstormProfilePath) } : {}),
...(str(state.testProfilePath) ? { testProfilePath: str(state.testProfilePath) } : {}),
}
// ① worktree 守卫:干净 + 与远端一致
const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined
if (worktreeAbs && fs.existsSync(worktreeAbs) && branch) {
const status = await git(worktreeAbs, ['status', '--porcelain'])
const fetched = await git(worktreeAbs, ['fetch', 'origin', branch])
if (!status.ok || !fetched.ok) {
toast(panel, 'error', `检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`)
finish()
return
}
const localHead = await git(worktreeAbs, ['rev-parse', 'HEAD'])
const remoteHead = await git(worktreeAbs, ['rev-parse', `origin/${branch}`])
const check = checkWorktreePushed({ statusPorcelain: status.stdout, localHead: localHead.stdout, remoteHead: remoteHead.stdout })
if (!check.ok) {
toast(panel, 'error', `无法移交 #${issueNumber}${check.reason}`)
finish()
return
}
}
const spinner = toast(panel, 'info', `正在移交 #${issueNumber} 给 ${to}…`, { spinner: true, dismissOnTimer: 120_000 })
// ② 关终端,等 jsonl 刷盘
let disposedAny = false
for (const [terminal, origin] of panel.terminalOrigin) {
if (origin.issueNumber === issueNumber) {
try {
terminal.dispose()
disposedAny = true
}
catch {}
}
}
for (const t of window.terminals) {
if (t.name === `issue-${issueNumber}-冲突解决`) {
try {
t.dispose()
disposedAny = true
}
catch {}
}
}
if (disposedAny)
await new Promise<void>(r => setTimeout(r, 600))
// ③ 打包 + 上传
let attachmentId: number
let manifest: HandoffManifest
// tgz 必须放在 staging 之外,否则 tar 会把半成品包也打进去。
const staging = await scratchDir('spx-handoff-')
const outDir = await scratchDir('spx-handoff-out-')
try {
manifest = await stageHandoffBundle(
{ issueNumber, from: me, branch, sessions, profiles },
staging,
{ projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir() },
)
const name = handoffAttachmentName(issueNumber)
const archive = path.join(outDir, name)
await packHandoff(staging, archive)
for (const old of await listIssueAttachments({ host, token, owner, repo, index: issueNumber })) {
if (old.name === name)
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId: old.id })
}
const uploaded = await uploadIssueAttachment({ host, token, owner, repo, index: issueNumber, name, data: await fsp.readFile(archive) })
attachmentId = uploaded.id
logger.add({ level: 'info', source: 'panel', message: `移交 #${issueNumber}:会话包已上传 (attachment ${attachmentId}, ${uploaded.size} bytes)`, details: JSON.stringify(manifest) })
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:打包/上传失败`, details: message })
dismiss(panel, spinner)
toast(panel, 'error', `移交失败(打包/上传):${message}`)
finish()
return
}
finally {
await fsp.rm(staging, { recursive: true, force: true })
await fsp.rm(outDir, { recursive: true, force: true })
}
// ④ 写共享字段 + 本机墓碑;⑤ 重新指派。失败回滚远端改动。
try {
await panel.mergeIssueState(issueNumber, handoffStartedStateExtra(attachmentId, me))
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
dismiss(panel, spinner)
toast(panel, 'error', `移交失败(写状态):${message}`)
finish()
return
}
try {
await updateIssueAssignees({ host, token, owner, repo, index: issueNumber, assignees: [to] })
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
await panel.mergeIssueState(issueNumber, { handoffAttachmentId: '', handoffFrom: '' }).catch(() => {})
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
dismiss(panel, spinner)
toast(panel, 'error', `移交失败(指派 ${to}):${message}`)
finish()
return
}
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)
if (worktreeAbs && fs.existsSync(worktreeAbs)) {
const settings = getSettings(panel.context)
try {
await panel.dispatchWorktreeHook('pre-remove', {
workspaceRoot,
worktreePath: worktreeAbs,
branch: branch ?? '',
issueNumber,
mainBranch: settings.devBranch || 'main',
customScriptPath: settings.worktreePreRemoveScript,
})
killProcessesUsingWorktree(worktreeAbs)
await removeWorktreeDir(workspaceRoot, worktreeAbs)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'warn', source: 'panel', message: `移交 #${issueNumber}:清理 worktree 失败`, details: message })
toast(panel, 'error', `已移交,但本机 worktree 清理失败:${message}`)
}
}
if (branch) {
const settings = getSettings(panel.context)
await cleanupFeatureBranch({ workspaceRoot, branch, issueNumber, devBranch: settings.devBranch, autoBuildBranch: settings.autoBuildBranch })
}
dismiss(panel, spinner)
panel.postMessage({ type: 'issue/patch', issueNumber, patch: handoffStartedUiPatch(attachmentId, me, to) })
toast(panel, 'success', `#${issueNumber} 已移交给 ${to}${manifest.claude.length} 个 claude 会话,${manifest.codex.length} 个 codex 会话)`)
finish()
}
/**
* 接管方:下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 →
* 清共享字段 + 删附件。前几步失败时附件还在,按钮仍显示,可重试。
*/
export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
const { workspaceRoot, host, owner, repo, token } = ctx
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
let state: Record<string, unknown>
try {
state = await panel.readIssueState(issueNumber)
}
catch (err) {
toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`)
finish()
return
}
const attachmentId = Number.parseInt(str(state.handoffAttachmentId) ?? '', 10)
if (!Number.isFinite(attachmentId)) {
toast(panel, 'error', `#${issueNumber} 没有待接管的会话包`)
finish()
return
}
const spinner = toast(panel, 'info', `正在接管 #${issueNumber}…`, { spinner: true, dismissOnTimer: 120_000 })
const extracted = await scratchDir('spx-takeover-')
try {
// ① 下载 + 解包 + 校验清单
const meta = await getIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId })
const data = await downloadAttachment({ token, url: meta.browser_download_url })
const archive = path.join(extracted, meta.name)
await fsp.writeFile(archive, data)
await unpackHandoff(archive, extracted)
const manifest = parseHandoffManifest(await fsp.readFile(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'), issueNumber)
// ② 重建 worktree
const branch = manifest.branch ?? str(state.branch)
let worktreeAbs: string | undefined
if (branch) {
const template = getSettings(panel.context).worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE
const templatePath = resolveWorktreeDir(template, workspaceRoot, slugFromBranch(branch))
worktreeAbs = await ensureWorktreeFromRemote({ workspaceRoot, worktreePath: templatePath, branch })
const settings = getSettings(panel.context)
await panel.dispatchWorktreeHook('post-create', {
workspaceRoot,
worktreePath: worktreeAbs,
branch,
issueNumber,
mainBranch: settings.devBranch || 'main',
customScriptPath: settings.worktreePostCreateScript,
})
}
// ③ 装会话文件
await installHandoffBundle(extracted, manifest, {
projectsRoot: claudeProjectsRoot(),
codexRoot: defaultCodexSessionsDir(),
workspaceProjectsDir: projectsDirFor(workspaceRoot),
...(worktreeAbs ? { worktreeProjectsDir: projectsDirFor(worktreeAbs) } : {}),
})
// ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn)
const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs }
await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local))
try {
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId })
}
catch (err) {
logger.add({ level: 'warn', source: 'panel', message: `接管 #${issueNumber}:删除附件失败`, details: err instanceof Error ? err.message : String(err) })
}
dismiss(panel, spinner)
panel.postMessage({
type: 'issue/patch',
issueNumber,
patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs }),
})
toast(panel, 'success', `已接管 #${issueNumber}(来自 ${manifest.from}${worktreeAbs ? `worktree${worktreeAbs}` : ''}`)
logger.add({ level: 'info', source: 'panel', message: `接管 #${issueNumber} 完成`, details: JSON.stringify({ manifest, worktreeAbs }) })
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `接管 #${issueNumber} 失败`, details: message })
dismiss(panel, spinner)
toast(panel, 'error', `接管失败:${message}`)
}
finally {
await fsp.rm(extracted, { recursive: true, force: true })
finish()
}
}
```
`toast/show` 的 `spinner` 为 `undefined` 时 postMessage 会丢键,无碍。
- [ ] **Step 3: KanbanPanel 分发**
`src/panel/KanbanPanel.ts` `handleMessage` 里 `column/change` 分支之后加:
```ts
if (msg.type === 'handoff/users') {
void handoffFlow.handleHandoffUsers(this, msg.issueNumber)
return
}
if (msg.type === 'handoff/start') {
void handoffFlow.handleHandoffStart(this, msg.issueNumber, msg.to)
return
}
if (msg.type === 'handoff/accept') {
void handoffFlow.handleHandoffAccept(this, msg.issueNumber)
return
}
```
顶部 `import * as handoffFlow from './handlers/handoffFlow'`。
- [ ] **Step 4: typecheck + lint + 提交**
Run: `pnpm typecheck && pnpm lint && pnpm test`
Expected: PASS
```bash
git add src/panel/messages.ts webview-ui/src/lib/messages.ts src/panel/handlers/handoffFlow.ts src/panel/KanbanPanel.ts
git commit -m "✨ feat(vscode): 移交/接管扩展端流程与消息分发"
```
---
### Task 10: 完成列会话归档改为整包搬迁
**Files:**
- Modify: `src/panel/handlers/issues.ts:383-433``// Before removing the worktree, copy the impl- and test-session jsonl …` 那一段)
**Interfaces:**
- Consumes: Task 4 `installClaudeSession`、`claudeProjectsRoot`。
- [ ] **Step 1: 替换实现**
把从 `// Before removing the worktree, copy the impl- and test-session jsonl into` 到该 `if` 块结束(含 `catch` 的 warn)整段替换为:
```ts
// 删 worktree 前把实施/测试会话整包(jsonl + 子目录)搬到主仓库 projects 目录,
// 并清掉 worktree 侧副本:新版 claude 遇到重复 sid 会拒绝 resume。
if ((implementSessionId || testSessionId) && worktreePath) {
const worktreeAbs = resolveWorktreePath(worktreePath, workspaceRoot)
const srcProjectsDir = projectsDirFor(worktreeAbs)
const dstProjectsDir = projectsDirFor(workspaceRoot)
for (const sid of [implementSessionId, testSessionId]) {
if (typeof sid !== 'string' || sid.length === 0)
continue
try {
await installClaudeSession({ sid, srcDir: srcProjectsDir, dstProjectsDir, projectsRoot: claudeProjectsRoot() })
logger.add({ level: 'info', source: 'panel', message: `cc 会话 ${sid} 已搬到主仓库 projects 目录 (issue #${issueNumber})` })
}
catch (err) {
logger.add({
level: 'warn',
source: 'panel',
message: `搬迁 cc 会话 ${sid} 失败 (issue #${issueNumber})worktree 清理仍继续`,
details: err instanceof Error ? err.message : String(err),
})
}
}
}
```
import`import { claudeProjectsRoot, installClaudeSession } from '../../cc/sessionBundle'`。若 `fs`/`path` 因此在该文件无其它引用,保留(文件其它地方仍在用)。
- [ ] **Step 2: typecheck + lint + 提交**
Run: `pnpm typecheck && pnpm lint && pnpm test`
Expected: PASS
```bash
git add src/panel/handlers/issues.ts
git commit -m "🐛 fix(vscode): 完成列归档会话改为整包搬迁并清理重复 sid"
```
---
### Task 11: webview:状态、按钮、选人弹窗、角标
**Files:**
- Create: `webview-ui/src/components/HandoffModal.tsx`
- Modify: `webview-ui/src/hooks/useIssues.ts`
- Modify: `webview-ui/src/App.tsx:340-380, 444-462`
- Modify: `webview-ui/src/components/BottomTabs.tsx:36-60, 120-140`
- Modify: `webview-ui/src/components/IssueDetailPanel.tsx:20-93, 574-608`
- Modify: `webview-ui/src/components/IssueCard.tsx`
- Modify: `webview-ui/src/components/KanbanBoard.tsx` / `KanbanColumn.tsx`(若 `IssueCard` 由它们渲染,无需传新 prop——角标直接读 `issue.handoffAttachmentId`
**Interfaces:**
- Consumes: Task 1 类型与消息、Task 9 消息。
- ProducesuseIssues 返回对象新增):`me: string | undefined`、`handoffUsers: { issueNumber: number, users: string[] } | null`、`requestHandoffUsers(issueNumber)`、`startHandoff(issueNumber, to)`、`acceptHandoff(issueNumber)`、`isHandoffRunning(issueNumber)`。
- [ ] **Step 1: useIssues**
在 `useIssues.ts` 状态区加:
```ts
const [me, setMe] = useState<string | undefined>(undefined)
const [handoffUsers, setHandoffUsers] = useState<{ issueNumber: number, users: string[] } | null>(null)
const handoffRunningRef = useRef<Set<number>>(new Set())
const [handoffRunning, setHandoffRunning] = useState<Set<number>>(new Set())
```
回调:
```ts
const requestHandoffUsers = useCallback((issueNumber: number): void => {
setHandoffUsers(null)
postMessage({ type: 'handoff/users', issueNumber })
}, [])
const markHandoffRunning = useCallback((issueNumber: number, running: boolean): void => {
if (running)
handoffRunningRef.current.add(issueNumber)
else
handoffRunningRef.current.delete(issueNumber)
setHandoffRunning(new Set(handoffRunningRef.current))
}, [])
const startHandoff = useCallback((issueNumber: number, to: string): void => {
if (handoffRunningRef.current.has(issueNumber))
return
markHandoffRunning(issueNumber, true)
postMessage({ type: 'handoff/start', issueNumber, to })
}, [markHandoffRunning])
const acceptHandoff = useCallback((issueNumber: number): void => {
if (handoffRunningRef.current.has(issueNumber))
return
markHandoffRunning(issueNumber, true)
postMessage({ type: 'handoff/accept', issueNumber })
}, [markHandoffRunning])
const isHandoffRunning = useCallback((issueNumber: number): boolean => handoffRunning.has(issueNumber), [handoffRunning])
```
`onMessage` switch`issues/update` 分支加 `setMe(msg.me)`;新增:
```ts
case 'handoff/users-result':
setHandoffUsers({ issueNumber: msg.issueNumber, users: msg.users })
break
case 'handoff/done':
markHandoffRunning(msg.issueNumber, false)
break
```
`useEffect` 依赖数组加 `markHandoffRunning`。返回对象加 `me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning`。
- [ ] **Step 2: HandoffModal**
`webview-ui/src/components/HandoffModal.tsx`
```tsx
import { useEffect, useState } from 'react'
import { Loader2, X } from 'lucide-react'
import { SelectMenu } from './ui/select-menu'
interface Props {
open: boolean
issueNumber: number | null
/** null = 候选列表还在加载 */
users: string[] | null
onCancel: () => void
onSubmit: (issueNumber: number, to: string) => void
}
export function HandoffModal({ open, issueNumber, users, onCancel, onSubmit }: Props) {
const [to, setTo] = useState<string>('')
useEffect(() => {
if (!open) {
setTo('')
return
}
function handleKey(e: KeyboardEvent): void {
if (e.key === 'Escape')
onCancel()
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [open, onCancel])
useEffect(() => {
if (users && users.length > 0 && !to)
setTo(users[0])
}, [users, to])
if (!open || issueNumber === null)
return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div
role="dialog"
aria-modal="true"
className="w-[360px] rounded border border-[var(--vscode-panel-border)] bg-[var(--vscode-editor-background)] p-4 text-sm shadow-lg"
>
<div className="mb-3 flex items-center">
<h2 className="flex-1 font-medium">
移交 #
{issueNumber}
</h2>
<button type="button" onClick={onCancel} aria-label="关闭" className="rounded p-1 hover:bg-white/10">
<X className="size-4" />
</button>
</div>
<p className="mb-3 text-xs opacity-70">
会把本机的 worktree 删除、会话记录打包挂到工单附件,并把工单指派给对方。移交前分支必须已全部 push。
</p>
{users === null
? (
<div className="flex items-center gap-2 text-xs opacity-70">
<Loader2 className="size-3.5 animate-spin" />
读取可指派用户…
</div>
)
: users.length === 0
? <div className="text-xs text-red-400">没有可指派的其他用户</div>
: (
<SelectMenu
value={to}
options={users.map(u => ({ value: u, label: u }))}
onChange={setTo}
/>
)}
<div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={onCancel} className="rounded border border-[var(--vscode-panel-border)] px-3 py-1 text-xs hover:bg-white/10">
取消
</button>
<button
type="button"
disabled={!to}
onClick={() => onSubmit(issueNumber, to)}
className="rounded bg-[var(--vscode-button-background)] px-3 py-1 text-xs text-[var(--vscode-button-foreground)] disabled:opacity-50"
>
移交
</button>
</div>
</div>
</div>
)
}
```
`SelectMenu` 的 props 为 `{ value: string, options: SelectMenuOption[], onChange: (value: string) => void }``webview-ui/src/components/ui/select-menu.tsx:13-16`);`SelectMenuOption` 的第二个字段名以该文件 `:7-11` 为准(`label` 或 `name`),按实际写。
- [ ] **Step 3: IssueDetailPanel 按钮**
Props 加:
```ts
/** 当前 Gitea loginundefined = 身份未知(接管按钮不显示)。 */
me?: string
onStartHandoff: (issueNumber: number) => void
onAcceptHandoff: (issueNumber: number) => void
isHandoffRunning: (issueNumber: number) => boolean
```
解构处加三个回调与 `me`。头部 `issue.source !== 'youtrack'` 的 fragment 内、「重置为待办」按钮之前加:
```tsx
{issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && (
<button
type="button"
disabled={isHandoffRunning(issue.number)}
onClick={() => onAcceptHandoff(issue.number)}
title={`接管(${issue.handoffFrom ?? '?'} 移交)`}
aria-label="接管"
className="inline-flex shrink-0 items-center gap-1 rounded border border-green-500/60 bg-green-500/10 px-2 py-1 text-xs text-green-500 hover:bg-green-500/20 disabled:opacity-50"
>
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
接管
</button>
)}
{!issue.handoffAttachmentId && issue.column !== 'done' && (
<button
type="button"
disabled={isHandoffRunning(issue.number)}
onClick={() => onStartHandoff(issue.number)}
title="移交给同事"
aria-label="移交"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-blue-500/60 hover:bg-blue-500/10 hover:text-blue-500 disabled:opacity-50"
>
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft className="size-3.5" />}
</button>
)}
{issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && (
<span className="inline-flex shrink-0 items-center rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 text-[10px] opacity-70" title={`${issue.handoffFrom ?? '?'} 已移交,等待 ${(issue.assignees ?? []).join(', ') || '?'} 接管`}>
待接管
</span>
)}
```
从 `lucide-react` 追加 import `ArrowRightLeft, Download, Loader2`(已 import 的不重复)。
- [ ] **Step 4: BottomTabs 透传**
Props 接口加 `me?: string`、`onStartHandoff`、`onAcceptHandoff`、`isHandoffRunning`(类型同上);渲染 `IssueDetailPanel` 处透传四个。
- [ ] **Step 5: App.tsx 接线 + 挂 modal**
从 `useIssues()` 解构出 `me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning`。加状态 `const [handoffIssue, setHandoffIssue] = useState<number | null>(null)`。
`BottomTabs` 加:
```tsx
me={me}
onStartHandoff={(n) => { setHandoffIssue(n); requestHandoffUsers(n) }}
onAcceptHandoff={acceptHandoff}
isHandoffRunning={isHandoffRunning}
```
在 `<NewIssueModal …/>` 旁挂:
```tsx
<HandoffModal
open={handoffIssue !== null}
issueNumber={handoffIssue}
users={handoffUsers && handoffUsers.issueNumber === handoffIssue ? handoffUsers.users : null}
onCancel={() => setHandoffIssue(null)}
onSubmit={(n, to) => { startHandoff(n, to); setHandoffIssue(null) }}
/>
```
- [ ] **Step 6: IssueCard 角标**
`IssueCard.tsx` 在 `locked` 图标之后(同级)加:
```tsx
{issue.handoffAttachmentId
? (
<span
className="absolute bottom-1 right-1.5 rounded border border-blue-400/50 bg-blue-400/10 px-1 text-[9px] text-blue-400"
title={`${issue.handoffFrom ?? '?'} 已移交,待接管`}
>
待接管
</span>
)
: null}
```
- [ ] **Step 7: typecheck + lint + 提交**
Run: `pnpm typecheck && pnpm lint`
Expected: PASS
```bash
git add webview-ui/src
git commit -m "✨ feat(vscode): webview 移交/接管按钮、选人弹窗与待接管角标"
```
---
### Task 12: README、版本、打包、安装、spx 重装
**Files:**
- Modify: `README.md`(工作流章节、state JSON 字段表、面板「工单 tab」段)
- Modify: `package.json:5``0.2.95` → `0.2.96`
- [ ] **Step 1: README**
「工作流」列表第 6 条「回退」后加第 7 条:
```
7. **移交**:工单 tab 头部「移交」→ 选同事。插件校验 worktree 已全部 push,关掉该工单的终端,把头脑风暴 / 实施 / 测试的 claude 会话(jsonl + 子目录)和 codex 审查会话打成 `spx-handoff-issue-N.tgz` 挂到工单附件,写 `handoffAttachmentId` / `handoffFrom`,把工单指派给对方,最后删本机 worktree 与本地分支(远端分支保留)。对方看板上该卡显示「待接管」,工单 tab 点「接管」:从 origin 重建 worktree`git worktree add -B <branch> … origin/<branch>`)、把会话文件装到本机 `~/.claude/projects/` 对应目录(先清同 sid 副本)与 `~/.codex/sessions/`、写本机字段、清移交字段并删附件。之后 resume / 合并 / 冲突解决 / 测试与本机实施的工单无异。
```
state JSON 字段表加两行:
```
| `handoffAttachmentId` / `handoffFrom` | 移交中:会话包附件 id 与发起人;接管后清空 |
```
「工单 tab」段落的顶部按钮列表加「移交 / 接管」。
「环境要求」表 `git` 行说明末尾加「、tar(移交打包)」。
- [ ] **Step 2: 版本号与打包**
`package.json` 的 `"version": "0.2.95"` → `"0.2.96"`。
Run: `pnpm test && pnpm lint && pnpm typecheck && pnpm ext:package`
Expected: 生成 `superpowers-vscode-clurdra-0.2.96.vsix`。
- [ ] **Step 3: 安装本机 + 重装 spx**
Run:
```bash
code --install-extension superpowers-vscode-clurdra-0.2.96.vsix --force --profile augment
cd cli && make install && spx --help | head -3
```
Expected: 扩展安装成功;`~/.local/bin/spx` 为带新 schema 的版本。
- [ ] **Step 4: 提交**
```bash
git add README.md package.json
git commit -m "🔖 chore(vscode): bump 0.2.96README 补移交/接管"
```
---
### Task 13: 端到端验证(手动)
**Files:** 无代码改动;问题回到对应 Task 修。
- [ ] **Step 1: 双窗口准备**
同一台机器开两个 VS Code 窗口指向同一仓库的两个 clone(或用 `dse` / `m1max` 其中一台作为接管方),分别配置两个不同 Gitea 账号的 token`git config user.email` 与 Gitea 邮箱一致)。两边都装 0.2.96 与新 spx。
- [ ] **Step 2: 移交**
在窗口 A:挑一个进行中的工单(有 worktree、实施会话),确保 `git status` 干净且已 push。工单 tab 点「移交」→ 选 B 账号 → 确认。
Expectedspinner toast → 成功 toastA 的工单 tab 会话 id / 工作树行清空;Gitea 工单附件里出现 `spx-handoff-issue-N.tgz`state 评论含 `handoffAttachmentId` / `handoffFrom`assignee 变为 BA 本机 worktree 目录消失、`git branch` 无该 feature 分支、`git ls-remote origin <branch>` 仍在。
- [ ] **Step 3: 接管**
在窗口 B:看板卡片出现「待接管」角标(webhook 指派事件触发刷新;否则点刷新)。工单 tab 点「接管」。
Expected:成功 toast 含 worktree 路径;工作树行可双击打开;会话 id 行双击能 `claude --resume` 起同事的实施会话且历史完整;`ls ~/.claude/projects/*/<sid>.jsonl` 只有一份;Gitea 附件已删除;state 评论两个 handoff 字段为空。
- [ ] **Step 4: 后续流程**
在窗口 B 把工单拖到「完成」:PR 合并(或制造冲突验证 `issue-N-冲突解决` 会话能在重建的 worktree 里启动)。
Expected:与本机实施的工单行为一致。
- [ ] **Step 5: 失败路径**
在 A 上对一个有未提交改动的 worktree 点「移交」。
Expected:toast「工作区有未提交改动…」,什么都没改(附件、assignee、本机状态均不变)。