# Headless runs and permissions

`gcode -p 'Run the tests'` runs one request and writes NDJSON to stdout. Diagnostics and permission summaries go to stderr.

| Exit | Meaning |
| --- | --- |
| 0 | Finished, including refusals by default. |
| 1 | Error: provider/API failure, crash, bad arguments or cleanup failure. |
| 3 | Finished with one or more refusals, only with `--fail-on-denial`. |
| 4 | Stopped at the session budget; a handoff was written. Its path is printed to stderr. |
| 128 + N | Stopped by signal N. SIGINT is 130; SIGTERM is 143. |

A refusal is not an executed failure. Each `permission_denials` entry in the result contains `tool_name`, `tool_use_id`, `tool_input`, and a user-facing `reason`. `suggested_rule` appears when a narrow permission rule can help. The stderr summary counts each tool and names only permission modes that would permit those calls.

```sh
gcode -p 'Run npm test' --allow 'Bash(npm test)' --fail-on-denial
gcode -p 'Update the report' --allow 'Edit(/absolute/project/report.md)' --deny 'WebSearch'
```

`--allow` and `--deny` are repeatable and apply to this CLI invocation. Personal rules live only in `~/.gcode/permissions.json`, never in a cloned project's files. The CLI loads them at startup and checks the file's mtime before permission checks, so desktop edits apply without a restart.

## Session budget

Budgets are off by default. `--budget-usd <n>` enables a session limit; `--budget-reserve-usd <n>` keeps part of it for a handoff (default $0.50, clamped to half a small limit; an explicit reserve above half the limit is rejected). Settings → Session budget supplies defaults for new sessions. A resumed session keeps its saved limit, stages, spending and outstanding reservations even if the defaults change.

```sh
gcode --budget-usd 5 --budget-reserve-usd .5 -p 'Complete the requested change'
gcode --budget-usd 5 --budget-reserve-usd .5 --handoff-model ollama:my-installed-model -p 'Complete the requested change'
gcode --resume SESSION_ID --budget-usd 10 -p 'Continue from the saved progress'
```

Every main, child, workflow, compaction and writer request must fit before transport starts. The admission check is `spending + estimated input <= limit − reserve`; equality fits. One reply may use part of the reserve. Input uses the engine's calibrated text/image estimate (or provider count), with a conservative margin. The output token cap and reservations protect the estimated shared balance while requests run concurrently. Provider token counts can differ from estimates, so this is not an absolute invoice cap. Prices are offline estimates. Unknown model prices and remote Ollama endpoints are refused under a budget; loopback Ollama and Local GGUF are $0.

The entire handoff writer sequence, including validation retries, chunks and merges, shares the remaining handoff allowance. If it cannot fit or produce a valid handoff, G-Code writes a local document from the recorded goal, requests, file changes and tool log. `budget-status` and `budget-reached` NDJSON frames include the durable ledger. Complete usage settles at the known model/cache rates; missing or incomplete usage retains its reservation across restart. Context and the budget dialog identify those unconfirmed amounts. A prior session without complete priced history cannot silently start accounting at zero.

Without the explicit `--handoff-model provider:model` option, headless stops with exit 4 and prints the saved handoff path. The option authorizes one automatic takeover: G-Code stops the old session's background work, creates a new session with the original stage limit, reads the handoff, verifies it against the files, follows Next steps and keeps the handoff updated. The target uses its own provider configuration. Loopback Ollama uses its configured host or `OLLAMA_HOST`. A headless Local GGUF takeover needs an already running local server at `GCODE_LOCAL_BASE_URL`; desktop starts its configured runtime through its normal path. An unavailable target or a failed handoff save exits 1, preserving any handoff that was saved. Exit 4 is never used to claim a failed save succeeded.

To add budget to a held session, resume with a higher **total** `--budget-usd` (for example 10 after 5). Its spend and unresolved holds remain, and a new stage receives a fresh reserve. The desktop offers Add $5 / $10 / $20 / Other on the same session; Not now keeps it paused and the composer reopens the dialog. Choosing Continue with explicitly starts a new session with the configured priced or local model. Editing defaults alone never unpauses a held session.

```json
{
  "version": 1,
  "allow": ["Bash(npm test)"],
  "deny": [],
  "projects": {
    "/real/absolute/project": { "allow": ["Edit(src/**)"], "deny": [] }
  }
}
```

| Rule | Scope |
| --- | --- |
| `ToolName` | Every call of that tool. `mcp__github` includes that server's tools; `mcp__github__create_issue` names one tool. |
| `Bash(npm test)` | That exact simple command. |
| `Bash(npm run test:*)` | The command and more arguments at a word boundary; does not match `npm run testx`. |
| `Read(~/docs/**)` | FileRead, Grep, Glob, ListDir and LSP targets inside the glob. |
| `Edit(/absolute/project/**)` | FileWrite, FileEdit and NotebookEdit targets inside the glob. |
| `WebFetch(domain:example.com)` | That host and redirects within the same host. `*.example.com` covers subdomains, not the bare domain. |

