Files
claude-code/src/tasks/LocalShellTask/LocalShellTask.tsx
claude-code-best db1f531691 style(B1-1): 格式化 ink/buddy/cli/context/screens/tasks/services/keybindings/state (43 files)
纯格式化:移除分号、React Compiler import、import 多行展开。
修复了 Box.tsx 和 ScrollBox.tsx 中无效的 global.d.ts import。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 21:56:11 +08:00

652 lines
18 KiB
TypeScript

import { feature } from 'bun:bundle'
import { stat } from 'fs/promises'
import {
OUTPUT_FILE_TAG,
STATUS_TAG,
SUMMARY_TAG,
TASK_ID_TAG,
TASK_NOTIFICATION_TAG,
TOOL_USE_ID_TAG,
} from '../../constants/xml.js'
import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js'
import type { AppState } from '../../state/AppState.js'
import type {
LocalShellSpawnInput,
SetAppState,
Task,
TaskContext,
TaskHandle,
} from '../../Task.js'
import { createTaskStateBase } from '../../Task.js'
import type { AgentId } from '../../types/ids.js'
import { registerCleanup } from '../../utils/cleanupRegistry.js'
import { tailFile } from '../../utils/fsOperations.js'
import { logError } from '../../utils/log.js'
import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'
import type { ShellCommand } from '../../utils/ShellCommand.js'
import {
evictTaskOutput,
getTaskOutputPath,
} from '../../utils/task/diskOutput.js'
import { registerTask, updateTaskState } from '../../utils/task/framework.js'
import { escapeXml } from '../../utils/xml.js'
import {
backgroundAgentTask,
isLocalAgentTask,
} from '../LocalAgentTask/LocalAgentTask.js'
import { isMainSessionTask } from '../LocalMainSessionTask.js'
import {
type BashTaskKind,
isLocalShellTask,
type LocalShellTaskState,
} from './guards.js'
import { killTask } from './killShellTasks.js'
/** Prefix that identifies a LocalShellTask summary to the UI collapse transform. */
export const BACKGROUND_BASH_SUMMARY_PREFIX = 'Background command '
const STALL_CHECK_INTERVAL_MS = 5_000
const STALL_THRESHOLD_MS = 45_000
const STALL_TAIL_BYTES = 1024
// Last-line patterns that suggest a command is blocked waiting for keyboard
// input. Used to gate the stall notification — we stay silent on commands that
// are merely slow (git log -S, long builds) and only notify when the tail
// looks like an interactive prompt the model can act on. See CC-1175.
const PROMPT_PATTERNS = [
/\(y\/n\)/i, // (Y/n), (y/N)
/\[y\/n\]/i, // [Y/n], [y/N]
/\(yes\/no\)/i,
/\b(?:Do you|Would you|Shall I|Are you sure|Ready to)\b.*\? *$/i, // directed questions
/Press (any key|Enter)/i,
/Continue\?/i,
/Overwrite\?/i,
]
export function looksLikePrompt(tail: string): boolean {
const lastLine = tail.trimEnd().split('\n').pop() ?? ''
return PROMPT_PATTERNS.some(p => p.test(lastLine))
}
// Output-side analog of peekForStdinData (utils/process.ts): fire a one-shot
// notification if output stops growing and the tail looks like a prompt.
function startStallWatchdog(
taskId: string,
description: string,
kind: BashTaskKind | undefined,
toolUseId?: string,
agentId?: AgentId,
): () => void {
if (kind === 'monitor') return () => {}
const outputPath = getTaskOutputPath(taskId)
let lastSize = 0
let lastGrowth = Date.now()
let cancelled = false
const timer = setInterval(() => {
void stat(outputPath).then(
s => {
if (s.size > lastSize) {
lastSize = s.size
lastGrowth = Date.now()
return
}
if (Date.now() - lastGrowth < STALL_THRESHOLD_MS) return
void tailFile(outputPath, STALL_TAIL_BYTES).then(
({ content }) => {
if (cancelled) return
if (!looksLikePrompt(content)) {
// Not a prompt — keep watching. Reset so the next check is
// 45s out instead of re-reading the tail on every tick.
lastGrowth = Date.now()
return
}
// Latch before the async-boundary-visible side effects so an
// overlapping tick's callback sees cancelled=true and bails.
cancelled = true
clearInterval(timer)
const toolUseIdLine = toolUseId
? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>`
: ''
const summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" appears to be waiting for interactive input`
// No <status> tag — print.ts treats <status> as a terminal
// signal and an unknown value falls through to 'completed',
// falsely closing the task for SDK consumers. Statusless
// notifications are skipped by the SDK emitter (progress ping).
const message = `<${TASK_NOTIFICATION_TAG}>
<${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine}
<${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}>
<${SUMMARY_TAG}>${escapeXml(summary)}</${SUMMARY_TAG}>
</${TASK_NOTIFICATION_TAG}>
Last output:
${content.trimEnd()}
The command is likely blocked on an interactive prompt. Kill this task and re-run with piped input (e.g., \`echo y | command\`) or a non-interactive flag if one exists.`
enqueuePendingNotification({
value: message,
mode: 'task-notification',
priority: 'next',
agentId,
})
},
() => {},
)
},
() => {}, // File may not exist yet
)
}, STALL_CHECK_INTERVAL_MS)
timer.unref()
return () => {
cancelled = true
clearInterval(timer)
}
}
function enqueueShellNotification(
taskId: string,
description: string,
status: 'completed' | 'failed' | 'killed',
exitCode: number | undefined,
setAppState: SetAppState,
toolUseId?: string,
kind: BashTaskKind = 'bash',
agentId?: AgentId,
): void {
// Atomically check and set notified flag to prevent duplicate notifications.
// If the task was already marked as notified (e.g., by TaskStopTool), skip
// enqueueing to avoid sending redundant messages to the model.
let shouldEnqueue = false
updateTaskState(taskId, setAppState, task => {
if (task.notified) {
return task
}
shouldEnqueue = true
return { ...task, notified: true }
})
if (!shouldEnqueue) {
return
}
// Abort any active speculation — background task state changed, so speculated
// results may reference stale task output. The prompt suggestion text is
// preserved; only the pre-computed response is discarded.
abortSpeculation(setAppState)
let summary: string
if (feature('MONITOR_TOOL') && kind === 'monitor') {
// Monitor is streaming-only (post-#22764) — the script exiting means
// the stream ended, not "condition met". Distinct from the bash prefix
// so Monitor completions don't fold into the "N background commands
// completed" collapse.
switch (status) {
case 'completed':
summary = `Monitor "${description}" stream ended`
break
case 'failed':
summary = `Monitor "${description}" script failed${exitCode !== undefined ? ` (exit ${exitCode})` : ''}`
break
case 'killed':
summary = `Monitor "${description}" stopped`
break
}
} else {
switch (status) {
case 'completed':
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" completed${exitCode !== undefined ? ` (exit code ${exitCode})` : ''}`
break
case 'failed':
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" failed${exitCode !== undefined ? ` with exit code ${exitCode}` : ''}`
break
case 'killed':
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" was stopped`
break
}
}
const outputPath = getTaskOutputPath(taskId)
const toolUseIdLine = toolUseId
? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>`
: ''
const message = `<${TASK_NOTIFICATION_TAG}>
<${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine}
<${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}>
<${STATUS_TAG}>${status}</${STATUS_TAG}>
<${SUMMARY_TAG}>${escapeXml(summary)}</${SUMMARY_TAG}>
</${TASK_NOTIFICATION_TAG}>`
enqueuePendingNotification({
value: message,
mode: 'task-notification',
priority: feature('MONITOR_TOOL') ? 'next' : 'later',
agentId,
})
}
export const LocalShellTask: Task = {
name: 'LocalShellTask',
type: 'local_bash',
async kill(taskId, setAppState) {
killTask(taskId, setAppState)
},
}
export async function spawnShellTask(
input: LocalShellSpawnInput & { shellCommand: ShellCommand },
context: TaskContext,
): Promise<TaskHandle> {
const { command, description, shellCommand, toolUseId, agentId, kind } = input
const { setAppState } = context
// TaskOutput owns the data — use its taskId so disk writes are consistent
const { taskOutput } = shellCommand
const taskId = taskOutput.taskId
const unregisterCleanup = registerCleanup(async () => {
killTask(taskId, setAppState)
})
const taskState: LocalShellTaskState = {
...createTaskStateBase(taskId, 'local_bash', description, toolUseId),
type: 'local_bash',
status: 'running',
command,
completionStatusSentInAttachment: false,
shellCommand,
unregisterCleanup,
lastReportedTotalLines: 0,
isBackgrounded: true,
agentId,
kind,
}
registerTask(taskState, setAppState)
// Data flows through TaskOutput automatically — no stream listeners needed.
// Just transition to backgrounded state so the process keeps running.
shellCommand.background(taskId)
const cancelStallWatchdog = startStallWatchdog(
taskId,
description,
kind,
toolUseId,
agentId,
)
void shellCommand.result.then(async result => {
cancelStallWatchdog()
await flushAndCleanup(shellCommand)
let wasKilled = false
updateTaskState<LocalShellTaskState>(taskId, setAppState, task => {
if (task.status === 'killed') {
wasKilled = true
return task
}
return {
...task,
status: result.code === 0 ? 'completed' : 'failed',
result: { code: result.code, interrupted: result.interrupted },
shellCommand: null,
unregisterCleanup: undefined,
endTime: Date.now(),
}
})
enqueueShellNotification(
taskId,
description,
wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed',
result.code,
setAppState,
toolUseId,
kind,
agentId,
)
void evictTaskOutput(taskId)
})
return {
taskId,
cleanup: () => {
unregisterCleanup()
},
}
}
/**
* Register a foreground task that could be backgrounded later.
* Called when a bash command has been running long enough to show the BackgroundHint.
* @returns taskId for the registered task
*/
export function registerForeground(
input: LocalShellSpawnInput & { shellCommand: ShellCommand },
setAppState: SetAppState,
toolUseId?: string,
): string {
const { command, description, shellCommand, agentId } = input
const taskId = shellCommand.taskOutput.taskId
const unregisterCleanup = registerCleanup(async () => {
killTask(taskId, setAppState)
})
const taskState: LocalShellTaskState = {
...createTaskStateBase(taskId, 'local_bash', description, toolUseId),
type: 'local_bash',
status: 'running',
command,
completionStatusSentInAttachment: false,
shellCommand,
unregisterCleanup,
lastReportedTotalLines: 0,
isBackgrounded: false, // Not yet backgrounded - running in foreground
agentId,
}
registerTask(taskState, setAppState)
return taskId
}
/**
* Background a specific foreground task.
* @returns true if backgrounded successfully, false otherwise
*/
function backgroundTask(
taskId: string,
getAppState: () => AppState,
setAppState: SetAppState,
): boolean {
// Step 1: Get the task and shell command from current state
const state = getAppState()
const task = state.tasks[taskId]
if (!isLocalShellTask(task) || task.isBackgrounded || !task.shellCommand) {
return false
}
const shellCommand = task.shellCommand
const description = task.description
const { toolUseId, kind, agentId } = task
// Transition to backgrounded — TaskOutput continues receiving data automatically
if (!shellCommand.background(taskId)) {
return false
}
setAppState(prev => {
const prevTask = prev.tasks[taskId]
if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) {
return prev
}
return {
...prev,
tasks: {
...prev.tasks,
[taskId]: { ...prevTask, isBackgrounded: true },
},
}
})
const cancelStallWatchdog = startStallWatchdog(
taskId,
description,
kind,
toolUseId,
agentId,
)
// Set up result handler
void shellCommand.result.then(async result => {
cancelStallWatchdog()
await flushAndCleanup(shellCommand)
let wasKilled = false
let cleanupFn: (() => void) | undefined
updateTaskState<LocalShellTaskState>(taskId, setAppState, t => {
if (t.status === 'killed') {
wasKilled = true
return t
}
// Capture cleanup function to call outside of updater
cleanupFn = t.unregisterCleanup
return {
...t,
status: result.code === 0 ? 'completed' : 'failed',
result: { code: result.code, interrupted: result.interrupted },
shellCommand: null,
unregisterCleanup: undefined,
endTime: Date.now(),
}
})
// Call cleanup outside of the state updater (avoid side effects in updater)
cleanupFn?.()
if (wasKilled) {
enqueueShellNotification(
taskId,
description,
'killed',
result.code,
setAppState,
toolUseId,
kind,
agentId,
)
} else {
const finalStatus = result.code === 0 ? 'completed' : 'failed'
enqueueShellNotification(
taskId,
description,
finalStatus,
result.code,
setAppState,
toolUseId,
kind,
agentId,
)
}
void evictTaskOutput(taskId)
})
return true
}
/**
* Background ALL foreground tasks (bash commands and agents).
* Called when user presses Ctrl+B to background all running tasks.
*/
/**
* Check if there are any foreground tasks (bash or agent) that can be backgrounded.
* Used to determine whether Ctrl+B should background existing tasks vs. background the session.
*/
export function hasForegroundTasks(state: AppState): boolean {
return Object.values(state.tasks).some(task => {
if (isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand) {
return true
}
// Exclude main session tasks - they display in the main view, not as foreground tasks
if (
isLocalAgentTask(task) &&
!task.isBackgrounded &&
!isMainSessionTask(task)
) {
return true
}
return false
})
}
export function backgroundAll(
getAppState: () => AppState,
setAppState: SetAppState,
): void {
const state = getAppState()
// Background all foreground bash tasks
const foregroundBashTaskIds = Object.keys(state.tasks).filter(id => {
const task = state.tasks[id]
return isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand
})
for (const taskId of foregroundBashTaskIds) {
backgroundTask(taskId, getAppState, setAppState)
}
// Background all foreground agent tasks
const foregroundAgentTaskIds = Object.keys(state.tasks).filter(id => {
const task = state.tasks[id]
return isLocalAgentTask(task) && !task.isBackgrounded
})
for (const taskId of foregroundAgentTaskIds) {
backgroundAgentTask(taskId, getAppState, setAppState)
}
}
/**
* Background an already-registered foreground task in-place.
* Unlike spawn(), this does NOT re-register the task — it flips isBackgrounded
* on the existing registration and sets up a completion handler.
* Used when the auto-background timer fires after registerForeground() has
* already registered the task (avoiding duplicate task_started SDK events
* and leaked cleanup callbacks).
*/
export function backgroundExistingForegroundTask(
taskId: string,
shellCommand: ShellCommand,
description: string,
setAppState: SetAppState,
toolUseId?: string,
): boolean {
if (!shellCommand.background(taskId)) {
return false
}
let agentId: AgentId | undefined
setAppState(prev => {
const prevTask = prev.tasks[taskId]
if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) {
return prev
}
agentId = prevTask.agentId
return {
...prev,
tasks: {
...prev.tasks,
[taskId]: { ...prevTask, isBackgrounded: true },
},
}
})
const cancelStallWatchdog = startStallWatchdog(
taskId,
description,
undefined,
toolUseId,
agentId,
)
// Set up result handler (mirrors backgroundTask's handler)
void shellCommand.result.then(async result => {
cancelStallWatchdog()
await flushAndCleanup(shellCommand)
let wasKilled = false
let cleanupFn: (() => void) | undefined
updateTaskState<LocalShellTaskState>(taskId, setAppState, t => {
if (t.status === 'killed') {
wasKilled = true
return t
}
cleanupFn = t.unregisterCleanup
return {
...t,
status: result.code === 0 ? 'completed' : 'failed',
result: { code: result.code, interrupted: result.interrupted },
shellCommand: null,
unregisterCleanup: undefined,
endTime: Date.now(),
}
})
cleanupFn?.()
const finalStatus = wasKilled
? 'killed'
: result.code === 0
? 'completed'
: 'failed'
enqueueShellNotification(
taskId,
description,
finalStatus,
result.code,
setAppState,
toolUseId,
undefined,
agentId,
)
void evictTaskOutput(taskId)
})
return true
}
/**
* Mark a task as notified to suppress a pending enqueueShellNotification.
* Used when backgrounding raced with completion — the tool result already
* carries the full output, so the <task_notification> would be redundant.
*/
export function markTaskNotified(
taskId: string,
setAppState: SetAppState,
): void {
updateTaskState(taskId, setAppState, t =>
t.notified ? t : { ...t, notified: true },
)
}
/**
* Unregister a foreground task when the command completes without being backgrounded.
*/
export function unregisterForeground(
taskId: string,
setAppState: SetAppState,
): void {
let cleanupFn: (() => void) | undefined
setAppState(prev => {
const task = prev.tasks[taskId]
// Only remove if it's a foreground task (not backgrounded)
if (!isLocalShellTask(task) || task.isBackgrounded) {
return prev
}
// Capture cleanup function to call outside of updater
cleanupFn = task.unregisterCleanup
const { [taskId]: removed, ...rest } = prev.tasks
return { ...prev, tasks: rest }
})
// Call cleanup outside of the state updater (avoid side effects in updater)
cleanupFn?.()
}
async function flushAndCleanup(shellCommand: ShellCommand): Promise<void> {
try {
await shellCommand.taskOutput.flush()
shellCommand.cleanup()
} catch (error) {
logError(error)
}
}