✨ feat(vscode): 会话 tab 可交接对话给同事

This commit is contained in:
cruldra committed 2026-08-27 15:14:42 +08:00
1 parent 5ee8226167
commit 77037924ef
20 files changed
+938 -26

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import {
buildSessionHandoffManifest,
isMailboxIssue,
MAILBOX_MARKER,
MAILBOX_TITLE,
parseSessionHandoffAttachmentName,
parseSessionHandoffManifest,
sessionHandoffAttachmentName,
slugSessionName,
} from './sessionHandoff'
const SID = '11111111-1111-4111-8111-111111111111'
describe('isMailboxIssue', () => {
it('title 命中', () => {
expect(isMailboxIssue({ title: MAILBOX_TITLE })).toBe(true)
})
it('label 命中', () => {
expect(isMailboxIssue({ title: '其他', labels: [{ name: 'spx-session-mailbox' }] })).toBe(true)
})
it('body marker 命中', () => {
expect(isMailboxIssue({ title: 'x', body: `hello ${MAILBOX_MARKER}` })).toBe(true)
})
it('普通工单不是', () => {
expect(isMailboxIssue({ title: '修登录', body: 'foo', labels: [{ name: 'bug' }] })).toBe(false)
})
})
describe('sessionHandoffAttachmentName', () => {
it('编解码往返', () => {
const name = sessionHandoffAttachmentName({
sid: SID,
from: 'chw',
to: 'cruldra',
name: '测试服初始化',
})
expect(name).toBe(`spx-handoff-session!${SID}!chw!cruldra!测试服初始化.tgz`)
expect(parseSessionHandoffAttachmentName(name)).toEqual({
sid: SID,
from: 'chw',
to: 'cruldra',
name: '测试服初始化',
})
})
it('空名字也合法', () => {
const name = sessionHandoffAttachmentName({ sid: SID, from: 'a', to: 'b', name: '' })
expect(parseSessionHandoffAttachmentName(name)).toEqual({
sid: SID,
from: 'a',
to: 'b',
name: '',
})
})
it('slug 去掉非法字符', () => {
expect(slugSessionName('foo/bar baz')).toBe('foo_bar_baz')
})
it('sid 非 UUID → 抛错', () => {
expect(() => sessionHandoffAttachmentName({ sid: '../x', from: 'a', to: 'b', name: '' })).toThrow(/UUID/)
})
it('login 含 ! → 抛错', () => {
expect(() => sessionHandoffAttachmentName({ sid: SID, from: 'a!b', to: 'c', name: '' })).toThrow(/from/)
})
it('工单移交包名解析不到', () => {
expect(parseSessionHandoffAttachmentName('spx-handoff-issue-12.tgz')).toBeUndefined()
})
})
describe('parseSessionHandoffManifest', () => {
const good = {
version: 1,
kind: 'managed-session',
from: 'chw',
to: 'cruldra',
createdAt: '2026-08-27T00:00:00Z',
workspacePath: '/home/chw/proj',
session: { id: SID, name: '测试服初始化', profilePath: '/x/offical.json' },
claude: [{ id: SID }],
}
it('合法清单原样返回', () => {
expect(parseSessionHandoffManifest(JSON.stringify(good))).toEqual(good)
})
it('kind 不对 → 抛错', () => {
expect(() => parseSessionHandoffManifest(JSON.stringify({ ...good, kind: 'issue' }))).toThrow(/kind/)
})
it('缺少 workspacePath → 抛错', () => {
const { workspacePath: _, ...rest } = good
expect(() => parseSessionHandoffManifest(JSON.stringify(rest))).toThrow(/workspacePath/)
})
it('claude 空 → 抛错', () => {
expect(() => parseSessionHandoffManifest(JSON.stringify({ ...good, claude: [] }))).toThrow(/claude/)
})
it('build 再 parse 往返', () => {
const built = buildSessionHandoffManifest({
from: 'chw',
to: 'cruldra',
workspacePath: '/home/chw/proj',
session: { id: SID, name: 'n' },
createdAt: '2026-08-27T00:00:00Z',
})
expect(parseSessionHandoffManifest(JSON.stringify(built))).toEqual(built)
})
})
+153
View File
@@ -0,0 +1,153 @@
/**
* 会话交接:mailbox 工单识别、附件文件名、managed-session 清单。
*
* 与工单移交的 handoff.json 分开:那边有 issue/worktree/四种 sid,这边只有一个
* claude 会话。混用解析会把会话包当工单包读崩。
*/
export const MAILBOX_TITLE = 'spx-session-mailbox'
export const MAILBOX_LABEL = 'spx-session-mailbox'
export const MAILBOX_MARKER = '<!-- spx:session-mailbox -->'
export const MAILBOX_BODY = `${MAILBOX_MARKER}
Superwork 会话交接邮箱。不要当任务处理,不要关闭。
`
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const LOGIN_RE = /^[\w.-]+$/
const FILENAME_RE = /^spx-handoff-session!([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})!([\w.-]+)!([\w.-]+)!([^!]*)\.tgz$/i
export interface SessionHandoffManifest {
version: 1
kind: 'managed-session'
from: string
to: string
createdAt: string
workspacePath: string
session: { id: string, name: string, profilePath?: string }
claude: Array<{ id: string }>
}
export interface SessionHandoffAttachmentRef {
sid: string
from: string
to: string
name: string
}
export function isMailboxIssue(issue: {
title?: string
body?: string
labels?: Array<{ name: string }> | null
}): boolean {
if (issue.title === MAILBOX_TITLE)
return true
if ((issue.labels ?? []).some(l => l.name === MAILBOX_LABEL))
return true
if (typeof issue.body === 'string' && issue.body.includes(MAILBOX_MARKER))
return true
return false
}
export function slugSessionName(name: string): string {
const trimmed = name.trim()
if (!trimmed)
return ''
return trimmed.replace(/[^\p{L}\p{N}._-]+/gu, '_').slice(0, 40)
}
export function sessionHandoffAttachmentName(opts: {
sid: string
from: string
to: string
name: string
}): string {
if (!UUID_RE.test(opts.sid))
throw new Error(`会话 id 不是 UUID:${opts.sid}`)
if (!LOGIN_RE.test(opts.from))
throw new Error(`from 含非法字符:${opts.from}`)
if (!LOGIN_RE.test(opts.to))
throw new Error(`to 含非法字符:${opts.to}`)
return `spx-handoff-session!${opts.sid}!${opts.from}!${opts.to}!${slugSessionName(opts.name)}.tgz`
}
export function parseSessionHandoffAttachmentName(filename: string): SessionHandoffAttachmentRef | undefined {
const m = FILENAME_RE.exec(filename)
if (!m)
return undefined
return { sid: m[1], from: m[2], to: m[3], name: m[4] ?? '' }
}
function optString(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined
}
export function parseSessionHandoffManifest(json: string): SessionHandoffManifest {
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.kind !== 'managed-session')
throw new Error(`handoff.json kind 不是 managed-session:${String(raw.kind)}`)
const from = optString(raw.from)
const to = optString(raw.to)
const createdAt = optString(raw.createdAt)
const workspacePath = optString(raw.workspacePath)
if (!from || !to || !createdAt || !workspacePath)
throw new Error('handoff.json 缺少 from / to / createdAt / workspacePath')
if (!LOGIN_RE.test(from) || !LOGIN_RE.test(to))
throw new Error('handoff.json from/to 非法')
const sessionRaw = (raw.session ?? {}) as Record<string, unknown>
const id = optString(sessionRaw.id)
const name = typeof sessionRaw.name === 'string' ? sessionRaw.name : ''
if (!id || !UUID_RE.test(id))
throw new Error(`handoff.json session.id 非法:${String(sessionRaw.id)}`)
const claudeRaw = Array.isArray(raw.claude) ? raw.claude : []
const claude = claudeRaw.map((e) => {
const o = e as Record<string, unknown>
const cid = optString(o.id)
if (!cid || !UUID_RE.test(cid))
throw new Error(`handoff.json claude 条目非法:${JSON.stringify(e)}`)
return { id: cid }
})
if (claude.length === 0)
throw new Error('handoff.json 没有 claude 会话文件')
const profilePath = optString(sessionRaw.profilePath)
return {
version: 1,
kind: 'managed-session',
from,
to,
createdAt,
workspacePath,
session: { id, name, ...(profilePath ? { profilePath } : {}) },
claude,
}
}
export function buildSessionHandoffManifest(opts: {
from: string
to: string
workspacePath: string
session: { id: string, name: string, profilePath?: string }
createdAt?: string
}): SessionHandoffManifest {
if (!UUID_RE.test(opts.session.id))
throw new Error(`会话 id 不是 UUID:${opts.session.id}`)
if (!LOGIN_RE.test(opts.from) || !LOGIN_RE.test(opts.to))
throw new Error('from/to 非法')
return {
version: 1,
kind: 'managed-session',
from: opts.from,
to: opts.to,
createdAt: opts.createdAt ?? new Date().toISOString(),
workspacePath: opts.workspacePath,
session: {
id: opts.session.id,
name: opts.session.name,
...(opts.session.profilePath ? { profilePath: opts.session.profilePath } : {}),
},
claude: [{ id: opts.session.id }],
}
}
+75
View File
@@ -92,6 +92,8 @@ export async function listIssuesByFilter(opts: {
/** 缺省 = 不按人过滤,拉仓库全部工单(团队视图)。 */
filter?: 'assigned_by' | 'created_by'
user?: string
/** 逗号分隔的 label 名。label 必须已存在,否则 Gitea 会丢掉过滤条件。 */
labels?: string
}): Promise<GiteaIssue[]> {
const out: GiteaIssue[] = []
let page = 1
@@ -103,6 +105,8 @@ export async function listIssuesByFilter(opts: {
url.searchParams.set('state', 'all')
if (opts.filter && opts.user)
url.searchParams.set(opts.filter, opts.user)
if (opts.labels)
url.searchParams.set('labels', opts.labels)
url.searchParams.set('limit', String(PAGE_SIZE))
url.searchParams.set('page', String(page))
@@ -119,6 +123,77 @@ export async function listIssuesByFilter(opts: {
return out
}
export interface GiteaLabel {
id: number
name: string
color: string
}
export async function listRepoLabels(opts: {
host: string
token: string
owner: string
repo: string
}): Promise<GiteaLabel[]> {
const out: GiteaLabel[] = []
let page = 1
while (true) {
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/labels`)
url.searchParams.set('limit', String(PAGE_SIZE))
url.searchParams.set('page', String(page))
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const batch = await res.json() as GiteaLabel[]
if (!Array.isArray(batch) || batch.length === 0)
break
out.push(...batch)
if (batch.length < PAGE_SIZE)
break
page += 1
}
return out
}
export async function createRepoLabel(opts: {
host: string
token: string
owner: string
repo: string
name: string
color: string
description?: string
}): Promise<GiteaLabel> {
const res = await fetch(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/labels`, {
method: 'POST',
headers: { ...authHeaders(opts.token), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: opts.name, color: opts.color, description: opts.description ?? '' }),
})
await ensureOk(res)
return (await res.json()) as GiteaLabel
}
export async function createIssue(opts: {
host: string
token: string
owner: string
repo: string
title: string
body: string
labels?: number[]
}): Promise<GiteaIssue> {
const res = await fetch(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues`, {
method: 'POST',
headers: { ...authHeaders(opts.token), 'Content-Type': 'application/json' },
body: JSON.stringify({
title: opts.title,
body: opts.body,
...(opts.labels && opts.labels.length > 0 ? { labels: opts.labels } : {}),
}),
})
await ensureOk(res)
return (await res.json()) as GiteaIssue
}
/**
* Fetches the firehose `/repos/{owner}/{repo}/issues/comments` endpoint, which
* returns every comment in the repo across all issues. Paginated until a short
+3 -1
View File
@@ -20,6 +20,7 @@
import type { GiteaComment, GiteaIssue } from './api'
import type { Issue, IssueColumn } from './types'
import * as fs from 'node:fs'
import { isMailboxIssue } from '../cc/sessionHandoff'
import { resolveWorktreePath } from '../git/worktree'
import {
getCurrentUser,
@@ -448,6 +449,7 @@ export async function loadIssues(opts: {
merged = mergeIssues(assigned, created)
allComments = comments
}
merged = merged.filter(issue => !isMailboxIssue(issue))
const buckets = groupCommentsByIssue(allComments)
// 先用各工单评论 bucket 算出 column,决定是否需要 per-issue 远程拉取。
@@ -527,7 +529,7 @@ export async function loadSingleIssue(opts: {
const { host, token, owner, repo, workspaceRoot, issueNumber } = opts
const issue = await getIssue({ host, token, owner, repo, index: issueNumber })
if (!issue)
if (!issue || isMailboxIssue(issue))
return null
const [comments, prerequisite] = await Promise.all([
+38
View File
@@ -0,0 +1,38 @@
import type { GiteaLabel } from './api'
import { isMailboxIssue, MAILBOX_BODY, MAILBOX_LABEL, MAILBOX_TITLE } from '../cc/sessionHandoff'
import { createIssue, createRepoLabel, listIssuesByFilter, listRepoLabels } from './api'
export async function findMailboxIssue(opts: {
host: string
token: string
owner: string
repo: string
}): Promise<number | undefined> {
const labels = await listRepoLabels(opts)
if (!labels.some(l => l.name === MAILBOX_LABEL))
return undefined
const issues = await listIssuesByFilter({ ...opts, labels: MAILBOX_LABEL })
return issues.find(i => isMailboxIssue(i))?.number
}
export async function ensureMailboxIssue(opts: {
host: string
token: string
owner: string
repo: string
}): Promise<number> {
const existing = await findMailboxIssue(opts)
if (existing !== undefined)
return existing
const labels = await listRepoLabels(opts)
let label: GiteaLabel | undefined = labels.find(l => l.name === MAILBOX_LABEL)
if (!label)
label = await createRepoLabel({ ...opts, name: MAILBOX_LABEL, color: '586069', description: 'Superwork session mailbox' })
const created = await createIssue({
...opts,
title: MAILBOX_TITLE,
body: MAILBOX_BODY,
labels: [label.id],
})
return created.number
}
+17
View File
@@ -32,6 +32,7 @@ import * as issues from './handlers/issues'
import * as managedSessions from './handlers/managedSessions'
import * as prFiles from './handlers/prFiles'
import * as profileAssets from './handlers/profileAssets'
import * as sessionHandoff from './handlers/sessionHandoff'
import * as sessions from './handlers/sessions'
import * as settings from './handlers/settings'
import * as terminals from './handlers/terminals'
@@ -440,6 +441,22 @@ export class KanbanWebviewPanel {
void handoffFlow.handleHandoffAccept(this, msg.issueNumber)
return
}
if (msg.type === 'session-handoff/users') {
void sessionHandoff.handleSessionHandoffUsers(this)
return
}
if (msg.type === 'session-handoff/start') {
void sessionHandoff.handleSessionHandoffStart(this, msg.sessionId, msg.to)
return
}
if (msg.type === 'session-handoff/accept') {
void sessionHandoff.handleSessionHandoffAccept(this, msg.attachmentId)
return
}
if (msg.type === 'session-handoff/refresh') {
void sessionHandoff.handleSessionHandoffRefresh(this)
return
}
if (msg.type === 'youtrack/list-projects') {
void youtrackIssues.handleListProjects(this, msg.baseUrl, msg.token)
return
+5 -5
View File
@@ -43,7 +43,7 @@ import {
handoffStartedUiPatch,
} from './handoff'
interface RepoCtx {
export interface RepoCtx {
workspaceRoot: string
host: string
owner: string
@@ -52,17 +52,17 @@ interface RepoCtx {
me: string
}
function toast(panel: KanbanWebviewPanel, level: 'info' | 'success' | 'error', message: string, extra?: { id?: string, spinner?: boolean, dismissOnTimer?: number }): string {
export 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 {
export function dismiss(panel: KanbanWebviewPanel, id: string): void {
panel.postMessage({ type: 'toast/dismiss', id })
}
async function resolveRepoCtx(panel: KanbanWebviewPanel): Promise<RepoCtx | undefined> {
export async function resolveRepoCtx(panel: KanbanWebviewPanel): Promise<RepoCtx | undefined> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
toast(panel, 'error', '请先打开一个工作区文件夹')
@@ -98,7 +98,7 @@ function str(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined
}
function scratchDir(prefix: string): Promise<string> {
export function scratchDir(prefix: string): Promise<string> {
return fsp.mkdtemp(path.join(os.tmpdir(), prefix))
}
+302
View File
@@ -0,0 +1,302 @@
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 { HANDOFF_MANIFEST_FILE } from '../../cc/handoffManifest'
import { resolveProfilePath } from '../../cc/profiles'
import {
claudeProjectsRoot,
copyClaudeSessionFiles,
findClaudeSessionFiles,
installClaudeSession,
rewritePathInFile,
rewritePathInTree,
} from '../../cc/sessionBundle'
import {
buildSessionHandoffManifest,
parseSessionHandoffAttachmentName,
parseSessionHandoffManifest,
sessionHandoffAttachmentName,
} from '../../cc/sessionHandoff'
import { projectsDirFor } from '../../cc/sessionWatcher'
import {
deleteIssueAttachment,
downloadAttachment,
listIssueAttachments,
listRepoAssignees,
uploadIssueAttachment,
} from '../../gitea/api'
import { ensureMailboxIssue, findMailboxIssue } from '../../gitea/mailboxIssue'
import { logger } from '../../logging/logger'
import { readManagedSessions, writeManagedSessions } from '../../sessions/managedStore'
import { dismiss, resolveRepoCtx, scratchDir, toast } from './handoffFlow'
import { pushManagedSessions } from './managedSessions'
export interface SessionHandoffPendingItem {
attachmentId: number
sid: string
from: string
to: string
name: string
}
export async function handleSessionHandoffUsers(panel: KanbanWebviewPanel): Promise<void> {
const ctx = await resolveRepoCtx(panel)
if (!ctx) {
panel.postMessage({ type: 'session-handoff/users-result', users: [] })
return
}
try {
const users = await listRepoAssignees(ctx)
panel.postMessage({
type: 'session-handoff/users-result',
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: 'session-handoff/users-result', users: [] })
}
}
export async function handleSessionHandoffRefresh(panel: KanbanWebviewPanel): Promise<void> {
const ctx = await resolveRepoCtx(panel)
if (!ctx) {
panel.postMessage({ type: 'session-handoff/pending', items: [] })
return
}
try {
const items = await listPendingForMe(ctx)
panel.postMessage({ type: 'session-handoff/pending', items })
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'warn', source: 'panel', message: '拉取待接收会话失败', details: message })
panel.postMessage({ type: 'session-handoff/pending', items: [] })
}
}
export async function handleSessionHandoffStart(panel: KanbanWebviewPanel, sessionId: string, to: string): Promise<void> {
const finish = (): void => panel.postMessage({ type: 'session-handoff/done', sessionId })
let spinner: string | undefined
try {
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
const data = await readManagedSessions(ctx.workspaceRoot)
const session = data.sessions.find(s => s.id === sessionId)
if (!session) {
toast(panel, 'error', '找不到这个会话记录')
return
}
const files = await findClaudeSessionFiles(sessionId, claudeProjectsRoot())
if (!files) {
toast(panel, 'error', `找不到会话文件 ${sessionId},无法交接`)
return
}
const existing = panel.managedTerminals.get(sessionId)
if (existing) {
try {
existing.dispose()
}
catch {}
await new Promise<void>(r => setTimeout(r, 600))
}
spinner = toast(panel, 'info', `正在把「${session.name || sessionId.slice(0, 8)}」交给 ${to}…`, { spinner: true, dismissOnTimer: 120_000 })
const manifest = buildSessionHandoffManifest({
from: ctx.me,
to,
workspacePath: ctx.workspaceRoot,
session: { id: session.id, name: session.name, profilePath: session.profilePath },
})
const attachmentName = sessionHandoffAttachmentName({
sid: session.id,
from: ctx.me,
to,
name: session.name,
})
const staging = await scratchDir('spx-session-handoff-')
const outDir = await scratchDir('spx-session-handoff-out-')
let mailboxIndex: number
try {
const claudeDir = path.join(staging, 'claude')
await fsp.mkdir(claudeDir, { recursive: true })
await copyClaudeSessionFiles(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)
await packHandoff(staging, archive)
for (const old of await listIssueAttachments({ ...ctx, index: mailboxIndex })) {
const parsed = parseSessionHandoffAttachmentName(old.name)
if (parsed && parsed.sid === session.id && parsed.to === to)
await deleteIssueAttachment({ ...ctx, index: mailboxIndex, attachmentId: old.id })
}
await uploadIssueAttachment({
...ctx,
index: mailboxIndex,
name: attachmentName,
data: await fsp.readFile(archive),
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `会话交接打包/上传失败 ${sessionId}`, details: message })
if (spinner)
dismiss(panel, spinner)
toast(panel, 'error', `交接失败:${message}`)
return
}
finally {
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {})
await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {})
}
session.handedOffTo = to
session.handedOffAt = Date.now()
await writeManagedSessions(ctx.workspaceRoot, data)
await pushAll(panel)
if (spinner)
dismiss(panel, spinner)
toast(panel, 'success', `已交给 ${to}(对方在会话 tab 待接收)`)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `会话交接未预期错误 ${sessionId}`, details: message })
toast(panel, 'error', `交接失败:${message}`)
if (spinner)
dismiss(panel, spinner)
}
finally {
finish()
}
}
export async function handleSessionHandoffAccept(panel: KanbanWebviewPanel, attachmentId: number): Promise<void> {
const finish = (): void => panel.postMessage({ type: 'session-handoff/done', sessionId: '' })
let spinner: string | undefined
try {
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
const mailboxIndex = await findMailboxIssue(ctx)
if (mailboxIndex === undefined) {
toast(panel, 'error', '还没有会话交接邮箱')
return
}
const assets = await listIssueAttachments({ ...ctx, index: mailboxIndex })
const asset = assets.find(a => a.id === attachmentId)
if (!asset) {
toast(panel, 'error', '找不到这份待接收附件')
return
}
const named = parseSessionHandoffAttachmentName(asset.name)
if (!named || named.to !== ctx.me) {
toast(panel, 'error', '这份交接不是给你的')
return
}
spinner = toast(panel, 'info', `正在接收「${named.name || named.sid.slice(0, 8)}」…`, { spinner: true, dismissOnTimer: 120_000 })
const archiveDir = await scratchDir('spx-session-handoff-in-')
const extracted = await scratchDir('spx-session-handoff-ex-')
try {
const buf = await downloadAttachment({ token: ctx.token, url: asset.browser_download_url })
const archive = path.join(archiveDir, asset.name)
await fsp.writeFile(archive, buf)
await unpackHandoff(archive, extracted)
const manifest = parseSessionHandoffManifest(await fsp.readFile(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'))
if (manifest.to !== ctx.me)
throw new Error(`清单接收人是 ${manifest.to},不是你`)
const claudeDir = path.join(extracted, 'claude')
if (manifest.workspacePath !== ctx.workspaceRoot) {
for (const { id } of manifest.claude) {
await rewritePathInFile(path.join(claudeDir, `${id}.jsonl`), manifest.workspacePath, ctx.workspaceRoot)
await rewritePathInTree(path.join(claudeDir, id), manifest.workspacePath, ctx.workspaceRoot)
}
}
const dstProjectsDir = projectsDirFor(ctx.workspaceRoot)
await fsp.mkdir(dstProjectsDir, { recursive: true })
for (const { id } of manifest.claude) {
await installClaudeSession({
sid: id,
srcDir: claudeDir,
dstProjectsDir,
projectsRoot: claudeProjectsRoot(),
})
}
const data = await readManagedSessions(ctx.workspaceRoot)
const existing = data.sessions.find(s => s.id === manifest.session.id)
const profilePath = resolveProfilePath(manifest.session.profilePath)
if (existing) {
if (manifest.session.name)
existing.name = manifest.session.name
existing.profilePath = profilePath
}
else {
data.sessions.push({
id: manifest.session.id,
name: manifest.session.name || named.name || manifest.session.id.slice(0, 8),
profilePath,
createdAt: Date.now(),
})
}
await writeManagedSessions(ctx.workspaceRoot, data)
await deleteIssueAttachment({ ...ctx, index: mailboxIndex, attachmentId }).catch((err) => {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'warn', source: 'panel', message: '接收后删附件失败', details: message })
})
}
finally {
await fsp.rm(archiveDir, { recursive: true, force: true }).catch(() => {})
await fsp.rm(extracted, { recursive: true, force: true }).catch(() => {})
}
await pushAll(panel)
if (spinner)
dismiss(panel, spinner)
toast(panel, 'success', `已接收「${named.name || named.sid.slice(0, 8)}」,来自 ${named.from}`)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `接收会话失败 attachment=${attachmentId}`, details: message })
toast(panel, 'error', `接收失败:${message}`)
if (spinner)
dismiss(panel, spinner)
}
finally {
finish()
}
}
async function listPendingForMe(ctx: { host: string, token: string, owner: string, repo: string, me: string }): Promise<SessionHandoffPendingItem[]> {
const mailboxIndex = await findMailboxIssue(ctx)
if (mailboxIndex === undefined)
return []
const assets = await listIssueAttachments({ ...ctx, index: mailboxIndex })
const items: SessionHandoffPendingItem[] = []
for (const a of assets) {
const parsed = parseSessionHandoffAttachmentName(a.name)
if (!parsed || parsed.to !== ctx.me)
continue
items.push({
attachmentId: a.id,
sid: parsed.sid,
from: parsed.from,
to: parsed.to,
name: parsed.name,
})
}
return items
}
async function pushAll(panel: KanbanWebviewPanel): Promise<void> {
await pushManagedSessions(panel)
await handleSessionHandoffRefresh(panel)
}
+15
View File
@@ -15,6 +15,14 @@ export interface ManagedSessionShowItem extends ManagedSession {
tabOpen?: boolean
}
export interface SessionHandoffPendingItem {
attachmentId: number
sid: string
from: string
to: string
name: string
}
export interface ManagedSessionsShowData {
sessions: ManagedSessionShowItem[]
}
@@ -46,6 +54,9 @@ export type ExtensionToWebview
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
| { type: 'handoff/users-result', issueNumber: number, users: string[] }
| { type: 'handoff/done', issueNumber: number }
| { type: 'session-handoff/users-result', users: string[] }
| { type: 'session-handoff/pending', items: SessionHandoffPendingItem[] }
| { type: 'session-handoff/done', sessionId: string }
| { type: 'issue/append', issue: Issue, select?: boolean }
| { type: 'issue/select-by-number', issueNumber: number }
| {
@@ -173,6 +184,10 @@ export type WebviewToExtension
| { type: 'handoff/users', issueNumber: number }
| { type: 'handoff/start', issueNumber: number, to: string }
| { type: 'handoff/accept', issueNumber: number }
| { type: 'session-handoff/users' }
| { type: 'session-handoff/start', sessionId: string, to: string }
| { type: 'session-handoff/accept', attachmentId: number }
| { type: 'session-handoff/refresh' }
| { type: 'dependency/set', issueNumber: number, prerequisiteNumber: number }
| { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number }
| { type: 'issue/update-auto-review', issueNumber: number, value: boolean }
+5 -1
View File
@@ -15,6 +15,8 @@ export interface ManagedSession {
name: string
profilePath?: string
createdAt: number
handedOffTo?: string
handedOffAt?: number
}
export interface ManagedSessionsData {
@@ -46,7 +48,9 @@ export async function readManagedSessions(workspaceRoot: string): Promise<Manage
const name = typeof s?.name === 'string' ? s.name : ''
const profilePath = typeof s?.profilePath === 'string' ? s.profilePath : undefined
const createdAt = typeof s?.createdAt === 'number' ? s.createdAt : 0
return { id, name, profilePath, createdAt }
const handedOffTo = typeof s?.handedOffTo === 'string' && s.handedOffTo.length > 0 ? s.handedOffTo : undefined
const handedOffAt = typeof s?.handedOffAt === 'number' ? s.handedOffAt : undefined
return { id, name, profilePath, createdAt, handedOffTo, handedOffAt }
})
.filter(s => s.id !== '')
: []
+10
View File
@@ -18,6 +18,7 @@ import { env, Uri, window, workspace } from 'vscode'
import { loginForToken } from '../auth/identity'
import { getToken } from '../auth/secrets'
import { getReviewPrompt } from '../cc/prompts'
import { isMailboxIssue } from '../cc/sessionHandoff'
import { detectRepo } from '../git/remote'
import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api'
import { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
@@ -525,6 +526,15 @@ class WebhookCoordinator {
return
}
if (isMailboxIssue({ title: event.title, body: event.body })) {
logger.add({
level: 'info',
source: 'webhook',
message: `issue #${event.issueNumber} 是会话交接 mailbox,跳过看板`,
})
return
}
const nonceMatch = event.body.match(/<!--\s*spx:nonce=([0-9a-f-]+)\s*-->/i)
const nonce = nonceMatch ? nonceMatch[1] : null
// Promote the in-flight brainstorm terminal into the panel's