Paths start with `~/` or `/`. A project rule may use a relative path. `*` stays inside one path segment; `**` crosses segments. Rules match resolved real targets, including dangling symlinks. A recursive folder search needs a rule covering its whole subtree; a deny rule that reaches a possible descendant blocks that search. Use the canonical absolute path for a global rule whose path passes through a system alias.

Hard safety refusals, including G-Code's own key and permission files, catastrophic shell commands, non-web URLs and Plan's read-only boundary, come first. Deny rules apply in every mode, including Bypass. Allow rules can cover Manual, Accept edits, Auto and headless calls; they cannot make Plan write. A shell allow must cover every simple command in a compound. Substitution/variable-expansion commands never gain an automatic Bash rule grant. Destructive commands still need approval unless the user selected Bypass; catastrophic commands remain refused.

Malformed individual rules are ignored with one warning naming the rule. Linked, oversized or structurally invalid files are refused; a running CLI retains its last valid rules so a broken update cannot erase loaded denies. Desktop writes are atomic with mode 0600 and validate the rules in Rust as well as the UI. Settings → Permissions has All projects and This project scopes. Add and Remove save immediately. An approval's Always allow action shows an editable rule, defaults to This project, and approves the pending call only after the rule is saved.
# CLI Workflow 使用说明

Workflow 在个人设置 `workflows.enabled: true` 时注册。调用 `{guide: true}` 获取脚本 API 与写作指南。桌面端当前使用 CLI 的前台兼容模式。

## 项目 hooks 的生效时点

主对话每轮开始固定项目 `.gcode/hooks/` 中当时启用的定义与文件字节。该轮启动的 Agent 和 Workflow 继承父快照及 hook 权限策略；只有没有父快照的独立 run 才从磁盘读取。全部子代理、兄弟代理和 schema 纠错重问共用快照，包括空快照；轮中新增定义不会载入。快照不冻结 hook 命令可能读取的其他文件。

每次执行前（包括审批通过后、启动进程前）核对文件字节。**preTool 守卫**被删除、禁用或修改时失败关闭，阻止它所守卫的工具调用；旧命令和新命令都不执行。**postTool / postQuery** 文件发生相同变动时，跳过该 hook，通过引擎 `system` 事件发出 `[hook warning]`，桌面会话可见；终端和 headless 同时写入 stderr，继续完成本轮。警告按 (hook, reloadScope) 去重：父轮次及其前台子任务共享一次提示；后台 run 使用包含 runId 的独立作用域，同一 run 重复跳过只提示一次，父轮次或其他 run 的提示不会将它吞掉。**preQuery** 在每次请求模型之前执行，包括同一轮工具循环的后续请求；若文件在轮中或审批等待期间变动，同样跳过并警告，不加载新定义。未变动 hook 的审批拒绝或命令执行失败仍按既有错误规则处理。

删除、禁用或修改 preTool 定义不会放行正在等待守卫的工具。主对话及继承父快照的 Agent / 前台 Workflow 都需要**发送新消息**开始新轮次才能重新加载：`Send a new message to reload hooks. Rerunning within this turn keeps the same definitions.`（发送新消息即可重新加载 hooks；本轮内重跑沿用相同定义）。headless `-p` / `--print` 需启动新调用，提示为 `Start a new -p/--print invocation to reload hooks. Rerunning within this invocation keeps the same definitions.`。没有父快照的独立 run 和后台 Workflow 均提示 `Restart this run to reload hooks.`，并附带该 run 的 `/workflows stop <runId>` 命令。错误和跳过警告都采用对应场景的提示。已经启动的 hook 进程需通过 `/workflows stop <runId>` 中断；桌面端点击 **Stop** 中断当前轮次。

**后台 run 的快照不会因新消息而更新**。需要更换定义时，用 `/workflows stop <runId>` 停止旧 run，再在新轮次启动新 run；本轮内重新调用 Workflow 仍继承本轮快照。降级为前台的桌面 / headless 请求继续使用其轮次 / 调用级提示。

桌面端的 workflow 仍在前台运行，运行时输入框锁定，界面上看不到 runId；需要中断已执行的 hook 时，点击 **Stop** 中断当前这一轮。需要加载新命令时，修改 hook 后发送新消息；本轮中让模型再次调用 Agent / Workflow 不会重新读取定义。

