🐛 fix(vscode): 会话终端直接拉起 claude/codex,不再往 shell 里打命令,启动命令不再污染 zsh 历史

新建工单、实施、审查、测试、原型、续聊、冲突解决、会话管理和各种恢复会话,
都改成开终端时直接指定要跑的程序(用户默认 shell 以 -ilc 'exec "$@"' 拉起),
环境变量照常加载,参数原样传入。程序退出时标签页随之关闭。
提示词和配置文件路径里带单引号也不再拒绝执行,相关的转义和拦截全部删掉。
This commit is contained in:
chehongweo committed 2026-09-29 04:40:35 +08:00
1 parent 21000d48e9
commit 3621729367
16 files changed
+232 -330

No files matched your search

+14 -18
View File
@@ -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 <id>` so cc restores that conversation. */
resumeSessionId?: string
/**
* When set, append the prompt as a positional `'<prompt>'` 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
}
+26
View File
@@ -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,
])
})
})
+8 -8
View File
@@ -1,8 +1,8 @@
/**
* Single source of truth for assembling `codex` invocations.
*
* The review flow shells codex two ways — `codex resume <id>` and the
* interactive `codex '<prompt>'` TUI — both sent to a terminal. Building the
* The review flow launches codex two ways — `codex resume <id>` and the
* interactive `codex <prompt>` 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 <id>`;'interactive' = `codex '<prompt>'` */
/** 'resume' = `codex resume <id>`;'interactive' = `codex <prompt>` */
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]
}
+25
View File
@@ -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)
})
})
+25
View File
@@ -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],
}
}
+8
View File
@@ -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)
}
+4 -4
View File
@@ -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<boolean> {
}): Promise<void> {
return sessions.triggerAutoReviewTab(this, opts)
}
+4 -33
View File
@@ -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({
+8 -38
View File
@@ -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 '<profilePath>'),prompt 非空时作为位置参数传入。
* - 启动参数统一由 buildCcArgv 构建(claude --dangerously-skip-permissions
* --settings <profilePath>),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 <name>;否则交互式无提示词。
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)
+3 -7
View File
@@ -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',
+77 -197
View File
@@ -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/<user>/...` 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<boolean> {
}): Promise<void> {
// 防 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<boolean> {
}): Promise<void> {
// 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',
+1 -1
View File
@@ -18,7 +18,7 @@ export interface TabLike {
/**
* `TabInputTerminal` 没有任何字段,只能用 tab.label 反查 `window.terminals`。
* 终端 name 常被 shell 的 OSC title 序列改写(追加 git branch 等后缀),
* 终端 name 可能被 OSC 标题序列改写(在名字后追加后缀),
* 精确匹配失败后再双向 startsWith 兜底。
*/
export function findTerminalByTabLabel<T extends TerminalLike>(terminals: readonly T[], label: string): T | undefined {
+4 -4
View File
@@ -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.
@@ -83,8 +83,6 @@ async function handleCreateSessionUri(uri: Uri): Promise<void> {
panel: null,
register: false,
})
if (!created)
return
logger.add({
level: 'info',
+1 -9
View File
@@ -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',
+24 -9
View File
@@ -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,
])
})
})