import type { KanbanWebviewPanel } from '../KanbanPanel' import { promises as fsp } from 'node:fs' import { window, workspace } from 'vscode' import { buildCcCommand } from '../../cc/ccCommand' import { resolveProfilePath } from '../../cc/profiles' import { pollForNewSession, projectsDirFor } from '../../cc/sessionWatcher' import { logger } from '../../logging/logger' import { listImportableNamedSessions } from '../../sessions/importableNamedSessions' import { decideManagedResumeProfile } from '../../sessions/managedResumeProfile' import { readManagedSessions, writeManagedSessions } from '../../sessions/managedStore' import { inferProfilePathForSession } from '../../sessions/sessionProfileInference' import { resolveTerminalLocation } from './terminals' /** 默认会话名:有 prompt 取前 20 字符,否则用短 id(前 8 位)。 */ function defaultSessionName(sessionId: string, prompt?: string): string { const trimmed = (prompt ?? '').trim() if (trimmed) return trimmed.slice(0, 20) return `会话 ${sessionId.slice(0, 8)}` } /** * 读取受管理会话列表,给每条附加 transient 字段 `tabOpen` * (= panel.managedTerminals 里有该 id 的存活终端),再推全量列表给 webview。 * * `tabOpen` 只在构造 show payload 时附加,不写进 .spx/session-names.json。 */ export async function pushManagedSessions(panel: KanbanWebviewPanel): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { panel.postMessage({ type: 'managed-sessions/show', data: { sessions: [] } }) return } const data = await readManagedSessions(workspaceRoot) panel.postMessage({ type: 'managed-sessions/show', data: { sessions: data.sessions.map(s => ({ ...s, tabOpen: panel.managedTerminals.has(s.id) })), }, }) } /** * 从列表关闭一个受管理会话的终端 tab(不删 store 记录)。 * dispose 会触发 onDidCloseTerminal,由它清理 managedTerminals 并重推列表。 */ export function handleManagedSessionsCloseTab(panel: KanbanWebviewPanel, sessionId: string): void { panel.managedTerminals.get(sessionId)?.dispose() } /** * 推送全量受管理会话列表给 webview。 */ export async function handleManagedSessionsGet(panel: KanbanWebviewPanel): Promise { await pushManagedSessions(panel) } export interface CreateCcSessionTabOptions { workspaceRoot: string /** 传给 resolveProfilePath 的原始 profile 路径(空串走默认 profile)。 */ profilePath: string name?: string prompt?: string /** 终端 cwd;缺省 workspaceRoot(会话 jsonl 落在该目录对应的 projects 子目录)。 */ cwd?: string /** 有 panel 时登记存活终端并推全量列表;panel 外触发(如 URI handler)传 null。 */ panel: KanbanWebviewPanel | null /** 是否把会话登记进 managed store(会话管理列表);委派类一次性会话传 false。 */ register: boolean } /** * 创建 cc 会话终端 tab(核心逻辑,不要求 panel): * - 启动命令统一由 buildCcCommand 构建(claude --dangerously-skip-permissions * --settings ''),prompt 非空时作为位置参数传入。 * - register 为 true 时用 pollForNewSession 捕获新 sessionId(共享 projects 目录用轮询更可靠), * 落进 workspaceRoot 的 .spx/session-names.json;有 panel 时再登记终端并推全量列表。 * - register 为 false 时只开终端,不落 store、不进会话管理列表。 * * 校验失败时 showErrorMessage 并返回 undefined;成功返回终端名。 */ export async function createCcSessionTab(opts: CreateCcSessionTabOptions): Promise<{ terminalName: string } | undefined> { const { workspaceRoot, panel } = opts const effectiveProfilePath = resolveProfilePath( opts.profilePath && opts.profilePath.trim() !== '' ? opts.profilePath : undefined, ) // 单引号会破坏下面的 shell 单引号包裹,防御性拒绝(与现有 handler 一致)。 if (effectiveProfilePath.includes('\'')) { void window.showErrorMessage( `创建会话失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, ) return undefined } const trimmedName = (opts.name ?? '').trim() if (trimmedName.includes('\'')) { void window.showErrorMessage('创建会话失败:会话名字含单引号,拒绝执行') return undefined } const trimmedPrompt = (opts.prompt ?? '').trim() if (trimmedPrompt.includes('\'')) { void window.showErrorMessage('创建会话失败:首个提示词含单引号,拒绝执行') return undefined } // 首个提示词:填了 prompt 用 prompt;否则填了 name 用 /rename ;否则交互式无提示词。 let effectivePrompt = '' if (trimmedPrompt) effectivePrompt = trimmedPrompt else if (trimmedName) effectivePrompt = `/rename ${trimmedName}` // cc 按进程 cwd 决定 projects 子目录;先 mkdir 让 watcher 不会错过 create 事件。 const effectiveCwd = opts.cwd ?? workspaceRoot const projDir = projectsDirFor(effectiveCwd) try { await fsp.mkdir(projDir, { recursive: true }) } catch (err) { console.warn('[superpowers] failed to mkdir claude projects dir:', err) } // managed 会话的 projects 目录是繁忙共享目录,fs.watch 会漏 rename;改用轮询 diff。 // 不登记的会话无需捕获 sessionId,省掉这趟轮询。 const watchPromise = opts.register ? pollForNewSession({ projectsDir: projDir, timeoutMs: 120_000 }) : null // 终端名:用户填了会话名字就用它,否则用默认 cc-会话。 // (VS Code 终端创建后不能改名,所以只在 createTerminal 时设定。) const terminal = window.createTerminal({ name: trimmedName || 'cc-会话', cwd: effectiveCwd, location: resolveTerminalLocation(panel, false), }) terminal.show(false) logger.add({ level: 'info', source: 'terminal', message: `已创建终端 "${terminal.name}"`, }) const cmd = buildCcCommand({ profilePath: effectiveProfilePath, prompt: effectivePrompt || undefined }) terminal.sendText(cmd) // Fire-and-forget:会话 jsonl 出现后写进 store;有 panel 再登记终端并推全量列表。 watchPromise?.then(async (sid) => { if (!sid) { logger.add({ level: 'warn', source: 'panel', message: '会话管理:cc 会话监听超时 (120s)', }) return } logger.add({ level: 'info', source: 'panel', message: `会话管理:已捕获 cc 会话 ${sid}`, }) try { const data = await readManagedSessions(workspaceRoot) data.sessions.push({ id: sid, name: trimmedName || defaultSessionName(sid, trimmedPrompt || undefined), profilePath: effectiveProfilePath, createdAt: Date.now(), }) await writeManagedSessions(workspaceRoot, data) if (panel) { // 捕获到 sid 后登记终端,再推 show,让列表的 tabOpen 立即为 true。 panel.managedTerminals.set(sid, terminal) await pushManagedSessions(panel) } } catch (err) { console.warn('[superpowers] failed to persist managed session:', err) } }).catch((err) => { console.warn('[superpowers] managed session watch failed:', err) }) return { terminalName: terminal.name } } /** * 从会话管理 tab 创建一个新的 cc 会话: * - cwd 用项目根(workspaceRoot,非 worktree)。 * - 其余逻辑(组命令 / 捕获 sessionId / store 登记)在 createCcSessionTab。 */ export async function handleManagedSessionsCreate(panel: KanbanWebviewPanel, profilePath: string, name?: string, prompt?: string): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { void window.showErrorMessage('请先打开一个工作区文件夹') return } const created = await createCcSessionTab({ workspaceRoot, profilePath, name, prompt, panel, register: true }) if (!created) return logger.add({ level: 'info', source: 'panel', message: '已从会话管理 tab 启动 cc 会话', }) void window.showInformationMessage('已创建 cc 会话') } /** * 重命名一个受管理会话(仅改本地记录的显示名)。 */ export async function handleManagedSessionsRename(panel: KanbanWebviewPanel, sessionId: string, name: string): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) return const trimmed = name.trim() if (!trimmed) return const data = await readManagedSessions(workspaceRoot) const target = data.sessions.find(s => s.id === sessionId) if (!target) return target.name = trimmed await writeManagedSessions(workspaceRoot, data) await pushManagedSessions(panel) } /** * 恢复一个受管理会话:在新终端 tab 里跑 `claude ... --resume `, * cwd = workspaceRoot。in-flight 锁防止重复点击重复 createTerminal。 */ export async function handleManagedSessionsResume(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string): Promise { const lockKey = `managed:${sessionId}` if (panel.resumeInFlight.has(lockKey)) { logger.add({ level: 'info', source: 'panel', message: `managed resume ${sessionId} 已在进行中,忽略重入`, }) return } panel.resumeInFlight.add(lockKey) try { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { void window.showErrorMessage('请先打开一个工作区文件夹') return } const data = await readManagedSessions(workspaceRoot) const target = data.sessions.find(s => s.id === sessionId) const override = profilePath?.trim() || undefined let inferred: string | null = null if (!override && (!target?.profilePath || target.profilePath.trim() === '') && target) { inferred = await inferProfilePathForSession(sessionId, projectsDirFor(workspaceRoot)) } const decided = decideManagedResumeProfile({ storedProfile: target?.profilePath, overrideProfile: profilePath, inferredProfile: inferred, }) if (decided.persist && target) { target.profilePath = decided.persist await writeManagedSessions(workspaceRoot, data) } // 已有该会话的存活终端,直接聚焦、不再开新的(防重复)。 const existing = panel.managedTerminals.get(sessionId) if (existing) { if (decided.persist) await pushManagedSessions(panel) existing.show(false) return } const effectiveProfilePath = resolveProfilePath(decided.effective) if (effectiveProfilePath.includes('\'')) { void window.showErrorMessage( `resume 失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, ) return } // 终端名用 store 里记录的会话名(缺省回落到 cc-会话)。 const terminal = window.createTerminal({ name: target?.name?.trim() || 'cc-会话', cwd: workspaceRoot, location: panel.resolveTerminalLocation(false), }) terminal.show(false) logger.add({ level: 'info', source: 'terminal', message: `已创建终端 "${terminal.name}" (resume ${sessionId})`, }) const cmd = buildCcCommand({ profilePath: effectiveProfilePath, resumeSessionId: sessionId }) terminal.sendText(cmd) // 登记终端并推 show,让列表的 tabOpen 立即为 true。 panel.managedTerminals.set(sessionId, terminal) await pushManagedSessions(panel) } finally { panel.resumeInFlight.delete(lockKey) } } /** * 从列表移除一个受管理会话(仅删本地记录,不删 jsonl)。 */ export async function handleManagedSessionsDelete(panel: KanbanWebviewPanel, sessionId: string): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) return const data = await readManagedSessions(workspaceRoot) const target = data.sessions.find(s => s.id === sessionId) const choice = await window.showWarningMessage( `确定从会话列表删除「${target?.name ?? sessionId}」?会关掉它的终端 tab,列表记录不可恢复(cc 会话本身的历史仍在 ~/.claude 里)。`, { modal: true }, '删除', ) if (choice !== '删除') return // 删除前先关掉该会话的终端 tab。 panel.managedTerminals.get(sessionId)?.dispose() const next = { sessions: data.sessions.filter(s => s.id !== sessionId) } await writeManagedSessions(workspaceRoot, next) panel.managedTerminals.delete(sessionId) await pushManagedSessions(panel) } /** * 列出当前工作区可导入的命名会话(jsonl 有 custom-title 且尚未在 managed store)。 */ export async function handleManagedSessionsListImportable(panel: KanbanWebviewPanel): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { panel.postMessage({ type: 'managed-sessions/importable', sessions: [] }) return } const sessions = await listImportableNamedSessions(workspaceRoot) panel.postMessage({ type: 'managed-sessions/importable', sessions }) } /** * 把已 /rename 的会话登记进 managed store(推断成功时写 profilePath,不 resume)。 * id 已存在时幂等,仍推送全量列表。 */ export async function handleManagedSessionsImport( panel: KanbanWebviewPanel, sessionId: string, name: string, ): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { void window.showErrorMessage('请先打开一个工作区文件夹') return } const trimmedId = sessionId.trim() const trimmedName = name.trim() if (!trimmedId || !trimmedName) return const data = await readManagedSessions(workspaceRoot) const existing = data.sessions.find(s => s.id === trimmedId) if (!existing) { const inferred = await inferProfilePathForSession(trimmedId, projectsDirFor(workspaceRoot)) data.sessions.push({ id: trimmedId, name: trimmedName, createdAt: Date.now(), ...(inferred ? { profilePath: inferred } : {}), }) await writeManagedSessions(workspaceRoot, data) } await pushManagedSessions(panel) }