执行前重读仅用于核对原始字节，不解析或执行新的定义，防止子代理写入的命令进入后续执行。加载与核对都以非阻塞方式打开文件，检查打开的描述符必须为普通文件，大小不得超过 1 MiB；读取期间长度改变也拒绝。FIFO、指向 FIFO 的软链接、其他非普通文件或超限文件在轮中视为已变动，按上述守卫阻断 / 非守卫警告规则处理；首次加载时则报无效定义。hook 上下文通过子进程环境变量传递，文件内容不会成为 Bash 命令文本的一部分。

hook 的 Bash 仍遵循父对话的实际权限模式：需要审批的执行仍须审批，`bypass` 模式不再询问。只读子代理的工具权限与 hook 的父级权限分别检查。

上下文通过以下六个环境变量传给 hook 子进程；正文仅作为数据，不拼入 `hook.command`：

| 变量 | 设置时点 | 内容与截断上限 |
| --- | --- | --- |
| `GCODE_HOOK_TYPE` | preTool / postTool / preQuery / postQuery | 当前 hook 类型，不截断 |
| `GCODE_TOOL_NAME` | preTool / postTool | 工具名，不截断 |
| `GCODE_TOOL_INPUT` | preTool | 解析后的工具输入 JSON；QueryEngine 先截至 5000，通用 runHooks 再设 10000 上限 |
| `GCODE_TOOL_OUTPUT` | postTool | 工具结果文本；QueryEngine 先截至 5000，通用 runHooks 再设 10000 上限 |
| `GCODE_QUERY` | preQuery | 本轮用户输入，runHooks 截至 10000 |
| `GCODE_RESPONSE` | postQuery | 本轮累计响应；QueryEngine 先截至 5000，通用 runHooks 再设 10000 上限 |

上述“字符”上限按 JavaScript `slice` 的 UTF-16 码元计算，超出部分直接截去，不追加省略号。可选上下文为空时不设置对应值；workflow 子代理不重复执行 preQuery / postQuery，但其 preTool / postTool 使用相同规则。环境变量值中的 U+0000 替换为 U+FFFD；`hook.command` 本身含 NUL 仍属于无效定义。

## 审批与进度的文字显示

Workflow 来源路径在主机生成来源字段时采用下文的严格显示规则，覆盖 Cc、Cf、Bidi_Control、Default_Ignorable_Code_Point 和 U+2028/U+2029；终端来源行直接使用该字段，不再次 JSON 编码。正常括号、引号、中文保留。不可见字符的例外仅为 ZWJ（U+200D）、ZWNJ（U+200C）、紧随合法 emoji 基字符的单个 VS16，以及三种完整 RGI 地区旗帜（gbeng / gbsct / gbwls）。

主对话审批的 filePath、终端 Input、桌面输入正文及对象键也使用相同显示转义；workflow label / phase / log 在显示边界处理。JSON 帧的线上编码额外转义控制字符，解析后仍得到原始字段；工具实际参数、文件路径和写入内容不会被显示层改写。桌面审批继续使用独立的 workflowSource 字段，布局与样式不变。

## 进度与递归

log / phase 优先派发，并保留一个派发位置，全部调用仍受 16384 条待处理硬上限约束。脚本 return 时先固定此前已入队的 log / phase，收尾会排空这些进度；尚未派发的 agent 取消并返回 null，已经派发的代理由 run 收尾取消。返回值序列化完成后，无论有没有待取消的 agent，都打开同一个一批微任务窗口：在关闭检查点入队前已排队的微任务，以及取消 agent 时已注册的 Promise 回调，其同步发出的 log / phase 纳入最终边界。它们再次经 await / then 排到关闭检查点之后的续体，以及其他窗口关闭后的 log / phase，丢弃且返回永不 settle 的 Promise，不序列化或派发。return 后新发起的 agent 调用同样永不 settle；这与 return 前已排队、在收尾取消为 null 的 agent 不同。这样后台 `for(;;) await log(...)` 不会因立即完成的空调用而微任务空转，return 前进度与返回值仍完整送达。这一边界与当时有没有其他在途日志无关，不会因持续追加进度而延长 run。默认 phase 在 agent() 调用时确定，避免进度插队改变阶段归属。

返回后的普通宿主调用回复仅用于确认冻结进度已送达，不再恢复原脚本的 await / then 续体；统一批次窗口以外的后续工作不能延长收尾。

Bun 的严格模式会优化尾递归，例如 `return recurse()`，因此它可能一直执行而不抛出可捕获的栈溢出；Node 的行为不同。真正的非尾递归栈溢出可正常捕获。非让出执行权的脚本由同步超时（默认 1000ms）或心跳超时（默认 2000ms、每 50ms 检查）终止 worker，错误提示需给递归或循环添加有限边界。宿主保持响应；这些时间不包含操作系统调度延迟。

## 审批回复

