ShellCommand TaskOutput LocalShellTask 后台执行模型
这页回答什么
Claude Code 里的 bash 命令,前台执行、后台化、输出持久化、卡住提示、完成回流,到底是怎么串起来的?为什么它不是“开个 shell 然后等结果”这么简单?
关键文件:
src/utils/Shell.tssrc/utils/ShellCommand.tssrc/utils/task/TaskOutput.tssrc/utils/task/diskOutput.tssrc/tools/BashTool/BashTool.tsxsrc/tasks/LocalShellTask/LocalShellTask.tsxsrc/query.tssrc/utils/attachments.tssrc/cli/print.ts
一句话结论
Claude Code 里的后台 shell 不是单纯一个 child process,而是
ShellCommand + TaskOutput + LocalShellTask + task-notification四层协作的执行模型。ShellCommand持有真实进程与 kill/background 语义,TaskOutput作为统一输出事实源,LocalShellTask托管后台生命周期,最终通过task-notification -> queued_command -> attachment回流到下一轮 query。
也就是说:
background shell 的本质,是一次“执行所有权从前台 tool 调用移交给任务系统”的过程。
1. Shell.ts 只负责起进程,不负责后台生命周期
exec() 做的核心动作是:
- 解析 shell provider
- 生成执行命令和 cwd 跟踪文件
- 建
TaskOutput(taskId, onProgress, stdoutToFile) spawn(...)- 用
wrapSpawn(...)包成ShellCommand
它还顺手处理:
- cwd 丢失恢复
- sandbox 包裹与清扫
- 前台命令完成后的 cwd 更新
但它不托管后台 task 生命周期。后台后的 owner 不是 Shell.ts,而是 LocalShellTask。
所以这一层更准确的定位是:
Shell.ts= 进程执行壳BashTool.tsx= 前台 / 后台切换协调层LocalShellTask= 后台托管层
2. ShellCommand 定义了真实进程控制语义
kill() 不是表面取消,而是进程树强杀
ShellCommandImpl.#doKill() 里直接:
treeKill(this.#childProcess.pid, 'SIGKILL')所以这里不是“杀 shell 自己”,而是按 process tree 强杀。
这解释了为什么:
killShellTasksForAgent()是真实 orphan-shell reaper- 背后不是 UI 状态翻转,而是实打实的进程树终止
interrupt 不直接 kill
#abortHandler() 有个关键分支:
if (this.#abortSignal.reason === 'interrupt') {
return
}用户发来新消息时,不直接 kill 当前命令,而是把是否 background 的决策留给上层。这意味着 Claude Code 把 interrupt 看成:
当前 shell 也许应该继续跑,只是不能再阻塞主 agent。
完成判定看 exit,不是 close
ShellCommand 明确监听:
childProcess.once('exit', ...)- 而不是
close
源码注释写得很清楚:
close会等 stdio 彻底关闭- 这会被继承 fd 的孙进程拖住
exit则在 shell 本身退出时就收口
所以它优先确保:
shell 退了就尽快交还控制权,不被后台残留 fd 卡住。
3. background() 不是重跑命令,而是真切换执行模式
ShellCommand.background(taskId) 只在 status === 'running' 时生效,动作包括:
- 记录
backgroundTaskId status = 'backgrounded'- 拆掉前台 timeout / abort listener
然后分两种模式:
file mode
bash 默认走 file mode:
- stdout/stderr 直接写到同一个 output file fd
- background 后启动 size watchdog
- 防止 stuck append loop 把盘打爆
pipe mode
hooks / onStdout 走 pipe mode:
- 内存里已有的输出会
spillToDisk() - 之后读取统一落到磁盘文件语义上
所以 background 不是“换个状态位”,而是:
前台控制层退场,后台守护层接管。
4. TaskOutput 是 shell 输出的统一事实源
这层很关键,它不是单纯缓存日志,而是整条链上的“输出账本”。
file mode
- stdout/stderr 直接写同一个 output file
- JS 不实时吃 stdout/stderr 流
- progress 靠共享 poller
tailFile(...) getStdout()最终从文件读
pipe mode
- stdout/stderr 先进入内存 buffer
- 超阈值后 spill 到磁盘
getStdout()返回内存内容,或尾部 + truncation notice
小输出 vs 大输出
TaskOutput.getStdout() / ShellCommand.#handleExit() 还定义了一个重要协议:
- 如果 output file 足够小,全文已进
ExecResult.stdout- 文件 redundant,可删
- 如果过大
- 只给 inline 片段
- 同时返回:
outputFilePathoutputFileSizeoutputTaskId
所以 Claude Code 的真实语义是:
小输出 inline,大输出持久化并把路径交给上层。
5. BashTool 的前台循环,本质是一个 ownership 决策器
前台 bash 路径在 BashTool.tsx 里,会:
exec(...)拿到shellCommand- 先走前台 progress loop
- 视情况决定是否 background
有三条后台化入口
- 用户显式
run_in_background - timeout 触发
onTimeout(...) - assistant mode 下,为保持主线程响应,超过 budget 自动后台化
核心点:后台化不是重新执行命令
无论 spawnBackgroundTask() 还是 backgroundExistingForegroundTask(),都不是重跑 bash,而是把同一个 shellCommand 挂到 LocalShellTask 上。
这意味着:
Claude Code 的 background bash 不是“再开一个新 shell”,而是把原进程的管理权交接出去。
竞态修补
BashTool 还专门处理了一个关键 race:
- 后台化信号已经发出
- 但命令在下一次 poll tick 前其实已经跑完
这时会:
markTaskNotified(taskId)- 去掉
backgroundTaskId - 必要时补回
outputFilePath - 把结果当普通已完成 foreground result 返回
也就是说,它避免了“其实已经完成却又多发一条后台通知”的双重结果问题。
6. LocalShellTask 才是后台 shell 的真正 owner
后台托管后,LocalShellTask 做的是:
- 注册 task state
shellCommand.background(taskId)- 启动 stall watchdog
- 等
shellCommand.result flushAndCleanup(shellCommand)- 更新 terminal task status
enqueueShellNotification(...)evictTaskOutput(taskId)
所以前台和后台的 cleanup ownership 很明确:
- 前台完成:
BashTool自己 cleanup - 后台完成:
LocalShellTaskcleanup
这不是共享清理责任,而是清晰移交。
7. shell 卡在交互 prompt 也会走任务回流
LocalShellTask 里的 startStallWatchdog() 每隔一段时间:
- 看 output file 是否继续增长
- tail 最后 1KB
- 判断最后一行是否像
(y/n)、Overwrite?、Press Enter这类 prompt
如果像,就发一条 没有 <status> 的 task-notification。
这点很重要:
- 它不是终态通知
- 而是一条中间事件
print.ts只把带<status>的 notification 记成 SDK 终态事件
所以 stalled prompt 的语义是:
任务没结束,但模型现在应该知道它被交互输入卡住了。
8. shell 完成回流,走的是同一条 task-notification 协议
后台 shell 终态时,enqueueShellNotification() 会生成:
<task_notification><task_id><output-file><status><summary>
然后:
enqueuePendingNotification({
value: message,
mode: 'task-notification',
})这说明后台 shell 和 local async agent / remote agent 一样,最后都收敛到同一条回流协议。
turn 尾 drain
query.ts 会在回合末尾:
- 取
queuedCommandsSnapshot - 过滤当前线程该吃的命令
- 交给
getQueuedCommandAttachments(...) - 变成
type: 'queued_command'
message 重包装
messages.ts 再把 queued_command 包成一种 system reminder 风格的 user message,让模型在下一轮真正看见这条通知。
所以 shell 完成并不是 UI 内部消息,而是:
正式进入下一轮模型上下文的结构化事件。
9. UI 和工具读取 shell 输出,也统一依赖 output file
ShellDetailDialog
直接:
getTaskOutputPath(shell.id)tailFile(path, 8192)
TaskOutputTool
源码已经明确标 deprecated:
Prefer using Read on the task output file path instead
这意味着 Claude Code 正在把“读后台任务输出”的标准路径收束为:
- 看 task notification 里的
outputFilePath - 然后直接
Read
而不是依赖专门的 task-output API。
10. 整体模型
把这一整条线压缩后,可以得到一个更准确的描述:
ShellCommand
- 持有真实 child process
- 定义 kill / background / timeout / result 语义
TaskOutput
- 持有统一输出事实源
- 管内存 / 磁盘 / truncation / progress
BashTool
- 前台 inline 消费 shell 执行
- 在合适时机把 ownership 移交出去
LocalShellTask
- 托管后台 shell 生命周期
- 处理 stall / terminal notification / cleanup / evict
query.ts + attachments.ts + messages.ts
- 把后台 shell 事件重新注入下一轮模型上下文
所以 Claude Code 的后台 shell 不是“一个运行中的 bash”,而是:
一个由任务系统托管、以磁盘输出为事实源、以 task-notification 为回流协议的长寿命执行对象。
与其他页面的关系
- task-notification 到 attachment 回流序列图 讲统一回流协议
- foreground 到 background 热切换机制 讲 agent 的 execution shell 切换
- killShellTasksForAgent 机制 讲 agent 终止时对 shell orphan 的清扫
- 这页则聚焦 bash / shell runtime 自身的执行模型
当前结论
- background shell 的本质不是重跑命令,而是同一个
shellCommand的 ownership handoff TaskOutput不是附属日志,而是统一输出账本LocalShellTask是后台 shell 的真正生命周期 owner- 完成、失败、被 kill、卡在 prompt,最终都能通过
task-notification协议重新回到模型上下文 - shell 这条线和 agent 异步路径在回流协议上是统一的,只是中间执行壳不同