Bash:解析、只读判定与后台任务
Bash 工具的输入与提示词、unbash 解析与只读判定规则体系、判定结果在权限层的含义、命令注册表生成、工作目录与 git 护栏、超时与自动转后台、输出截断与图片、TaskOutput 与 TaskStop,以及 Bash 读文件怎样回填读取状态。
Bash 是 ZCode 里权限面最宽的内置工具之一。它的静态声明是 readOnly: false、sideEffectScope: "system"、riskLevel: "high"、needsApproval: true(apps/zcode-cli/packages/core/src/tool/handlers/bash.ts:446),但每次调用都会先被解析一遍:能证明是只读的命令当场降级为低风险、免审批;证明不了的,才按高风险走审批。这套判定加上后台生命周期、输出投影和读取状态联动,占了 apps/zcode-cli/packages/core/src/tool/handlers/ 下 37 个 bash*.ts 文件(约 6100 行),外加一个 1.9 MB 的生成文件。
本篇只讲 Bash 这一侧。子进程怎样起、用哪个 shell、环境变量怎样清洗,见下一篇执行边界;后台任务的统一注册表与完成通知见后台任务与通知;权限规则的语法与匹配顺序见权限模式与规则。
位置(core/src/tool/handlers/ 下) | 职责 |
|---|---|
bash.ts、bash-prompt.ts、bash-background-*.ts | 工具入口:组装执行请求,选前台、显式后台或超时转后台 |
bash-command-parser.ts | 用 unbash 把命令拆成简单命令序列 |
bash-semantics.ts、bash-readonly-policy*.ts(20 个) | 只读判定、退出码语义 |
bash-command-permission-policy.ts、bash-command-rule-evaluator.ts、generated/bash-command-registry.ts | 权限规则的匹配主语与“始终允许”建议 |
bash-git-runtime-safety.ts、bash-cwd-policy.ts | git 运行时护栏、工作目录保留与重置 |
bash-output.ts、bash-model-content.ts、bash-image-output.ts、bash-gh-rate-limit.ts | 结果对象与模型可见文本 |
bash-read-file-sources.ts、bash-read-file-state.ts | 与读取状态联动 |
task-output*.ts、task-stop.ts | 读后台输出、停止后台任务 |
输入与提示词
输入 schema 在 apps/zcode-cli/packages/contracts/src/tools/bash.ts:32,是一个 strict 对象:
| 字段 | 含义 |
|---|---|
command | 要执行的命令字符串 |
timeout | 毫秒,描述里写明上限 600000(contracts/src/tools/bash.ts:11);字符串数字也会被转成数字(contracts/src/tools/bash.ts:78) |
description | 一句主动语态的说明,简单命令 5 到 10 个词,字段描述里带了 ls、git status 等示例(contracts/src/tools/bash.ts:14) |
run_in_background | 显式后台运行 |
dangerouslyDisableSandbox | 字面意思是“绕过沙箱”,但下一篇会看到它没有实际效果 |
两个布尔字段接受 "true"、"yes"、"1"、"on" 这类字符串(contracts/src/tools/bash.ts:12、contracts/src/tools/bash.ts:88),对模型偶尔吐出的字符串布尔值很宽容。
工具描述由 apps/zcode-cli/packages/core/src/tool/handlers/bash-prompt.ts:1 拼出,篇幅不长,核心是这两句(bash-prompt.ts:13、bash-prompt.ts:16):
Working directory persists between calls, but prefer absolute paths —
cdin a compound command can trigger a permission prompt.
run_in_backgroundruns the command detached: it keeps running across turns and re-invokes you when it exits. No&needed.
其余几条:不要用 Bash 跑 find、grep、cat、head、tail、sed、awk、echo,改用专用工具(开启内嵌搜索时 find、grep 从名单里去掉,因为 shell 里的这两个命令已被替换,见读、写、改、搜);超时的默认值与上限写成具体数字;Git 一节禁止交互式参数(git rebase -i 之类),要求用 gh 操作 GitHub,只在用户要求时提交或推送,在默认分支上先建分支(bash-prompt.ts:18)。
一次调用的路径
入口 executeBashHandler(apps/zcode-cli/packages/core/src/tool/handlers/bash.ts:104)先组装执行请求(handlers/bash.ts:388):命令以 mode: "shell"、shellProfile: "posix-bash" 交给执行端口,带上会话固定的 shell 选择;前台命令要求成功后回传最终工作目录(captureCwdAfterSuccess,handlers/bash.ts:420);内联输出上限 30000 字节,后台一律落盘、前台只在截断时保留落盘文件(handlers/bash.ts:422)。然后在三条路里选一条(handlers/bash.ts:172):
const runCommand = async () =>
parsed.run_in_background && backgroundLifecyclePort
? backgroundLifecyclePort.runBashWithBackgroundLifecycle(
request,
{ mode: "explicit" },
runOptions,
)
: eligibleForAutoBackground && backgroundLifecyclePort
? backgroundLifecyclePort.runBashWithBackgroundLifecycle(
request,
{ mode: "auto_on_timeout" },
runOptions,
)
: {
kind: "foreground" as const,
result: await executionPort.run(request, runOptions),
};注意中间那条:普通前台命令默认也走后台生命周期,模式是 auto_on_timeout。只有两种情况退回纯前台:命令首词是 sleep(apps/zcode-cli/packages/core/src/tool/handlers/bash-background-policy.ts:3),或者当前是闲时回合。闲时回合还会直接拒绝 run_in_background,注释解释了原因:后台命令完成后会另起一轮通知回合,而那一轮不带闲时模型,会落到用户自己的套餐上跑完整的 Agent 循环(handlers/bash.ts:136)。闲时任务本身见定时任务与闲时任务。
命令运行 2 秒后,适配器每秒读一次输出文件尾部(apps/zcode-cli/packages/adapters/src/exec/execution-utils.ts:14),工具把它转成 ToolCallProgress 事件,带已用时间、字节数和输出预览,界面据此滚动显示(handlers/bash.ts:359)。
命令解析:unbash
解析用的是 bash 解析库 unbash,版本锁在 4.0.1(apps/zcode-cli/packages/core/package.json:44)。analyzeBashCommand(apps/zcode-cli/packages/core/src/tool/handlers/bash-command-parser.ts:51)的产出是一串“简单命令调用”,每条记下 argv、原文片段、前缀环境赋值、重定向和它前面的连接符(&&、||、|、|& 或顺序执行,bash-command-parser.ts:14)。几个处理原则:
- 只展开四种节点。
Statement、AndOr、Pipeline是容器,Command是简单命令;其余任何节点类型都只记进unsupportedNodeTypes,不再往里走(bash-command-parser.ts:129)。从代码看,括号子 shell、if/for/while、函数定义这类复合结构都不在这四种之内。语句末尾的&也记为一种不支持的语法(bash-command-parser.ts:115)。 - 长度与错误:超过 10000 个字符直接按解析失败处理(
bash-command-parser.ts:48);parse抛异常,或返回的errors非空,同样记为解析失败(bash-command-parser.ts:64、bash-command-parser.ts:76)。 - 重定向:语句级与命令级的重定向合并到每条命令上,目标和 here-doc 正文也要检查是否动态(
bash-command-parser.ts:166、bash-command-parser.ts:207)。 - 动态词:命令替换、进程替换会在主命令之前执行,权限判断不能把它们当普通参数。判定按词的组成部分逐一看:只有字面量、单引号和 ANSI-C 引号(
$'...')算静态,双引号要看里面的内容;命令替换、进程替换、变量展开、算术展开、花括号展开、扩展 glob 与未知类型一律当动态(bash-command-parser.ts:221)。
解析失败、有不支持的语法、有动态词,三项之一成立,命令就不“权限安全”(bash-command-parser.ts:92),此后既不可能判为只读,也不会被拆开去匹配前缀规则。
只读判定
整条命令的判定在 isRuntimeReadOnlyBashCommand(apps/zcode-cli/packages/core/src/tool/handlers/bash-semantics.ts:39):先要求权限安全、至少有一条命令;同一条命令里既有 git 又有 cd、pushd、popd 时直接否决,因为 git 可能在目标目录加载 hooks 与配置,而 cd && grep 这种仍可放行(apps/zcode-cli/packages/core/src/tool/handlers/bash-git-runtime-safety.ts:37);含 git 且当前目录的 git 环境可疑时也否决(见下文“git 护栏”)。然后逐条判定,每一条都必须明确判为只读,有一条未知就整体不是只读(bash-semantics.ts:52)。
单条命令的判定是一串短路(apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-argv.ts:25):
export function evaluateBashReadonlyPolicy(
commandPart: BashCommandInvocation,
): boolean | undefined {
if (!areEnvAssignmentsAllowed(commandPart)) return false;
if (!areRedirectsAllowed(commandPart)) return false;
const argv = stripSafeCommandWrappers(commandPart.argv);
if (argv.length === 0) return false;
if (argv.some(isUnsafeWindowsUncPath)) return false;
if (argv[0] === "git") return isGitReadOnlyCommand(argv);
const directArgvResult = evaluateDirectReadonlyArgv(argv);
if (directArgvResult !== undefined) return directArgvResult;
const prefixPolicyResult = evaluateReadonlyPrefixPolicy(argv, commandPart.commandText);
if (prefixPolicyResult !== undefined) return prefixPolicyResult;
if (READONLY_ALLOW_ANY_ARG_COMMANDS.has(argv[0] ?? "")) return true;
if (process.platform === "win32" && argv[0] === "xargs") return undefined;
const policy = READONLY_COMMAND_POLICIES.get(argv[0] ?? "");
if (!policy) return undefined;
if (argv[0] === "cd" && argv.length > 2) return false;
if (policy.additionalCommandIsDangerousCallback?.(commandPart.commandText, argv.slice(1)))
return false;
if (!isArgvAllowedByPolicy(argv, policy, argv[0] ?? "")) return false;
if (policy.regex && !policy.regex.test(commandPart.commandText)) return false;
return true;
}在它之前还有一道 hasKnownBashWriteOption:sed -i、find 的 -delete/-exec/-fprint 等 10 个写选项、tree -o、git 的危险全局参数,出现即否决(bash-readonly-policy-argv.ts:55)。各层的内容:
| 层 | 规则 | 出处 |
|---|---|---|
| 环境赋值前缀 | 只许 39 个无害变量,如 LANG、TZ、NO_COLOR、CI、GIT_TERMINAL_PROMPT | apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-argv-io.ts:3 |
| 重定向 | 只许输入重定向 <、<<、<&、<<<;输出只许写到 /dev/null 或 >&N;/dev/tcp、/dev/udp 一律拒 | bash-readonly-policy-argv-io.ts:53 |
| 包装词 | 剥掉 command、builtin、noglob 再看真正的命令;Windows UNC 路径拒 | bash-readonly-policy-argv-io.ts:75 |
| 精确 argv | ip addr、node -v、python3 --version 等;docker images/ps 另查危险全局参数;printf、find、history、arch、ifconfig 各有专门检查 | apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-argv-direct.ts:69 |
| 多词前缀 | 24 条:docker inspect、docker logs、gh auth status,以及 21 条 gh 只读命令(gh pr view、gh run list、gh search code 等) | apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-commands.ts:21 |
| 任意参数 | 46 个命令不看参数:cat、head、tail、wc、diff、stat、uname、which、sleep 等 | apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-simple-commands.ts:171 |
| 标志表 | 60 个命令按安全标志表逐个检查参数,部分附加回调 | bash-readonly-policy-simple-commands.ts:59 |
| git | 24 个只读子命令,各有标志表 | bash-readonly-policy-commands.ts:16 |
标志表是这套规则的主体。每个安全标志声明取值类型:none、number、string、optionalString、char,以及只能是字面 {} 或 EOF 的两种(apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-types.ts:1)。检查器支持 --flag=value、-n5 这类粘连写法和 -la 这类短标志簇(簇里每个字母都必须是 none 类型),head/tail 的 -20 特判放行,位置参数不限,遇到 -- 停止检查;表里没有的标志一律拒(apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-argv-flags.ts:18)。标志表管不住的语义交给回调:
sed脚本里出现w写文件命令即否决(apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-callbacks.ts:82);jq禁-f、-L、--rawfile等读外部文件的选项,过滤器里出现$ENV、env、include、import也否决(bash-readonly-policy-callbacks.ts:5);date的位置参数必须以+开头,否则可能是在设置系统时间(bash-readonly-policy-callbacks.ts:86);ps禁含e的 BSD 风格参数,它会显示进程的环境变量(bash-readonly-policy-callbacks.ts:104);xargs只能驱动 8 个命令:echo、printf、wc、grep、egrep、fgrep、head、tail(bash-readonly-policy-callbacks.ts:269),在 Windows 上xargs一律算未知;gh的参数值若含://、@或两个以上/,就可能指向别的主机,否决(bash-readonly-policy-callbacks.ts:298)。
从代码看有一处重叠:cat、head、tail、wc 等既在标志表里,又在“任意参数”集合里。任意参数的检查排在标志表之前(bash-readonly-policy-argv.ts:42),所以这些命令的标志表实际不生效。
git 先规范化全局参数:只允许 --no-pager、--paginate;-c、-C、--git-dir、--work-tree、--exec-path、--config-env、--namespace、--super-prefix、--shallow-file、--attr-source、--bare 这 11 个会改变 git 读哪份配置、在哪个仓库执行的参数,出现即否决(bash-readonly-policy-simple-commands.ts:45)。子命令按最长前缀匹配(apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-argv-git.ts:12),再过回调:git log、git show、git rev-list、git shortlog、git for-each-ref 的格式串里出现 %G 或 %(signature 会触发 GPG 验签,否决(apps/zcode-cli/packages/core/src/tool/handlers/bash-readonly-policy-git-callbacks.ts:15);git branch、git tag 不带 --list/-l 又有位置参数,就是在创建分支或标签,否决(bash-readonly-policy-git-callbacks.ts:92);git reflog 只许 show、list;git ls-remote 带位置参数会联网,否决;git remote show 必须带 -n(bash-readonly-policy-git-callbacks.ts:21、:32、:52)。
判定为只读意味着什么
判定结果通过工具入口的 resolvePermissionCapability 生效(handlers/bash.ts:77):只读命令的能力被覆盖为 readOnly: true、needsApproval: false、riskLevel: "low"、sideEffectScope: "none"。权限服务据此在各模式下分流(apps/zcode-cli/packages/core/src/permission/service.ts:97):
| 模式 | 只读 Bash | 非只读 Bash |
|---|---|---|
build、edit | 直接放行,规则 mode.build.readOnly(service.ts:456) | 询问,规则 mode.build.highRisk(service.ts:474) |
Plan(planEnabled 为真) | 放行,规则 mode.plan.readOnly(service.ts:408) | 拒绝而不是询问,规则 mode.plan.nonReadOnly(service.ts:443) |
yolo 且不在 Plan | 放行(service.ts:136) | 放行 |
Plan 模式的分支排在项目 allow 规则之前(service.ts:176),所以审批时选“始终允许”存下的项目规则(比如 npm test:*)在规划期间也不生效;项目级 deny、ask 规则则更早(service.ts:158)。
还要注意,只读判定只看命令与参数的形态,不看路径:从代码看,cat、head 读工作区之外的绝对路径同样算只读,在 build 模式下也免审批。
同一套判定还用在别处:动态工作流在 ToolCallStarted 事件上读“这一笔是否会改写工作区”(apps/zcode-cli/packages/core/src/tool/executor/permission-capability.ts:28);记忆 Agent 只放行只读 Bash(apps/zcode-cli/packages/core/src/memory/memory-agent-loop.ts:147,见项目记忆)。
规则匹配的主语。resolveBashPermissionRulePolicy(apps/zcode-cli/packages/core/src/tool/handlers/bash-command-permission-policy.ts:85)把命令拆成每条简单命令的“主语”,交给 evaluateBashRules(apps/zcode-cli/packages/core/src/tool/handlers/bash-command-rule-evaluator.ts:13):
- 规则内容与整条命令逐字相等,总是命中;
- 命令不权限安全,或含重定向、非静态的环境赋值,就只认逐字相等(
bash-command-permission-policy.ts:169); allow规则要覆盖每一条非只读的子命令,只读的部分自动豁免,所以cd app && npm test只需要一条npm test:*;deny、ask规则命中任意一条子命令即生效;前缀:*形式按词边界匹配,其余带*的走通配(bash-command-rule-evaluator.ts:40)。
命令注册表:给“始终允许”找稳定前缀
用户在审批时选“始终允许”,ZCode 要提议一条规则。逐字规则太窄,整个 git:* 又太宽,所以需要知道一条命令的哪几个词是“动作”。buildSuggestedUpdates(bash-command-permission-policy.ts:135)为每条非只读子命令算一个稳定前缀,拼成 前缀:*,最多 5 条,超过或算不出就退回逐字规则(bash-command-permission-policy.ts:16)。前缀的算法在 resolveStableCommandPrefix(bash-command-permission-policy.ts:187):剥掉最多两层 env、sudo、nohup、time、command 包装;rm、chmod、dd、mkfs、sh、bash、powershell 等 16 个高风险根命令永远不给前缀(bash-command-permission-policy.ts:19);几类有固定深度:python -m 模块、npm/pnpm/yarn/bun run 脚本、deno task、make/just 目标、aws/az 取两词、gcloud 取三词(bash-command-permission-policy.ts:68);其余查注册表,跳过已知选项(带参数的选项连参数一起跳),沿子命令树往下走。
| 命令 | 建议的规则 |
|---|---|
pnpm --dir app run lint | pnpm run lint:*(--dir 被识别为带参数的全局选项并丢弃) |
git -C sub commit -m "x" | git commit:* |
python3 -m pytest tests | python3 -m pytest:* |
rm -rf dist | 逐字规则 rm -rf dist |
注册表来自 Fig 的命令行补全库。生成脚本 apps/zcode-cli/scripts/generate-bash-command-registry.mjs:15 要求 @withfig/autocomplete 恰好是 2.692.3(版本检查在 generate-bash-command-registry.mjs:52),逐个 import 包里的补全规格,压成四元组 [名字, 选项, 参数标志, 子命令]:选项只记是否带参数;参数标志用位表示是否是命令、模块、可变长、可选、危险、文件、目录(generate-bash-command-registry.mjs:23、:162);函数式的动态子命令与只含 loadSpec 的节点被跳过(generate-bash-command-registry.mjs:141、:149)。输出按名字排序、附上构建文件的 sha256,体积超过 3 MiB 就报错(generate-bash-command-registry.mjs:16、:86)。--check 模式在临时目录重新生成、与仓库里的文件逐字节比较(generate-bash-command-registry.mjs:92),挂在 pnpm check 上(apps/zcode-cli/package.json:10、:23)。仓库里的产物 1906984 字节,含 707 个根命令名(含别名),文件头记录跳过了 522 个 loadSpec 节点(apps/zcode-cli/packages/core/src/tool/handlers/generated/bash-command-registry.ts:4)。
同类思路可以对照 OpenCode 的 Shell 工具:那边用 tree-sitter 解析命令,按命令元数生成“总是允许”规则。
工作目录与 git 护栏
工作目录。Bash 每次都起新 shell,只把最终目录带回来:前台命令在原命令后追加一段,退出码为 0 时把 pwd -P 写进临时文件(apps/zcode-cli/packages/adapters/src/exec/cwd-capture.ts:51),注释明说环境变量、别名、函数都不会保留(cwd-capture.ts:32)。decideBashCwdPolicy(apps/zcode-cli/packages/core/src/tool/handlers/bash-cwd-policy.ts:33)只在命令成功、且是主会话(子 Agent 不算)时生效(bash-cwd-policy.ts:37):新目录在工作区内就保留;离开工作区则重置回工作区根,并在 stderr 末尾追加 Shell cwd was reset to …(bash-cwd-policy.ts:47)。比较时同时看字面路径与真实路径,macOS 的 /private/tmp、/private/var 先归一(bash-cwd-policy.ts:104)。
git 运行时护栏。代码里没有“禁止 git push --force”这类硬拦截:破坏性 git 命令只是判不成只读,按普通高风险命令走审批,yolo 下照样执行。护栏针对的是另一类风险:一个恶意仓库的 .git 配置可以让看似只读的 git status 执行任意程序。isGitRuntimeContextUnsafe(bash-git-runtime-safety.ts:71)从当前目录往上找 .git:
.git是符号链接或gitdir:文件时,目标解析后若落在当前目录之内、或路径里没有.git这一段,判为不安全;gitdir文件超过 32 KiB 或含 NUL 也不安全(bash-git-runtime-safety.ts:98、:124);.git目录要有合法的HEAD、可进入的objects与refs、且没有commondir,才算可信(bash-git-runtime-safety.ts:141);- 在找到可信的
.git之前,路上任何一层出现HEAD文件或objects、refs目录,就当成裸仓库,不安全(bash-git-runtime-safety.ts:172)。
判为不安全后,这条命令里的任何 git 调用都不再是只读,回到审批。
超时与后台
| 数字 | 值 | 出处 |
|---|---|---|
| 默认超时 | 120000 毫秒,环境变量 BASH_DEFAULT_TIMEOUT_MS 可改 | apps/zcode-cli/packages/core/src/tool/bash-timeout-policy.ts:6、:18 |
| 超时上限 | 600000 毫秒,BASH_MAX_TIMEOUT_MS 可改,但不低于默认值 | bash-timeout-policy.ts:7、:23 |
| 本次超时 | timeout 与默认值取其一,再与上限取小;0 视同未给 | bash-timeout-policy.ts:34 |
| 执行器看门狗 | 上面的值再加 6000 毫秒清理宽限 | handlers/bash.ts:497、apps/zcode-cli/packages/core/src/tool/executor/timeout.ts:203 |
| 子 Agent 的后台 Bash | 最长 3600000 毫秒,到点取消 | apps/zcode-cli/packages/core/src/runtime/helpers/runtime-tools.ts:25 |
两个环境变量在组装运行时配置时读取(apps/zcode-cli/packages/bootstrap/src/app/runtime-config.ts:120);代码里没有按命令名调整超时的表。
关键在于“超时”对前台命令意味着什么。runBashWithBackgroundLifecycle(apps/zcode-cli/packages/adapters/src/exec/node-execution-adapter-lifecycle.ts:102)把真正的进程超时设为 0、输出改为始终落盘(node-execution-adapter-lifecycle.ts:123),另挂一个“前台期限”计时器;期限一到,进程并不被杀,而是原地转为后台任务(node-execution-adapter-lifecycle.ts:190):
const commitBackground = () => {
if (state !== "foreground" || controller.signal.aborted) return false;
// 旧 explicit background 复用了通用 start(),foreground timeout 与
// parent turn abort 会继续挂在子进程上。这里先原子提交状态,再同步清理 deadline、
// 脱离 parent abort 并登记 task,避免 abort/completion 在提交缝隙里误杀后台进程。
state = "backgrounded";
bashLifecycle?.onBackgrounded?.();
clearForegroundDeadline();
removeExternalAbort();
this.backgroundTasks.set(taskId, record);
if (persistedLimitReached) {
controller.abort("output_limit");
}
resolveOutcome({
kind: "backgrounded",
task: {
taskId,
status: "running",
startedAt,
pid: record.pid,
...outputPaths,
},
});
return true;
};显式后台的区别只是:进程一启动(收到 started 事件)就立即提交(node-execution-adapter-lifecycle.ts:245)。两种情况下,工具都返回 status: "backgrounded" 与任务 ID,模型看到 Command running in background with ID: …,外加输出文件路径和“用 Read 看中间输出”的提示(apps/zcode-cli/packages/core/src/tool/handlers/bash-model-content.ts:119)。所以在 ZCode 里,只有 sleep 开头的命令和闲时回合里的命令会真正“超时被杀”,普通命令超时的结果是变成后台任务;转入后台后主会话里不再有超时,只剩输出文件 5 GB 的上限(见下一篇)。执行器随后把它登记进运行时任务表,完成时注入 <task-notification> 再起一轮(apps/zcode-cli/packages/core/src/tool/executor/background-tasks.ts:91),细节见后台任务与通知。
同一个格式化函数里还有两段文案:“超过 assistant 模式 15 秒阻塞预算,被移到后台”与“用户手动转后台”(bash-model-content.ts:122、:125)。对应的 assistantAutoBackgrounded、backgroundedByUser 字段只在契约里声明,全仓库没有任何代码给它们赋值,TUI 里也没有手动转后台的按键。从代码看,这两段是没有接线的遗留文案。
读后台输出与停止
TaskOutput 的契约在 apps/zcode-cli/packages/contracts/src/tools/task-output.ts:4,带着 AgentOutputTool、BashOutputTool、AgentOutput、BashOutput 四个别名(contracts/src/tools/task-output.ts:5)。它的描述开头就是 DEPRECATED,建议对 bash 任务直接用 Read 读输出文件(contracts/src/tools/task-output.ts:12)。参数是 task_id、block(默认 true)和 timeout(默认 30000、最大 600000 毫秒,contracts/src/tools/task-output.ts:29)。处理逻辑在 apps/zcode-cli/packages/core/src/tool/handlers/task-output.ts:40:
block=false且任务仍在运行,返回not_ready;block=true时每 100 毫秒轮询一次,到时仍未结束返回timeout(handlers/task-output.ts:34、handlers/task-output.ts:229);- Bash 任务运行中只读输出文件开头 30000 字节,结束后读结尾 8 MiB(
apps/zcode-cli/packages/core/src/tool/handlers/task-output-projection.ts:10、apps/zcode-cli/packages/core/src/tool/handlers/task-output-bash.ts:120); - 给模型的文本最多 32000 字符,保留尾部并在前面注明完整文件路径;
TASK_MAX_OUTPUT_LENGTH可调,上限 160000(handlers/task-output.ts:199); - 读到终态才把任务标记为
notified,而且先投影、再检查中止信号、最后写标记,避免吞掉完成通知(handlers/task-output.ts:60)。
描述里还说任务 ID 可以用 /tasks 命令查看(contracts/src/tools/task-output.ts:22),但 ZCode 的内置斜杠命令表里没有 /tasks(packages/shared/src/zcode-slash-command-help.ts:9)。
TaskStop(apps/zcode-cli/packages/core/src/tool/handlers/task-stop.ts:96)别名 KillShell、KillBash,参数 task_id,旧名 shell_id 仍兼容(task-stop.ts:29)。它调用后台任务控制端口,发起方标为 model,这样终态通知会写“被你停止”而不是“被用户停止”(task-stop.ts:50);自身超时 10000 毫秒,不可覆盖(task-stop.ts:136)。
界面上的后台任务详情走另一条路:运行时方法 readBackgroundBashOutput(apps/zcode-cli/packages/core/src/runtime/methods/background-bash-output.ts:5)由 ZCode Protocol 的 backgroundBashOutput 查询调用(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/v4-gateway.ts:1906),适配器按启动时的会话 ID 校验归属,只返回输出文件结尾 8192 字节(node-execution-adapter-lifecycle.ts:300、packages/shared/src/background-bash-output.ts:3)。
输出:截断、退出码与图片
Bash 的 stdout 与 stderr 由子进程直接写进同一个文件(下一篇细说),所以结果里 stderr 通常为空,stdout 是合并后的输出。给模型的内容由 formatBashModelContent(bash-model-content.ts:14)生成:
- 截断:内联部分取输出开头 30000 字节(
apps/zcode-cli/packages/adapters/src/exec/bash-file-output.ts:132),适配器侧可用BASH_MAX_OUTPUT_LENGTH调到最多 150000(apps/zcode-cli/packages/adapters/src/exec/bash-output-policy.ts:1)。超出时完整文件保留,模型看到一个<persisted-output>信封:总大小、完整路径、开头 2000 字符预览(bash-model-content.ts:10、apps/zcode-cli/packages/core/src/tool/result-persistence-format.ts:29)。没超出的前台输出文件在结算时删除(apps/zcode-cli/packages/adapters/src/exec/node-execution-adapter-results.ts:94)。 - 退出码:非零退出码不抛异常,作为结果字段返回(
contracts/src/tools/bash.ts:123)。interpretBashReturnCode按链上最后一条命令给出语义:grep、rg退出 1 是 “No matches found”,diff是 “Files differ”,find是 “Some directories were inaccessible”,test/[是 “Condition is false”,git grep、git diff同样识别(bash-semantics.ts:134)。这四种不算错误;其余非零退出码在模型内容开头写Exit code N,并把工具结果标为错误(bash-model-content.ts:50、apps/zcode-cli/packages/core/src/runtime/helpers/tool-result.ts:74)。被中断的命令追加<error>Command was aborted before completion</error>(bash-model-content.ts:105)。 - 图片:如果整个 stdout 恰好是一个
data:image/…;base64,…形式的 URL,就作为图片块交给模型(apps/zcode-cli/packages/core/src/tool/handlers/bash-image-output.ts:20)。落盘的输出最多读 20 MiB;有图像处理端口时缩放到长宽都不超过 2000 像素(bash-image-output.ts:12、apps/zcode-cli/packages/contracts/src/tools/read.ts:20);缩放失败但图片头校验通过时用原图,解码失败则退回文本。 - gh 限流提示:命令里调用了
gh(auth、help、version、alias、completion、config除外),输出又匹配 “API rate limit exceeded” 等字样时,追加一条system-reminder,说明 5000 次每小时的配额由所有工具与 Agent 共享,要求先查gh api rate_limit再睡到重置;同一进程 60 秒内只提示一次(apps/zcode-cli/packages/core/src/tool/handlers/bash-gh-rate-limit.ts:1)。提示还让模型轮询时改用ScheduleWakeup,但 ZCode 并没有注册这个工具,它只出现在一张工具排序名单里(apps/zcode-cli/packages/core/src/tool/provider-visible-order.ts:19)。
Bash 读文件与读取状态
Edit、Write 要求“先读后写”(见读、写、改、搜)。模型常用 Bash 看文件,所以 Bash 结束后 applyBashReadFileStateEffects 做两件事(apps/zcode-cli/packages/core/src/tool/handlers/bash-read-file-state.ts:51)。
回填。collectBashReadFileSources(apps/zcode-cli/packages/core/src/tool/handlers/bash-read-file-sources.ts:44)只认几种一眼能看懂的形态,且整条命令不能含 |、<、>:cat 文件(可带 -n)、head/tail -n N 文件(默认 10 行)、sed -n 加 N,Mp 或 Np 脚本,以及单独一条的 grep 模式 文件(必须退出码为 0);多条命令时夹在中间的 echo、printf、true、: 可以忽略。满足条件、stdout 没被截断、文件不超过 10 MiB、之前没有记录,就按命令实际展示的内容写一条读取记录(bash-read-file-state.ts:95):head、tail、sed -n 记下行范围,cat、grep 不带范围,等同整文件读取。这些记录一律不标记为部分视图(bash-read-file-state.ts:149),而 Edit 判断“没读过”只看有没有记录、是不是部分视图(apps/zcode-cli/packages/core/src/tool/handlers/edit.ts:429),所以一次 head、甚至一次命中的 grep 之后,模型就可以直接 Edit 这个文件。
过期提示。命令匹配格式化类标记(--write、--fix、--in-place、black、cargo fmt、go fmt、terraform fmt 等 19 种,bash-read-file-state.ts:20)时,逐个 stat 已读过的文件,修改时间晚于命令开始、且晚于记录的,汇总成一句 [This command modified N files you've previously read: … Call Read before editing.],最多列 5 个路径(bash-read-file-state.ts:182)。后台命令、图片输出、被中断或出错的结果,这两件事都不做(bash-read-file-state.ts:168)。
Shell 选择随会话固定
用哪个 shell 在会话创建时决定,之后不再变。persistBashShellSelectionSnapshot 把选择写成一条会话条目,ID 是会话 ID 加 :runtime:bash_shell_selection(apps/zcode-cli/packages/core/src/runtime/methods/bash-shell-snapshot.ts:27),注释说明理由:shell 设置变更只影响新会话,冷恢复必须继续用同一个 shell(bash-shell-snapshot.ts:48)。恢复时读最新一条:快照里的路径仍可执行就是 restored;不可用了且当前有候选就 fallback 到当前候选,否则 stale(bash-shell-snapshot.ts:104)。Windows 上字面的 cmd.exe 不做可执行检查(bash-shell-snapshot.ts:196)。shell 候选怎样挑出来、恢复后怎样提醒模型 shell 变了,见下一篇。
下一篇:执行边界:子进程、环境与网络——命令最终怎样 spawn、登录 Shell 快照怎么取、代理与证书变量怎样封存再还给子进程,以及为什么没有操作系统沙箱。