非桌面模式（TTY 或管道）的主对话审批收到文本回复即处理：开头匹配 `^(y|yes)\b`（忽略大小写）时允许，其余拒绝。因此 `yes please` 和 `yes.` 允许，`nope`、`ok`、`yeah`、`no thanks` 和直接回车拒绝。这些回复不会转发给模型。带编号的 `y <requestId>` / `n <requestId>` 可回答指定审批，编号后有附加文字（例如 `y <requestId> please`）也按该编号处理。

非桌面模式下，只有确实有待处理审批、且第一个词为 `y/yes/n/no`（忽略大小写）时，才检查第二个词是否为失效 UUID，并提示“该审批已不存在”。没有待处理审批时，第二个词是 UUID 的消息仍正常交给模型。

终端中的 workflow 子代理审批需要带 requestId。单独的 `y`、`yes`、`n`、`no` 只提示补全编号，不批准、不排队。其他文本保留为用户消息。

桌面托盘使用带 requestId 的 `{type:'approval',requestId,allowed}` 帧回答指定审批。取消或 run 结束时，CLI 发出 `{type:'approval-withdrawn',requestId}`；桌面只关闭相同编号的托盘，随后展示 CLI 发来的下一项。过期编号不会回答新的审批。桌面模式的主对话和 workflow 审批都只接受带 requestId 的 approval 帧；所有普通文本（包括 y、yes please、n 和 y <requestId>）一律进入对话队列，不批准也不拒绝审批。没有撤回后吞掉一次输入的标记。无审批时，y/n 始终是普通用户消息。

桌面描述正文不追加文本形式的权威来源。workflowSource 作为独立字段经 Rust 传至托盘，以现有 gc-approval-path 样式显示 Source: kind · path；正文使用 pre-line 保留换行。来源路径由宿主在构造 workflowSource 时采用上述严格显示规则及其有限例外，终端直接显示此字段，不再经 JSON.stringify 二次转义；带编号文本语法保持不变。非桌面管道主对话仍支持无编号的文本或 approval 帧；workflow 审批必须指明编号。

桌面 IPC 复用现有 send_message 通道承载显式 JSON 帧：sendMessage 包装 message，respondToolApproval 包装 approval，cli::send 按帧类型路由。粘贴进正文的帧被嵌套为文字，不会被解释成审批。旧 bool-only Tauri 审批入口明确拒绝缺少 requestId 的调用；桌面与 CLI 必须一起发布。

审批帧的 filePath 为显示字段，构造时按上述严格显示规则转为可见的 `\u` 转义；终端 Input 行和桌面输入预览的 JSON 文本也采用同一规则，包括对象键。实际工具输入及获批后写入的路径、内容保留原值，转义仅用于审批显示。

## 显示与原始数据

工具输入、hook 的 Error 消息、tool_result、会话记录保留原文。转义仅用于显示出口；`frameJSON` 的线上转义经 JSON 解码后仍还原原文。Agent / Workflow 的常驻工具说明介绍各自用途，hook 的重载边界由错误、警告和 Workflow 指南解释。

审批、路径与命令列表采用严格规则：转义控制字符、方向控制字符、Default_Ignorable_Code_Point 和 U+2028/U+2029。仅保留 ZWJ/ZWNJ、合法 emoji 基字符后面的单个 VS16（U+FE0F），以及 U+1F3F4 黑旗 + gbeng / gbsct / gbwls 的 tag 编码 + U+E007F 这三种完整 RGI 地区旗帜。VS16 不能直接附在肤色修饰符或区域指示符上；多余、孤立或非 emoji 后面的 VS16，以及其余 U+FE00–FE0F、U+E0100–E01EF 变体选择符均显示为 Unicode 转义。其他 tag 字符一律逐个转义，即使前面是 ✅、©、™、👋🏻、区域指示符或黑旗，且带有终止符，也不放行。`/todos`、`/tools` 和 Fatal 错误行在写入终端时转义，不改动待办文件、工具定义或错误数据。

Workflow metadata 还保留原有 Cc 空格化、其他 Cf 过滤及来源伪装检测；tag、变体选择符及新覆盖的非 Cf 默认不可见字符显示为转义，不能作为不可见载荷留在名称或描述中。正常连接文字、合法 VS16 与三种 RGI 地区旗帜使用相同例外。

TTY 模型回复正文另用宽松规则：先将 CRLF（包括跨流式 chunk 的 CRLF）规范化为 LF，再转义 C0（LF/TAB 除外）、DEL、C1、U+202A–202E、U+2066–2069。LRM/RLM/ALM、软连字符、BOM、WJ 保留；孤立 CR 显式转义。桌面协议、headless 输出与持久化正文保留原始 CRLF 和其他字符。
