ShellCommand TaskOutput LocalShellTask 后台执行模型

这页回答什么

Claude Code 里的 bash 命令,前台执行、后台化、输出持久化、卡住提示、完成回流,到底是怎么串起来的?为什么它不是“开个 shell 然后等结果”这么简单?

关键文件:

  • src/utils/Shell.ts
  • src/utils/ShellCommand.ts
  • src/utils/task/TaskOutput.ts
  • src/utils/task/diskOutput.ts
  • src/tools/BashTool/BashTool.tsx
  • src/tasks/LocalShellTask/LocalShellTask.tsx
  • src/query.ts
  • src/utils/attachments.ts
  • src/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 片段
    • 同时返回:
      • outputFilePath
      • outputFileSize
      • outputTaskId

所以 Claude Code 的真实语义是:

小输出 inline,大输出持久化并把路径交给上层。


5. BashTool 的前台循环,本质是一个 ownership 决策器

前台 bash 路径在 BashTool.tsx 里,会:

  • exec(...) 拿到 shellCommand
  • 先走前台 progress loop
  • 视情况决定是否 background

有三条后台化入口

  1. 用户显式 run_in_background
  2. timeout 触发 onTimeout(...)
  3. 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
  • 后台完成:LocalShellTask cleanup

这不是共享清理责任,而是清晰移交。


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 为回流协议的长寿命执行对象。


与其他页面的关系

当前结论

  • background shell 的本质不是重跑命令,而是同一个 shellCommand 的 ownership handoff
  • TaskOutput 不是附属日志,而是统一输出账本
  • LocalShellTask 是后台 shell 的真正生命周期 owner
  • 完成、失败、被 kill、卡在 prompt,最终都能通过 task-notification 协议重新回到模型上下文
  • shell 这条线和 agent 异步路径在回流协议上是统一的,只是中间执行壳不同