diff --git a/vscode/src/cc/ccCommand.ts b/vscode/src/cc/ccCommand.ts index 1041c53..f0fc645 100644 --- a/vscode/src/cc/ccCommand.ts +++ b/vscode/src/cc/ccCommand.ts @@ -1,43 +1,39 @@ import { resolveClaudeBin } from './claudeBin' /** - * Single source of truth for the `claude` (cc) terminal launch command. + * Single source of truth for the `claude` (cc) session argv. * * Every session entry point (brainstorm / implement / test / review / resume / - * managed session) shells out `claude` with the same flags; building the - * string in one place keeps those call sites from drifting apart. + * managed session) launches `claude` with the same flags; building the argv + * in one place keeps those call sites from drifting apart. * * We deliberately pass no `--system-prompt`; cc runs with its own default * system prompt. */ export interface CcCommandOpts { - /** Path passed to `--settings` (caller guarantees it contains no single quote). */ + /** Path passed to `--settings`. */ profilePath: string /** e.g. 'high' → `--effort high`; omitted means no `--effort`. */ effort?: string /** When set, append `--resume ` so cc restores that conversation. */ resumeSessionId?: string /** - * When set, append the prompt as a positional `''` arg. Combined - * with `resumeSessionId` it is submitted as the next message of the - * restored conversation. + * When set, append the prompt as a positional arg. Combined with + * `resumeSessionId` it is submitted as the next message of the restored + * conversation. */ prompt?: string } -function quoteIfNeeded(bin: string): string { - return bin.includes(' ') ? `'${bin}'` : bin -} - -export function buildCcCommand(opts: CcCommandOpts): string { - const parts = [quoteIfNeeded(resolveClaudeBin() ?? 'claude')] +export function buildCcArgv(opts: CcCommandOpts): string[] { + const argv = [resolveClaudeBin() ?? 'claude'] if (opts.effort) - parts.push(`--effort ${opts.effort}`) - parts.push('--dangerously-skip-permissions', `--settings '${opts.profilePath}'`) + argv.push('--effort', opts.effort) + argv.push('--dangerously-skip-permissions', '--settings', opts.profilePath) if (opts.resumeSessionId) - parts.push(`--resume ${opts.resumeSessionId}`) + argv.push('--resume', opts.resumeSessionId) if (opts.prompt) - parts.push(`'${opts.prompt}'`) - return parts.join(' ') + argv.push(opts.prompt) + return argv } diff --git a/vscode/src/cc/codexCommand.test.ts b/vscode/src/cc/codexCommand.test.ts new file mode 100644 index 0000000..5b33b6c --- /dev/null +++ b/vscode/src/cc/codexCommand.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { buildCodexArgv } from './codexCommand' + +describe('buildCodexArgv', () => { + it('resume 模式带上模型覆盖与会话 id', () => { + expect(buildCodexArgv({ mode: 'resume', sessionId: 'tid', model: ' gpt-5 ', reasoningEffort: 'high' })).toEqual([ + 'codex', + 'resume', + '-c', + 'model=gpt-5', + '-c', + 'model_reasoning_effort=high', + '--dangerously-bypass-approvals-and-sandbox', + 'tid', + ]) + }) + + it('interactive 模式空覆盖被丢弃,提示词原样作为最后一个参数', () => { + const prompt = '/review it\'s $PR\n第二行' + expect(buildCodexArgv({ mode: 'interactive', prompt, model: '', reasoningEffort: ' ' })).toEqual([ + 'codex', + '--dangerously-bypass-approvals-and-sandbox', + prompt, + ]) + }) +}) diff --git a/vscode/src/cc/codexCommand.ts b/vscode/src/cc/codexCommand.ts index ac607d6..35a7000 100644 --- a/vscode/src/cc/codexCommand.ts +++ b/vscode/src/cc/codexCommand.ts @@ -1,8 +1,8 @@ /** * Single source of truth for assembling `codex` invocations. * - * The review flow shells codex two ways — `codex resume ` and the - * interactive `codex ''` TUI — both sent to a terminal. Building the + * The review flow launches codex two ways — `codex resume ` and the + * interactive `codex ` TUI — each as a session terminal's process. Building the * flags in one place keeps `--dangerously-bypass-approvals-and-sandbox` and the * optional model / reasoning-effort `-c` overrides from drifting apart. * @@ -11,11 +11,11 @@ */ export interface CodexCommandOpts { - /** 'resume' = `codex resume `;'interactive' = `codex ''` */ + /** 'resume' = `codex resume `;'interactive' = `codex ` */ mode: 'resume' | 'interactive' /** resume 模式必填:codex thread id */ sessionId?: string - /** interactive 模式的 prompt(positional)。调用方保证不含单引号。 */ + /** interactive 模式的 prompt(positional) */ prompt?: string /** 空 = 不传 `-c model=`,用 codex 默认 */ model?: string @@ -40,11 +40,11 @@ function configOverrides(opts: CodexCommandOpts): string[] { return parts } -/** Full shell command string (with `codex` prefix) for `terminal.sendText`. */ -export function buildCodexCommandString(opts: CodexCommandOpts): string { +/** Full argv, starting with `codex`. */ +export function buildCodexArgv(opts: CodexCommandOpts): string[] { const overrides = configOverrides(opts) const parts = opts.mode === 'resume' ? ['resume', ...overrides, BYPASS_FLAG, ...(opts.sessionId ? [opts.sessionId] : [])] - : [...overrides, BYPASS_FLAG, ...(opts.prompt ? [`'${opts.prompt}'`] : [])] - return ['codex', ...parts].join(' ') + : [...overrides, BYPASS_FLAG, ...(opts.prompt ? [opts.prompt] : [])] + return ['codex', ...parts] } diff --git a/vscode/src/cc/sessionLaunch.test.ts b/vscode/src/cc/sessionLaunch.test.ts new file mode 100644 index 0000000..1e97c92 --- /dev/null +++ b/vscode/src/cc/sessionLaunch.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { buildSessionLaunch } from './sessionLaunch' + +describe('buildSessionLaunch', () => { + it('用登录交互 shell 执行 exec "$@",$0 占位为 shell 名', () => { + expect(buildSessionLaunch('/opt/homebrew/bin/bash', ['claude', '--resume', 'abc'])).toEqual({ + shellPath: '/opt/homebrew/bin/bash', + shellArgs: ['-ilc', 'exec "$@"', 'bash', 'claude', '--resume', 'abc'], + }) + }) + + it('默认 shell 为空时回退 /bin/zsh', () => { + expect(buildSessionLaunch('', ['codex'])).toEqual({ + shellPath: '/bin/zsh', + shellArgs: ['-ilc', 'exec "$@"', 'zsh', 'codex'], + }) + }) + + it('含空格、单引号、$、换行的提示词原样作为一个参数', () => { + const prompt = 'it\'s a "test" with $HOME and `cmd`\n第二行 \'\\\'\'' + const { shellArgs } = buildSessionLaunch('/bin/zsh', ['claude', prompt]) + expect(shellArgs.at(-1)).toBe(prompt) + expect(shellArgs).toHaveLength(5) + }) +}) diff --git a/vscode/src/cc/sessionLaunch.ts b/vscode/src/cc/sessionLaunch.ts new file mode 100644 index 0000000..ecc8d28 --- /dev/null +++ b/vscode/src/cc/sessionLaunch.ts @@ -0,0 +1,25 @@ +import * as path from 'node:path' + +/** + * 会话终端直接由登录 shell 拉起目标程序,而不是开个空 shell 再往里打字: + * - `-il` 照常加载 .zprofile / .zshrc,PATH、mise 等环境与手动开终端一致 + * - `-c` 执行的命令不进 shell 历史,超长的启动命令不污染历史补全 + * - argv 走 `"$@"` 原样传进去,空格、引号、`$`、换行都不经 shell 解析 + * - `exec` 让程序顶替 shell,程序退出时终端标签页随之关闭 + */ + +export interface SessionLaunch { + shellPath: string + shellArgs: string[] +} + +const FALLBACK_SHELL = '/bin/zsh' + +export function buildSessionLaunch(shell: string, argv: readonly string[]): SessionLaunch { + const shellPath = shell || FALLBACK_SHELL + // `-c` 后的第一个参数成为 $0,占位用 shell 自己的名字,后面的才是 "$@"。 + return { + shellPath, + shellArgs: ['-ilc', 'exec "$@"', path.basename(shellPath), ...argv], + } +} diff --git a/vscode/src/cc/sessionTerminal.ts b/vscode/src/cc/sessionTerminal.ts new file mode 100644 index 0000000..7c19a1b --- /dev/null +++ b/vscode/src/cc/sessionTerminal.ts @@ -0,0 +1,8 @@ +import type { SessionLaunch } from './sessionLaunch' +import { env } from 'vscode' +import { buildSessionLaunch } from './sessionLaunch' + +/** 用户在 VS Code 里的默认 shell 拉起会话程序,展开进 `window.createTerminal` 的选项。 */ +export function sessionLaunch(argv: readonly string[]): SessionLaunch { + return buildSessionLaunch(env.shell, argv) +} diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index bf4c4d2..e07e248 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -857,7 +857,7 @@ export class KanbanWebviewPanel { /** * Scan `vscode.window.terminals` for an existing terminal whose name * matches `expectedName`. Matches exact, or `startsWith(expectedName + ' ')` - * so a shell that appended a git branch suffix (e.g. "issue-48-实施 + * so a title sequence that appended a suffix (e.g. "issue-48-实施 * 5f56026c") still counts. Skips terminals whose process already exited * (`exitStatus !== undefined`) — those are zombie tabs and shouldn't block * a fresh spawn. @@ -875,8 +875,8 @@ export class KanbanWebviewPanel { } /** - * Open (or re-use) the review terminal tab for an issue and send the codex - * review command. Used by the webhook auto-review flow so the user can watch + * Open (or re-use) the review terminal tab for an issue and start codex + * review in it. Used by the webhook auto-review flow so the user can watch * codex work in real time instead of it running headless in the background. * * 只有已持久化 reviewSessionId 时才注入 "PR 更新了,再审一下";tab 活着但 @@ -890,7 +890,7 @@ export class KanbanWebviewPanel { /** workspace-relative or absolute path; if missing/invalid we fall back to workspaceRoot. */ worktreePath: string workspaceRoot: string - }): Promise { + }): Promise { return sessions.triggerAutoReviewTab(this, opts) } diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index b410661..3f04655 100644 --- a/vscode/src/panel/handlers/issues.ts +++ b/vscode/src/panel/handlers/issues.ts @@ -10,11 +10,12 @@ import * as os from 'node:os' import * as path from 'node:path' import { commands, env, ThemeColor, Uri, window, workspace } from 'vscode' import { getToken } from '../../auth/secrets' -import { buildCcCommand } from '../../cc/ccCommand' +import { buildCcArgv } from '../../cc/ccCommand' import { resolveCommitProfileForRun } from '../../cc/commitProfile' import { resolveProfilePath } from '../../cc/profiles' import { renderBrainstormPrompt } from '../../cc/prompts' import { claudeProjectsRoot, installClaudeSession } from '../../cc/sessionBundle' +import { sessionLaunch } from '../../cc/sessionTerminal' import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher' import { spawnClaude } from '../../cc/spawnClaude' import { gitFetch, resolveFeatureBranch } from '../../git/branches' @@ -1440,37 +1441,9 @@ export async function handleIssueCreate( nonce, imagePaths: imagePaths.length > 0 ? imagePaths : undefined, }) - if (prompt.includes('\'')) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: '创建失败:prompt 含单引号,拒绝执行', - dismissOnTimer: 8000, - }) - try { - await fsp.rm(inboxDir, { recursive: true, force: true }) - } - catch {} - return - } // 新建工单弹窗里选的 profile > 设置里的「头脑风暴默认配置文件」> DEFAULT。 const effectiveProfilePath = sessions.resolveBrainstormProfilePath(panel, brainstormProfilePath) - if (effectiveProfilePath.includes('\'')) { - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: `创建失败:brainstormProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, - dismissOnTimer: 8000, - }) - try { - await fsp.rm(inboxDir, { recursive: true, force: true }) - } - catch {} - return - } // Ensure the claude projects subdir exists *before* spawning the // terminal so the watcher can't miss the create event. @@ -1492,6 +1465,7 @@ export async function handleIssueCreate( location: panel.resolveTerminalLocation(false), iconPath: iconUri, color: themeColor, + ...sessionLaunch(buildCcArgv({ profilePath: effectiveProfilePath, prompt })), }) terminal.show(false) logger.add({ @@ -1513,13 +1487,10 @@ export async function handleIssueCreate( terminal, createdAt: Date.now(), }) - - const cmd = buildCcCommand({ profilePath: effectiveProfilePath, prompt }) - terminal.sendText(cmd) logger.add({ level: 'info', source: 'panel', - message: `已发送新建工单 prompt nonce=${shortNonce}`, + message: `已启动新建工单会话 nonce=${shortNonce}`, }) panel.postMessage({ diff --git a/vscode/src/panel/handlers/managedSessions.ts b/vscode/src/panel/handlers/managedSessions.ts index 2a843c6..9f9c6f4 100644 --- a/vscode/src/panel/handlers/managedSessions.ts +++ b/vscode/src/panel/handlers/managedSessions.ts @@ -1,8 +1,9 @@ import type { KanbanWebviewPanel } from '../KanbanPanel' import { promises as fsp } from 'node:fs' import { window, workspace } from 'vscode' -import { buildCcCommand } from '../../cc/ccCommand' +import { buildCcArgv } from '../../cc/ccCommand' import { resolveProfilePath } from '../../cc/profiles' +import { sessionLaunch } from '../../cc/sessionTerminal' import { pollForNewSession, projectsDirFor } from '../../cc/sessionWatcher' import { logger } from '../../logging/logger' import { listImportableNamedSessions } from '../../sessions/importableNamedSessions' @@ -71,39 +72,20 @@ export interface CreateCcSessionTabOptions { /** * 创建 cc 会话终端 tab(核心逻辑,不要求 panel): - * - 启动命令统一由 buildCcCommand 构建(claude --dangerously-skip-permissions - * --settings ''),prompt 非空时作为位置参数传入。 + * - 启动参数统一由 buildCcArgv 构建(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> { +export async function createCcSessionTab(opts: CreateCcSessionTabOptions): Promise<{ terminalName: string }> { 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 = '' @@ -134,6 +116,7 @@ export async function createCcSessionTab(opts: CreateCcSessionTabOptions): Promi name: trimmedName || 'cc-会话', cwd: effectiveCwd, location: resolveTerminalLocation(panel, false), + ...sessionLaunch(buildCcArgv({ profilePath: effectiveProfilePath, prompt: effectivePrompt || undefined })), }) terminal.show(false) logger.add({ @@ -142,9 +125,6 @@ export async function createCcSessionTab(opts: CreateCcSessionTabOptions): Promi 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) { @@ -197,9 +177,7 @@ export async function handleManagedSessionsCreate(panel: KanbanWebviewPanel, pro return } - const created = await createCcSessionTab({ workspaceRoot, profilePath, name, prompt, panel, register: true }) - if (!created) - return + await createCcSessionTab({ workspaceRoot, profilePath, name, prompt, panel, register: true }) logger.add({ level: 'info', @@ -280,18 +258,13 @@ export async function handleManagedSessionsResume(panel: KanbanWebviewPanel, ses } 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), + ...sessionLaunch(buildCcArgv({ profilePath: effectiveProfilePath, resumeSessionId: sessionId })), }) terminal.show(false) logger.add({ @@ -300,9 +273,6 @@ export async function handleManagedSessionsResume(panel: KanbanWebviewPanel, ses 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) diff --git a/vscode/src/panel/handlers/release.ts b/vscode/src/panel/handlers/release.ts index d96c137..d395a30 100644 --- a/vscode/src/panel/handlers/release.ts +++ b/vscode/src/panel/handlers/release.ts @@ -8,8 +8,9 @@ import type { CherryPickConflict } from '../../git/release' import type { KanbanWebviewPanel } from '../KanbanPanel' import { window, workspace } from 'vscode' -import { buildCcCommand } from '../../cc/ccCommand' +import { buildCcArgv } from '../../cc/ccCommand' import { resolveProfilePath } from '../../cc/profiles' +import { sessionLaunch } from '../../cc/sessionTerminal' import { gitFetch } from '../../git/branches' import { attachPulls, @@ -226,18 +227,13 @@ ${remaining} return } const profilePath = resolveProfilePath(getSettings(panel.context).conflictResolutionProfilePath.trim() || undefined) - if (profilePath.includes('\'')) { - toast(panel, 'error', `冲突解决失败:profilePath 含单引号 (${profilePath})`, { dismissOnTimer: 10_000 }) - return - } const terminal = window.createTerminal({ name: terminalName, cwd: conflict.worktreePath, location: panel.resolveTerminalLocation(false), + ...sessionLaunch(buildCcArgv({ profilePath, effort: 'high', prompt })), }) terminal.show(false) - // buildCcCommand 用单引号包提示词,内部的 ' 要转成 '\'' 才不会破壳。 - terminal.sendText(buildCcCommand({ profilePath, effort: 'high', prompt: prompt.replace(/'/g, '\'\\\'\'') })) logger.add({ level: 'info', source: 'panel', diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index 016e407..9d80bce 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -8,11 +8,12 @@ import * as path from 'node:path' import { window, workspace } from 'vscode' import { loginForToken } from '../../auth/identity' import { getToken } from '../../auth/secrets' -import { buildCcCommand } from '../../cc/ccCommand' -import { buildCodexCommandString } from '../../cc/codexCommand' +import { buildCcArgv } from '../../cc/ccCommand' +import { buildCodexArgv } from '../../cc/codexCommand' import { watchForCodexSession } from '../../cc/codexSessionWatcher' import { resolveProfilePath } from '../../cc/profiles' import { renderBrainstormPrompt, renderImplementPlanPrompt, renderPrototypePrompt, renderReviewPrompt, renderTestPrompt } from '../../cc/prompts' +import { sessionLaunch } from '../../cc/sessionTerminal' import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher' import { detectRepo } from '../../git/remote' import { ensureWorktree, resolveWorktreePath } from '../../git/worktree' @@ -140,16 +141,6 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: = sessionKind === 'implement' ? panel.resolveImplementProfilePath(profilePath) : resolveBrainstormProfilePath(panel, profilePath) - // Reject paths containing single quotes — we shell-quote with single - // quotes below, and embedded quotes would break out of the wrap. In - // practice profile paths live under `/home//...` so this is a - // defensive guard that should never fire. - if (effectiveProfilePath.includes('\'')) { - void window.showErrorMessage( - `resume 失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, - ) - return - } const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath // 实施会话住在 worktree 里;relCwd 可能是绝对路径 / `~/...` / 工作区相对 // 路径,统一走 resolveWorktreePath。worktree 已被清理(典型:卡片拖到 @@ -219,6 +210,7 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: cwd: effectiveCwd, location: panel.resolveTerminalLocation(false), ...(issueIcon ? { iconPath: issueIcon.iconUri, color: issueIcon.themeColor } : {}), + ...sessionLaunch(buildCcArgv({ profilePath: effectiveProfilePath, effort: sessionKind === 'implement' ? 'high' : undefined, resumeSessionId: sessionId })), }) panel.terminals.set(sessionId, terminal) if (issueNumber !== undefined) @@ -240,8 +232,6 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: dismissOnTimer: 5000, }) } - const cmd = buildCcCommand({ profilePath: effectiveProfilePath, effort: sessionKind === 'implement' ? 'high' : undefined, resumeSessionId: sessionId }) - terminal.sendText(cmd) } finally { if (lockKey !== undefined) @@ -332,23 +322,23 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi // `color` option. Force a `circle-filled` codicon so the tab actually // shows the issue color; keep `color` too for panel-view fallback. const { themeColor, iconUri } = await panel.resolveIssueIcon(issueNumber) + const settings = getSettings(panel.context) const terminal = window.createTerminal({ name: reviewTerminalName, cwd: effectiveCwd, location: panel.resolveTerminalLocation(false), iconPath: iconUri, color: themeColor, + ...sessionLaunch(buildCodexArgv({ + mode: 'resume', + sessionId, + model: settings.codexModel, + reasoningEffort: settings.codexReasoningEffort, + })), }) panel.reviewTerminals.set(sessionId, terminal) panel.trackSessionTerminal(terminal, issueNumber, 'review') terminal.show(false) - const settings = getSettings(panel.context) - terminal.sendText(buildCodexCommandString({ - mode: 'resume', - sessionId, - model: settings.codexModel, - reasoningEffort: settings.codexReasoningEffort, - })) logger.add({ level: 'info', source: 'terminal', @@ -362,8 +352,8 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi } /** - * Open (or re-use) the review terminal tab for an issue and send the codex - * review command. Used by the webhook auto-review flow so the user can watch + * Open (or re-use) the review terminal tab for an issue and start codex + * review in it. Used by the webhook auto-review flow so the user can watch * codex work in real time instead of it running headless in the background. * * 路径由 {@link decideAutoReviewPath} 决定(与 TUI 对齐): @@ -372,10 +362,6 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi * - new: 无 session → 完整启动 codex review * - noop: tab 活着但 session 尚未落盘 → 跳过(避免 opened 后紧跟 * synchronize 把首轮审查污染成再审文案) - * - * Returns true if the command was successfully dispatched (or intentionally - * skipped as noop / already in-flight), false if a pre-condition failed - * (single-quote in prompt — only checked on the new-terminal path). */ export async function triggerAutoReviewTab(panel: KanbanWebviewPanel, opts: { issueNumber: number @@ -384,7 +370,7 @@ export async function triggerAutoReviewTab(panel: KanbanWebviewPanel, opts: { /** workspace-relative or absolute path; if missing/invalid we fall back to workspaceRoot. */ worktreePath: string workspaceRoot: string -}): Promise { +}): Promise { // 防 opened + synchronize 竞态:同 issue 的自动审查启动中不重入。 const lockKey = `${opts.issueNumber}:auto-review` if (panel.resumeInFlight.has(lockKey)) { @@ -393,12 +379,12 @@ export async function triggerAutoReviewTab(panel: KanbanWebviewPanel, opts: { source: 'review', message: `自动审查 #${opts.issueNumber} 已在启动中,忽略重入`, }) - return true + return } panel.resumeInFlight.add(lockKey) try { - return await triggerAutoReviewTabUnlocked(panel, opts) + await triggerAutoReviewTabUnlocked(panel, opts) } finally { panel.resumeInFlight.delete(lockKey) @@ -411,7 +397,7 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: { prompt: string worktreePath: string workspaceRoot: string -}): Promise { +}): Promise { // Resolve cwd. If worktreePath is provided but doesn't exist on disk // (worktree was cleaned up after merge), fall back to workspaceRoot and // toast the user — same fallback as handleResumeSession. Only matters on @@ -472,7 +458,7 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: { panel.trackSessionTerminal(existing, opts.issueNumber, 'review') existing.show(false) } - return true + return } if (reviewPath === 'reuse') { @@ -487,7 +473,7 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: { source: 'terminal', message: `已发送"PR 更新了,再审一下"到复用的 #${opts.issueNumber}-审查 tab`, }) - return true + return } if (reviewPath === 'resume' && existingSessionId) { @@ -499,23 +485,23 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: { // rollout-*.jsonl, so there's nothing to capture. No cwdFallback // toast either — keep parity with the reuse branch. const { themeColor, iconUri } = await panel.resolveIssueIcon(opts.issueNumber) + const settings = getSettings(panel.context) const terminal = window.createTerminal({ name: terminalName, cwd: effectiveCwd, location: panel.resolveTerminalLocation(false), iconPath: iconUri, color: themeColor, + ...sessionLaunch(buildCodexArgv({ + mode: 'resume', + sessionId: existingSessionId, + model: settings.codexModel, + reasoningEffort: settings.codexReasoningEffort, + })), }) panel.reviewTerminals.set(existingSessionId, terminal) panel.trackSessionTerminal(terminal, opts.issueNumber, 'review') terminal.show(false) - const settings = getSettings(panel.context) - terminal.sendText(buildCodexCommandString({ - mode: 'resume', - sessionId: existingSessionId, - model: settings.codexModel, - reasoningEffort: settings.codexReasoningEffort, - })) // codex resume 要先从记录里把对话拼回来,拼完才吃得进字。 // 已经开着的审查终端不用等;冷启动留 8 秒,够它起来又不至于拖很久。 setTimeout(() => { @@ -526,36 +512,50 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: { source: 'review', message: `已 codex resume 审查会话 ${existingSessionId} 并注入再审消息 (#${opts.issueNumber})`, }) - return true + return } // --- new-terminal path: full codex startup + session watcher --- - // codex command is shell-quoted with single quotes; reject prompts that - // would break the quoting rather than try to escape (same posture as - // handleImplement). Only relevant on the new-terminal path — the reuse - // path sends a fixed string, not opts.prompt. - if (opts.prompt.includes('\'')) { - logger.add({ - level: 'error', - source: 'webhook', - message: `审查 prompt 含单引号,拒绝执行 #${opts.issueNumber}`, - }) - return false - } - const { themeColor, iconUri } = await panel.resolveIssueIcon(opts.issueNumber) + const settings = getSettings(panel.context) + + // 先盯会话目录再拉起 codex,免得文件在盯上之前就建好了。拿到的 id 存成 + // reviewSessionId:PR 再更新时靠它往这个窗口发「再审」,窗口关了也能接着聊。 + // codex 启动可能拖好几分钟,一直盯到这个终端关掉为止。 + const watchAbort = new AbortController() + const watcherPromise = watchForCodexSession({ + baseDir: defaultCodexSessionsDir(), + cwd: effectiveCwd, + signal: watchAbort.signal, + }) + + // Interactive TUI (`codex` without `exec`/`review` subcommands): keeps + // the terminal alive after the run so users can follow up with codex, + // mirroring how claude implementation/brainstorm sessions stay open. + // 提示词模板自带 /review 前缀,不在这里再拼。 const terminal = window.createTerminal({ name: terminalName, cwd: effectiveCwd, location: panel.resolveTerminalLocation(false), iconPath: iconUri, color: themeColor, + ...sessionLaunch(buildCodexArgv({ + mode: 'interactive', + prompt: opts.prompt, + model: settings.codexModel, + reasoningEffort: settings.codexReasoningEffort, + })), }) + const closeListener = window.onDidCloseTerminal((closed) => { + if (closed === terminal) + watchAbort.abort() + }) + void watcherPromise.finally(() => closeListener.dispose()) logger.add({ level: 'info', source: 'terminal', - message: `已创建审查终端 "${terminalName}" cwd=${effectiveCwd}`, + message: `已创建审查终端 "${terminalName}" cwd=${effectiveCwd},codex review 已启动`, }) panel.trackSessionTerminal(terminal, opts.issueNumber, 'review') terminal.show(false) @@ -570,38 +570,6 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: { }) } - // 先盯会话目录再发命令,免得文件在盯上之前就建好了。拿到的 id 存成 - // reviewSessionId:PR 再更新时靠它往这个窗口发「再审」,窗口关了也能接着聊。 - // codex 启动可能拖好几分钟,一直盯到这个终端关掉为止。 - const watchAbort = new AbortController() - const closeListener = window.onDidCloseTerminal((closed) => { - if (closed === terminal) - watchAbort.abort() - }) - const watcherPromise = watchForCodexSession({ - baseDir: defaultCodexSessionsDir(), - cwd: effectiveCwd, - signal: watchAbort.signal, - }).finally(() => closeListener.dispose()) - - // Interactive TUI (`codex` without `exec`/`review` subcommands): keeps - // the terminal alive after the run so users can follow up with codex, - // mirroring how claude implementation/brainstorm sessions stay open. - // 提示词模板自带 /review 前缀,不在这里再拼。 - const settings = getSettings(panel.context) - const cmd = buildCodexCommandString({ - mode: 'interactive', - prompt: opts.prompt, - model: settings.codexModel, - reasoningEffort: settings.codexReasoningEffort, - }) - terminal.sendText(cmd) - logger.add({ - level: 'info', - source: 'terminal', - message: `已发送 codex review 命令到 #${opts.issueNumber}-审查 tab`, - }) - // Fire-and-forget: when the watcher resolves with a thread_id, persist // it to state JSON + push an issue/patch so the webview shows the resume // button. Failures here don't break the review run itself. @@ -652,8 +620,6 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: { details: err instanceof Error ? err.message : String(err), }) }) - - return true } /** @@ -748,14 +714,6 @@ export async function handleImplement( // 工单级 profilePath > 默认 fallback。工单的 profile 锁定可在工单详情面板里覆盖。 const effectiveProfilePath = panel.resolveImplementProfilePath(profilePath) - // Same guard as handleResumeSession — we wrap with single quotes and - // embedded quotes would break out of the wrap. Defensive only. - if (effectiveProfilePath.includes('\'')) { - void window.showErrorMessage( - `实施失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, - ) - return - } const settings = getSettings(panel.context) const port = settings.webhookPort @@ -900,10 +858,6 @@ export async function handleImplement( issueNumber: panel.issueRefLabel(issueNumber), planFile, }) - if (prompt.includes('\'')) { - void window.showErrorMessage('实施失败:prompt 含单引号,拒绝执行') - return - } // 实施 cc tab 创建前钩子。tab 复用 fast path(顶部 findExistingTerminal // 命中)走早 return,不会执行到这里,刚好满足"复用不触发"语义。 @@ -926,6 +880,7 @@ export async function handleImplement( location: panel.resolveTerminalLocation(false), iconPath: iconUri, color: themeColor, + ...sessionLaunch(buildCcArgv({ profilePath: effectiveProfilePath, effort: 'high', prompt })), }) terminal.show(false) // Track for the auto-review flow: synchronize callbacks call @@ -938,12 +893,10 @@ export async function handleImplement( source: 'terminal', message: `已创建终端 "${terminal.name}"`, }) - const cmd = buildCcCommand({ profilePath: effectiveProfilePath, effort: 'high', prompt }) - terminal.sendText(cmd) logger.add({ level: 'info', source: 'implement', - message: `已发送 prompt 到终端 #${issueNumber}`, + message: `已启动实施会话 #${issueNumber}`, }) // Wait in the background for the session jsonl to materialize; merge @@ -1035,16 +988,6 @@ export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueN } const effectiveProfilePath = resolveBrainstormProfilePath(panel, brainstormProfilePath) - if (effectiveProfilePath.includes('\'')) { - void window.showErrorMessage( - `续聊失败:brainstormProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, - ) - return - } - if (prompt.includes('\'')) { - void window.showErrorMessage('续聊失败:prompt 含单引号,拒绝执行') - return - } // resume 沿用原 id,只有新开会话才要盯着 session jsonl 出现; // 监听要在 createTerminal 之前挂上,否则会漏掉创建事件。 @@ -1067,6 +1010,11 @@ export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueN location: panel.resolveTerminalLocation(false), iconPath: iconUri, color: themeColor, + ...sessionLaunch(buildCcArgv({ + profilePath: effectiveProfilePath, + resumeSessionId: continuePath === 'resume' ? sessionId : undefined, + prompt, + })), }) if (continuePath === 'resume' && sessionId) panel.terminals.set(sessionId, terminal) @@ -1077,18 +1025,12 @@ export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueN source: 'terminal', message: `已创建终端 "${terminal.name}"`, }) - - terminal.sendText(buildCcCommand({ - profilePath: effectiveProfilePath, - resumeSessionId: continuePath === 'resume' ? sessionId : undefined, - prompt, - })) logger.add({ level: 'info', source: 'brainstorm', message: continuePath === 'resume' ? `已带续聊 prompt 恢复 #${issueNumber} 规划会话 ${sessionId}` - : `已发送规划 prompt 到终端 #${issueNumber}`, + : `已启动 #${issueNumber} 规划会话`, }) // 拿到 session jsonl 后把 id 写回 state JSON,详情面板据此切到 resume 入口。 @@ -1200,12 +1142,6 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum // 测试会话只用 testProfilePath;空 = DEFAULT,不回退实施 profile。 const effectiveProfilePath = resolveTestProfilePath(panel, testProfilePath) - if (effectiveProfilePath.includes('\'')) { - void window.showErrorMessage( - `启动测试失败:testProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, - ) - return - } // worktree 里:代码就在当前工作目录,不用 tea 拉取,直接测; // 主 worktree:PR 已合并,先用 tea 熟悉改动再测。验收目标直接写进提示词, @@ -1217,10 +1153,6 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum ? `worktree 分支 ${branch || effectiveCwd}` : `PR #${pr}`, }) - if (prompt.includes('\'')) { - void window.showErrorMessage('启动测试失败:prompt 含单引号,拒绝执行') - return - } // Ensure the projects dir exists so the watcher can't miss the create // event. cwd 决定 claude session jsonl 落在哪个 projects 子目录下。 @@ -1254,6 +1186,7 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum location: panel.resolveTerminalLocation(false), iconPath: iconUri, color: themeColor, + ...sessionLaunch(buildCcArgv({ profilePath: effectiveProfilePath, prompt })), }) panel.trackSessionTerminal(terminal, issueNumber, 'test') terminal.show(false) @@ -1262,13 +1195,10 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum source: 'terminal', message: `已创建终端 "${terminal.name}"`, }) - - const cmd = buildCcCommand({ profilePath: effectiveProfilePath, prompt }) - terminal.sendText(cmd) logger.add({ level: 'info', source: 'panel', - message: `已发送测试 prompt 到终端 #${issueNumber}`, + message: `已启动测试会话 #${issueNumber}`, }) // Fire-and-forget: when the session jsonl materializes, persist the id as @@ -1335,7 +1265,7 @@ export async function handleStartReviewSession(panel: KanbanWebviewPanel, issueN return } - const ok = await triggerAutoReviewTab(panel, { + await triggerAutoReviewTab(panel, { issueNumber, prNumber: pr, prompt: renderReviewPrompt(panel.context, { @@ -1346,8 +1276,6 @@ export async function handleStartReviewSession(panel: KanbanWebviewPanel, issueN worktreePath, workspaceRoot, }) - if (!ok) - void window.showErrorMessage(`启动审查会话失败 #${issueNumber},详见扩展日志`) } /** @@ -1406,12 +1334,6 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session // --settings 决定 provider/model/鉴权,resume 也必须与 start 一致解析 profile。 const effectiveProfilePath = resolveTestProfilePath(panel, testProfilePath) - if (effectiveProfilePath.includes('\'')) { - void window.showErrorMessage( - `resume 失败:testProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, - ) - return - } const terminalName = `issue-${issueNumber}-测试` const existingByName = panel.findExistingTerminal(terminalName) @@ -1448,6 +1370,7 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session location: panel.resolveTerminalLocation(false), iconPath: iconUri, color: themeColor, + ...sessionLaunch(buildCcArgv({ profilePath: effectiveProfilePath, resumeSessionId: sessionId })), }) panel.trackSessionTerminal(terminal, issueNumber, 'test') terminal.show(false) @@ -1456,9 +1379,6 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session source: 'terminal', message: `已创建测试会话终端 #${issueNumber} cwd=${effectiveCwd}`, }) - - const cmd = buildCcCommand({ profilePath: effectiveProfilePath, resumeSessionId: sessionId }) - terminal.sendText(cmd) } finally { panel.resumeInFlight.delete(lockKey) @@ -1533,21 +1453,11 @@ export async function handleStartPrototypeSession(panel: KanbanWebviewPanel, iss // 原型会话只用 prototypeProfilePath;空 = DEFAULT,不回退其它会话 profile。 const effectiveProfilePath = resolvePrototypeProfilePath(panel, prototypeProfilePath) - if (effectiveProfilePath.includes('\'')) { - void window.showErrorMessage( - `启动原型失败:prototypeProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, - ) - return - } const prompt = renderPrototypePrompt(panel.context, { state, issueNumber: panel.issueRefLabel(issueNumber), }) - if (prompt.includes('\'')) { - void window.showErrorMessage('启动原型失败:prompt 含单引号,拒绝执行') - return - } // Ensure the projects dir exists so the watcher can't miss the create // event. cwd 决定 claude session jsonl 落在哪个 projects 子目录下。 @@ -1581,6 +1491,7 @@ export async function handleStartPrototypeSession(panel: KanbanWebviewPanel, iss location: panel.resolveTerminalLocation(false), iconPath: iconUri, color: themeColor, + ...sessionLaunch(buildCcArgv({ profilePath: effectiveProfilePath, prompt })), }) panel.trackSessionTerminal(terminal, issueNumber, 'prototype') terminal.show(false) @@ -1589,13 +1500,10 @@ export async function handleStartPrototypeSession(panel: KanbanWebviewPanel, iss source: 'terminal', message: `已创建终端 "${terminal.name}"`, }) - - const cmd = buildCcCommand({ profilePath: effectiveProfilePath, prompt }) - terminal.sendText(cmd) logger.add({ level: 'info', source: 'panel', - message: `已发送原型 prompt 到终端 #${issueNumber}`, + message: `已启动原型会话 #${issueNumber}`, }) // Fire-and-forget: when the session jsonl materializes, persist the id as @@ -1688,12 +1596,6 @@ export async function handleResumePrototypeSession(panel: KanbanWebviewPanel, se // --settings 决定 provider/model/鉴权,resume 也必须与 start 一致解析 profile。 const effectiveProfilePath = resolvePrototypeProfilePath(panel, prototypeProfilePath) - if (effectiveProfilePath.includes('\'')) { - void window.showErrorMessage( - `resume 失败:prototypeProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, - ) - return - } const terminalName = `issue-${issueNumber}-原型` const existingByName = panel.findExistingTerminal(terminalName) @@ -1728,6 +1630,7 @@ export async function handleResumePrototypeSession(panel: KanbanWebviewPanel, se location: panel.resolveTerminalLocation(false), iconPath: iconUri, color: themeColor, + ...sessionLaunch(buildCcArgv({ profilePath: effectiveProfilePath, resumeSessionId: sessionId })), }) panel.trackSessionTerminal(terminal, issueNumber, 'prototype') terminal.show(false) @@ -1736,9 +1639,6 @@ export async function handleResumePrototypeSession(panel: KanbanWebviewPanel, se source: 'terminal', message: `已创建原型会话终端 #${issueNumber} cwd=${effectiveCwd}`, }) - - const cmd = buildCcCommand({ profilePath: effectiveProfilePath, resumeSessionId: sessionId }) - terminal.sendText(cmd) } finally { panel.resumeInFlight.delete(lockKey) @@ -1868,7 +1768,7 @@ export async function startConflictResolution(panel: KanbanWebviewPanel, opts: { }) // 4. 启临时 cc 会话(不进 state json、不进任何 map),cwd 设为 worktree。 - const promptRaw = `当前 git 仓库正在 merge 一个分支但出现了冲突。你的工作目录已经在 worktree 内、分支已经是 ${featureBranch}。你的任务: + const prompt = `当前 git 仓库正在 merge 一个分支但出现了冲突。你的工作目录已经在 worktree 内、分支已经是 ${featureBranch}。你的任务: 1. 跑 \`git status\` 查看冲突文件 2. 逐个解决冲突 @@ -1881,9 +1781,6 @@ export async function startConflictResolution(panel: KanbanWebviewPanel, opts: { - 不要 push 到 ${devBranch} - 解决完后告诉用户「冲突已解决,PR #${prIndex} 已更新,请再拖一次工单到完成列」` - // 单引号 shell 转义:把每个 ' 转成 '\'' - const prompt = promptRaw.replace(/'/g, '\'\\\'\'') - // 与实施/测试会话一致:建终端前触发 impl-tab-pre-create 钩子(开 pycharm 等)。 await panel.dispatchWorktreeHook('impl-tab-pre-create', { workspaceRoot, @@ -1894,12 +1791,18 @@ export async function startConflictResolution(panel: KanbanWebviewPanel, opts: { customScriptPath: settings.implTabPreCreateScript, }) + // 冲突解决只用全局设置 conflictResolutionProfilePath;空串 = 默认。 + // 工单/设置里可能持久化了跨机绝对路径,统一走 resolveProfilePath remapping。 + const effectiveProfilePath = resolveProfilePath( + settings.conflictResolutionProfilePath.trim() || undefined, + ) let terminal: Terminal try { terminal = window.createTerminal({ name: `issue-${issueNumber}-冲突解决`, cwd: worktreeAbs, location: panel.resolveTerminalLocation(false), + ...sessionLaunch(buildCcArgv({ profilePath: effectiveProfilePath, effort: 'high', prompt })), }) } catch (err) { @@ -1920,29 +1823,6 @@ export async function startConflictResolution(panel: KanbanWebviewPanel, opts: { return } terminal.show(false) - // 冲突解决只用全局设置 conflictResolutionProfilePath;空串 = 默认。 - // 工单/设置里可能持久化了跨机绝对路径,统一走 resolveProfilePath remapping。 - const effectiveProfilePath = resolveProfilePath( - settings.conflictResolutionProfilePath.trim() || undefined, - ) - if (effectiveProfilePath.includes('\'')) { - logger.add({ - level: 'error', - source: 'panel', - message: `冲突解决:profilePath 含单引号,拒绝执行 (issue #${issueNumber})`, - details: effectiveProfilePath, - }) - panel.postMessage({ - type: 'toast/show', - id: makeNonce(), - level: 'error', - message: `冲突解决失败:profilePath 含单引号 (${effectiveProfilePath})`, - dismissOnTimer: 6000, - }) - return - } - const cmd = buildCcCommand({ profilePath: effectiveProfilePath, effort: 'high', prompt }) - terminal.sendText(cmd) logger.add({ level: 'info', diff --git a/vscode/src/panel/handlers/terminalTabs.ts b/vscode/src/panel/handlers/terminalTabs.ts index 209c040..7c15e5b 100644 --- a/vscode/src/panel/handlers/terminalTabs.ts +++ b/vscode/src/panel/handlers/terminalTabs.ts @@ -18,7 +18,7 @@ export interface TabLike { /** * `TabInputTerminal` 没有任何字段,只能用 tab.label 反查 `window.terminals`。 - * 终端 name 常被 shell 的 OSC title 序列改写(追加 git branch 等后缀), + * 终端 name 可能被 OSC 标题序列改写(在名字后追加后缀), * 精确匹配失败后再双向 startsWith 兜底。 */ export function findTerminalByTabLabel(terminals: readonly T[], label: string): T | undefined { diff --git a/vscode/src/panel/handlers/terminals.ts b/vscode/src/panel/handlers/terminals.ts index 8860c55..468ecae 100644 --- a/vscode/src/panel/handlers/terminals.ts +++ b/vscode/src/panel/handlers/terminals.ts @@ -47,8 +47,8 @@ export function injectIntoImplTerminal(panel: KanbanWebviewPanel, issueNumber: n void isFirstReview let terminal = panel.implTerminals.get(issueNumber) if (!terminal) { - // Match by prefix — shell OSC title escapes can append a git branch - // suffix to terminal.name (e.g. "issue-48-实施 5f56026c"). + // Match by prefix — OSC title escapes can append a suffix to + // terminal.name (e.g. "issue-48-实施 5f56026c"). const wantedPrefix = `issue-${issueNumber}-实施` for (const t of window.terminals) { if (t.name.startsWith(wantedPrefix)) { @@ -88,7 +88,7 @@ export function submitToSessionTerminal(terminal: Terminal, text: string): void * 本机是否开着该工单的存活终端(exitStatus undefined)——webhook 门禁对 * 无 assignee 存量工单的兜底证据。不依赖 panel:看板关闭时实施终端 * 仍活着,归属判定不能因此失明。前缀匹配策略与 injectIntoImplTerminal - * 一致(shell OSC 会在名字后追加分支后缀)。 + * 一致(终端标题序列可能在名字后追加后缀)。 */ export function hasLiveIssueSessionTerminal( issueNumber: number, @@ -103,7 +103,7 @@ export function hasLiveIssueSessionTerminal( /** * Scan `vscode.window.terminals` for an existing terminal whose name * matches `expectedName`. Matches exact, or `startsWith(expectedName + ' ')` - * so a shell that appended a git branch suffix (e.g. "issue-48-实施 + * so a title sequence that appended a suffix (e.g. "issue-48-实施 * 5f56026c") still counts. Skips terminals whose process already exited * (`exitStatus !== undefined`) — those are zombie tabs and shouldn't block * a fresh spawn. diff --git a/vscode/src/uri/createSessionUriHandler.ts b/vscode/src/uri/createSessionUriHandler.ts index 8c0f233..beb02d9 100644 --- a/vscode/src/uri/createSessionUriHandler.ts +++ b/vscode/src/uri/createSessionUriHandler.ts @@ -83,8 +83,6 @@ async function handleCreateSessionUri(uri: Uri): Promise { panel: null, register: false, }) - if (!created) - return logger.add({ level: 'info', diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index 52fc88b..1237074 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -1751,21 +1751,13 @@ class WebhookCoordinator { const prompt = renderReviewPrompt(this.ctx, { state, issueNumber: String(issueNumber), prNumber }) - const ok = await this.activePanel.triggerAutoReviewTab({ + await this.activePanel.triggerAutoReviewTab({ issueNumber, prNumber, prompt, worktreePath, workspaceRoot, }) - if (!ok) { - logger.add({ - level: 'warn', - source: 'webhook', - message: `triggerAutoReviewTab 失败 #${issueNumber}`, - }) - return - } logger.add({ level: 'info', diff --git a/vscode/test/cc/ccCommand.test.ts b/vscode/test/cc/ccCommand.test.ts index 9d4d83b..306d953 100644 --- a/vscode/test/cc/ccCommand.test.ts +++ b/vscode/test/cc/ccCommand.test.ts @@ -1,23 +1,38 @@ import { describe, expect, it, vi } from 'vitest' -import { buildCcCommand } from '../../src/cc/ccCommand' +import { buildCcArgv } from '../../src/cc/ccCommand' vi.mock('../../src/cc/claudeBin', () => ({ - resolveClaudeBin: () => 'claude', + resolveClaudeBin: () => '/Users/me/My Apps/claude', })) -describe('buildCcCommand', () => { +const BASE = ['/Users/me/My Apps/claude', '--dangerously-skip-permissions', '--settings', '/p.json'] + +describe('buildCcArgv', () => { it('appends the prompt as a positional arg for a fresh session', () => { - expect(buildCcCommand({ profilePath: '/p.json', prompt: 'hello' })) - .toBe('claude --dangerously-skip-permissions --settings \'/p.json\' \'hello\'') + expect(buildCcArgv({ profilePath: '/p.json', prompt: 'hello' })) + .toEqual([...BASE, 'hello']) }) it('resumes without a prompt when only a session id is given', () => { - expect(buildCcCommand({ profilePath: '/p.json', resumeSessionId: 'abc' })) - .toBe('claude --dangerously-skip-permissions --settings \'/p.json\' --resume abc') + expect(buildCcArgv({ profilePath: '/p.json', resumeSessionId: 'abc' })) + .toEqual([...BASE, '--resume', 'abc']) }) it('resumes and hands the prompt to the restored session', () => { - expect(buildCcCommand({ profilePath: '/p.json', resumeSessionId: 'abc', prompt: 'go on' })) - .toBe('claude --dangerously-skip-permissions --settings \'/p.json\' --resume abc \'go on\'') + expect(buildCcArgv({ profilePath: '/p.json', resumeSessionId: 'abc', prompt: 'go on' })) + .toEqual([...BASE, '--resume', 'abc', 'go on']) + }) + + it('puts effort first and keeps quotes / $ / newlines in paths and prompt as-is', () => { + const prompt = 'don\'t $touch\n"this"' + expect(buildCcArgv({ profilePath: '/it\'s/p.json', effort: 'high', prompt })).toEqual([ + '/Users/me/My Apps/claude', + '--effort', + 'high', + '--dangerously-skip-permissions', + '--settings', + '/it\'s/p.json', + prompt, + ]) }) })