`
+works until the condition is met and `/goal clear` cancels it. `/resume` restores a saved
+conversation; `/tasks` lists background shell jobs and sub-agents. Esc interrupts the current
+reply; a message typed during a run steers it, and queues above the composer when steering is
+refused (Up edits that queue). `/mode` `/steer` `/queue` `/loop` stay as hidden aliases for one
+version (`/loop` is now `/goal`). Without `--accept`, `/goal` proposes acceptance commands from
+test entry points that exist in the workspace (`package.json` test, Makefile, pytest, `go.mod`)
+and says so when it finds none. `/stop` (alias `/abort`) stops only background processes this
+session started. The hidden `/task` is the Task OS entry (status / timeline / resume / view /
+verify); `/task verify` takes one verdict without a model turn. A PASS still comes only from the
+verdict provider.
+
+Key flags: `-m/--model`, `--provider`, `--base-url`, `-C/--cd`, `-c/--config k=v`,
+`--read-only` · `--workspace-write` · `--full-access` (workspace-write confines Moss's own file tools; shell commands run normally without an OS sandbox), `--trust-device`, `--accept-edits`,
+`--ask-for-approval `, `-p/--print`, `--json`, `--output-format `.
+
+Key env vars (full list: `moss config env`): `MOSS_PROFILE` · `MOSS_WORKSPACE` ·
+`MOSS_SAFETY_MODE` · `MOSS_APPROVAL_POLICY` · `MOSS_MAX_AGENT_TURNS` · `MOSS_CONTEXT_TOKENS` ·
+`MOSS_BUDGET_MAX_*` · `MOSS_DEVICE_*` · `MOSS_NO_RDK_DOCS`.
+
+> **Gotcha:** model settings are config-only. `MOSS_MODEL` / `MOSS_PROVIDER` / `MOSS_BASE_URL` /
+> `MOSS_API_KEY` are read but ignored — use `moss setup` or `moss config set`.
+
+Without an explicit file, Moss reads the user config and merges `.moss/config.json` from the
+workspace as project defaults (the user file wins). `--config-file` or `MOSS_CONFIG_FILE` loads
+only that file, so the project `.moss/config.json` layer is not part of the run.
+
+## UI language
+
+**English is the default UI language.** Chinese is a complete, optional UI language. It covers
+chrome, help, errors, and setup text. Assistant replies still follow the language of the user's
+message.
+
+```bash
+moss --lang zh # this run only
+MOSS_LANG=zh moss # process env (a project .env cannot set this)
+moss config set language zh # user config ~/.config/moss/config.json
+moss config set language auto # default: Chinese only when the locale starts with zh
+```
+
+Precedence: `--lang` > `MOSS_LANG` > user config `language` > system locale. Neutral tags
+(`C`, `POSIX`, `C.UTF-8`) are not a language and fall through to the next of `LC_ALL`,
+`LC_MESSAGES`, and `LANG`. If none names a language, the UI stays English. A project
+`.moss/config.json` and a project `.env` cannot set the UI language.
+
+In the shell, `/language` (alias `/lang`) switches the session. `/language zh save` writes the
+user config. On a Chinese system locale that has not chosen yet, the first `moss setup` offers
+one line: press `e` to switch to English.
+
+## Safety and privacy
+
+- **Full by default since v0.26**: local writes and **reversible** device changes skip the
+ prompt. Destructive device operations (reboot, flashing, writes to `/boot` or `/etc`, network
+ changes, removing system packages, stopping ssh) still confirm. Full matches Claude Code's
+ "ask less by default", not `--dangerously-skip-permissions` against a robot board.
+- **Four interaction modes** (Shift+Tab cycles, or `/plan` to enter plan mode; `/mode` remains for one version):
+
+ | Mode | Behavior |
+ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `manual` | mutations and device changes ask one by one |
+ | `acceptEdits` | workspace file-tool edits auto-approve; shell and device changes still ask |
+ | `plan` | read-only planning; mutations and device changes blocked |
+ | `full` (default) | local writes and reversible device work skip the prompt; destructive device work confirms on a TTY and is refused headless. Deny rules and host hard blocks still apply |
+
+- **`workspace-write` is not an OS sandbox.** workspace-write confines Moss's own file tools. Shell commands run normally without an OS sandbox. `write_file`, `edit_file`, `multi_edit`, `move_file`, and `apply_patch` stay inside the workspace. `exec` is not wrapped in Landlock, bubblewrap, or seatbelt. A static scan rejects some out-of-workspace shell writes it can see (redirections, `cp`, `mv`); a child `node` or `python` process can still write outside, for example under `/tmp`. Shell safety is output redaction and write-back guards. An opt-in OS sandbox is specified in [`docs/design/os-sandbox.md`](docs/design/os-sandbox.md) and stays off.
+- **Permission rules** (`/permissions`, effective in any mode, deny beats everything incl. full):
+ - three levels `allow` / `ask` / `deny`, priority deny > ask > allow;
+ - syntax `ToolName(pattern)` with moss-native tool names:
+ `/permissions add deny "read_file(./.env)"`, `/permissions add allow "exec(npm run *)"`;
+ - session rules take effect on the next tool call; `/permissions persist` writes the user config
+ and survives restarts;
+ - `--read-only` / `MOSS_SAFETY_MODE=read-only` is a read-only ceiling that compresses any mode
+ including full.
+- **Host hard blocks never lift**: destructive host `exec` commands (`rm -rf /` …) and path
+ escapes stay blocked in full mode — full skips the asking, not the checking. The same shapes on
+ the device are not a permanent hard block: a TTY confirmation, an allow rule, or explicit trust
+ runs them for real.
+- **Protected paths (O94 + O101) are best-effort interception, not a sandbox**: direct writes, deletes or
+ moves of a protected path (e.g. `~/.ssh`, files inside `.git/`) by file tools or shell commands
+ need one confirmation even in full mode; `git`'s own writes to `.git/` do not. Shell detection
+ parses the command line (O97/O94) and, with O101, scans inline `-c`/`-e` code and workspace
+ scripts before run. Anything that still slips through is caught after the command by a metadata
+ snapshot (mtime/size/hash only — never file contents): the run is marked incomplete ("modified",
+ not "intercepted"), exit non-zero, no auto-restore. `exec_background` runs the same layer-3
+ check when the background process fully exits. Still not an OS sandbox; `&` / `nohup` delayed
+ writes inside a foreground command, and writes after the Moss process itself exits, remain known
+ limits.
+- **Trust a device** (any one; deny rules still win): `--trust-device` (this process only),
+ `MOSS_DEVICE_TRUST=full`, `permissions.deviceTrust=full`, or `permissions.trustedDevices` /
+ `MOSS_DEVICE_TRUST_DEVICES` (comma-separated host or device id). Answering `a` trusts only the
+ scope named in the prompt (for example `systemctl restart` or `stop` of that unit, or the same
+ command prefix) until the session ends. Reading `/etc/shadow`, private keys, `sshd_config`, or
+ `authorized_keys` is a `sensitive` tier: it still confirms, and the copy does not call it
+ destructive. Prompts and refusals follow the UI language (`--lang`, `MOSS_LANG`, or the user
+ config `language`; otherwise Simplified Chinese when the system locale starts with `zh`).
+ Every decision is appended to `.moss/evidence.jsonl`
+ (`metric: device_policy`) and, when a task is in progress, to its timeline as a `note`.
+ Policy: [`docs/superpowers/plans/2026-10-09-device-safety-policy.md`](docs/superpowers/plans/2026-10-09-device-safety-policy.md).
+- **Legacy keys** (one release of grace): `profile` / `trustedTools` / `deniedTools` /
+ `safetyMode` / `approvalPolicy` are translated on read (cautious→manual+read-only ceiling,
+ balanced→manual, autonomous→full, trustedTools→allow rules, deniedTools→deny rules); writing
+ them prints a deprecation notice — use the `permissions.*` block for new config.
+- Credentials come only from `.env` or the environment: never hardcoded, never logged, never
+ passed to child processes, never written to the device registry. No account, no cloud, no
+ telemetry.
+
+## Quality gates and benchmarks
+
+```bash
+npm run check # prettier + eslint (0 warning) + typecheck
+npm run test # build + every test/*.spec.mjs (235 specs, run against dist/)
+npm run smoke # CLI smoke: --version / --help / PTY startup
+npm run verify # check + test + smoke — required before any release
+
+# Real terminals (tmux / GNU screen / Terminal.app); off by default, missing terminals are skipped
+npm run build && MOSS_REAL_TERMINALS=1 npm run test:filter -- --filter tui-real-terminals
+```
+
+Benchmarks stay out of git in `bench/results/`:
+
+- `npm run bench` · `npm run bench:ab -- reasoning-high` · `npm run bench:noise`: agent capability, A/B, noise band
+- `npm run bench:swe` · `npm run bench:tb`: locked SWE-bench Verified subset · Terminal-Bench
+- `npm run bench:deepswe`: DeepSWE v1.1 with the model held fixed against other harnesses (runs
+ through Pier; published scores in `bench/boards/deepswe-v1.1-harness.json`)
+- `npm run bench:device -- --dry` (or `--target sim` / `--target real`): RDK board-task success
+ rate. A row passes only when Moss's verdict passes and an independent probe matches the
+ evidence — see [`docs/bench/device-bench.md`](docs/bench/device-bench.md)
+- `npm run bench:tui-feel`: TUI feel · `node scripts/task-os-metrics.mjs`: Task OS metrics
+ (turns / tools / success rate)
+
+A version number states the **current capability level**: `main` is the rolling line, tags are cut
+only after `verify` is green and `examples/` pass for real — see
+[`docs/release-policy.md`](docs/release-policy.md).
+
+## Support matrix
+
+| Dimension | Supported | Verified by |
+| --------- | ------------------------------------------------------------------------ | ----------------------------------- |
+| Node | ≥ 22.16.0 | CI matrix (22.16 / 24) |
+| Platform | Linux / macOS / Windows | CI matrix (no PTY smoke on Windows) |
+| Providers | deepseek / qwen / openai / anthropic / openai-compatible | unit tests + smoke |
+| Surfaces | TTY: full-screen TUI; non-TTY / `MOSS_NO_TUI=1` / Windows: readline REPL | TUI specs + PTY smoke |
+
+## Documentation
+
+[`AGENTS.md`](AGENTS.md) (architecture and conventions) ·
+[`docs/release-policy.md`](docs/release-policy.md) ·
+[`docs/capability-layer.md`](docs/capability-layer.md) ·
+[`docs/design/os-sandbox.md`](docs/design/os-sandbox.md) (opt-in OS sandbox for `exec`, default off) ·
+[`docs/cli-parity/`](docs/cli-parity/) (real-terminal checklist:
+[`tui-real-terminals.md`](docs/cli-parity/tui-real-terminals.md)) ·
+[`docs/bench/device-bench.md`](docs/bench/device-bench.md) ·
+[`CHANGELOG.md`](CHANGELOG.md) ·
+[`docs/superpowers/plans/`](docs/superpowers/plans/).
+
+## License
+
+MIT — see [`LICENSE`](LICENSE).
diff --git a/README.md b/README.md
index 2c1eb0c3..90518e7c 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
[](https://nodejs.org)
[](#支持矩阵)
-**中文** · [English](#moss--english)
+**中文** · [English](https://github.com/D-Robotics/moss/blob/main/README.en.md)
@@ -244,7 +244,7 @@ moss --print "定义任务:相机管线保持 30 FPS 持续 60 秒;部署、
**扩展。** MCP 客户端(stdio + streamable HTTP,工具懒加载);轻量 skills(`.moss/skills//SKILL.md`,渐进披露,`$ARGUMENTS` 传参);自定义斜杠命令(`.moss/commands/.md`);人设(`.moss/soul.md`);生命周期 hook。
-**嵌入与自动化。** headless 输出是给脚本 / CI 用的稳定契约;把任务跑到裁决位时,**只有 accepted 才退出码 0**:
+**嵌入与自动化。** headless 输出是给脚本 / CI 用的稳定契约;把任务跑到裁决位时,**只有 accepted 才退出码 0**。`-p` 末行「未验证 / Unverified」(声称检查通过但本会话未跑过)退出码为 **9**(`ExitCode.INCOMPLETE`),与真正完成(0)区分。注意:未验证与未完成同为退出码 9,脚本需看末行区分(「未验证:」/ `Unverified:` 对比「未完成:」/ `Incomplete:`):
```bash
moss --print "summarize this repository"
@@ -362,6 +362,7 @@ moss config set language auto # 默认:仅当系统区域以 zh 开
- 会话级规则下一个工具调用即生效;`/permissions persist` 写用户配置重启仍生效;
- `--read-only` / `MOSS_SAFETY_MODE=read-only` 是压过任何模式(含 full)的只读上限。
- **本机硬拦截永不撤**:本机 `exec` 的毁灭性命令(`rm -rf /` 等)与路径逃逸在 full 模式下同样被拦——full 跳过的是询问,不是检查。设备侧的同一类命令不硬拦死:TTY 确认、allow 规则,或显式信任之后会真的执行。
+- **受保护路径(O94)只是尽力拦截,不是沙箱**:文件工具与 shell 命令直接写入、删除或移动受保护路径(如 `~/.ssh`、`.git/` 内的文件)时,即使在 full 模式下也要确认一次;`git commit`、`git checkout` 等 git 自身对 `.git/` 的写入不弹确认。shell 这一层靠解析命令行识别写入目标,属于尽力拦截,不是操作系统沙箱。通过脚本间接写入(如 `python x.py`、`node -e`、`bash -c`)由 O101 三层处理:内联代码与工作区脚本在执行前走同一套确认(`-p` 下拒绝);绕过前两层的改动在命令结束后用元数据快照检出,标成「已被修改」并让 run outcome 未完成(退出非 0),不自动还原。shell 层仍是尽力拦截,不是沙箱;`exec_background` 在进程完全退出时同样做第三层对比。单条前台命令里的 `&` / `nohup` 延后写入,以及 Moss 进程退出后的写入,仍是已知局限。
- **显式信任设备**(任一即可;deny 仍赢):`--trust-device`(仅本进程)、`MOSS_DEVICE_TRUST=full`、`permissions.deviceTrust=full`、`permissions.trustedDevices` 或 `MOSS_DEVICE_TRUST_DEVICES`(逗号分隔的 host / device id)。确认框里选 `a` 只信任提示里写明的范围(例如同一 unit 的 `systemctl restart` 或 `stop`,或同一命令前缀),不是整台设备的全部毁灭性操作。读取 `/etc/shadow`、私钥、`sshd_config`、`authorized_keys` 归入 `sensitive`:同样要确认,但文案和证据不把它叫成毁灭性修改。确认与拒绝文案跟随界面语言(`--lang`、`MOSS_LANG` 或用户配置 `language`;否则系统区域以 `zh` 开头时为简体中文)。每次决定写入 `.moss/evidence.jsonl`(`metric: device_policy`),有进行中的任务时同时写入时间线 `note`。策略说明见 [`docs/superpowers/plans/2026-10-09-device-safety-policy.md`](docs/superpowers/plans/2026-10-09-device-safety-policy.md)。
- **旧键兼容**(一版宽限):`profile` / `trustedTools` / `deniedTools` / `safetyMode` / `approvalPolicy` 读入即按映射表翻译(cautious→manual+只读上限、balanced→manual、autonomous→full、trustedTools→allow 规则、deniedTools→deny 规则),写侧提示 deprecated,新配置请用 `permissions.*` 块。
- 凭据只从 `.env` 或环境变量读,绝不硬编码、不进日志、不传子进程、不写设备清单。
@@ -412,479 +413,3 @@ npm run build && MOSS_REAL_TERMINALS=1 npm run test:filter -- --filter tui-real-
## License
MIT(见 [`LICENSE`](LICENSE))。
-
----
-
-## Moss — English
-
-**English** · [中文](#moss)
-
-> One Intent → One Task → One Agent Loop → One Verified Result.
-
-Moss is a minimal, cross-platform coding agent harness **and** an Agent Task OS for robot
-development. It turns a sentence of intent into a state-machine task with machine-checkable
-acceptance criteria, runs it through one agent loop, and **refuses to call it done without a
-verdict backed by recorded evidence**. It talks to RDK / Linux robots over SSH, so "done" can
-mean _the board actually did it_.
-
-- TypeScript / ESM single package · Node ≥ 22.16.0 · Linux / macOS / Windows
-- No account, no cloud service, no telemetry — providers are plain HTTP endpoints
-- Four surfaces: full-screen TUI · readline REPL · headless CLI · embeddable SDK
-
-### Prerequisites
-
-Check Node first (**hard requirement: Node ≥ 22.16.0**):
-
-```bash
-node -v
-```
-
-If Node is missing or too old, install it first (copyable):
-
-- **macOS:** `brew install node@22`, or `nvm install 22 && nvm use 22` / `fnm install 22 && fnm use 22`
-- **Ubuntu / Debian:** NodeSource ([nodesource/distributions](https://github.com/nodesource/distributions)) or the same nvm / fnm commands
-- **Windows (PowerShell):** `winget install OpenJS.NodeJS.LTS`
-- **In China:** `npm config set registry https://registry.npmmirror.com`. For nvm's Node downloads, set `NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node`
-
-On Node older than 22.16, `npm install -g @rdk-moss/agent@latest` (and the source-path `preinstall` / `prepare` hooks) **exits non-zero immediately**, prints the detected Node version and copyable upgrade commands (including the success-path `npm install -g @rdk-moss/agent@latest`), and **does not** leave a broken global `moss`. Node 22.16 ships with npm 10. Do not follow npm's notice to upgrade to npm 12: Node 22.16 does not support npm 12.
-
-#### macOS
-
-Git requires the Xcode Command Line Tools. If `git` is missing, run `xcode-select --install`. Moss itself does not need a C compiler, and a failed optional native build of `cpu-features` still leaves a working `moss`. If the global prefix is not writable, use the user-level prefix below.
-
-#### Windows
-
-Install Git for Windows. PowerShell's execution policy can block `moss.ps1`. Allow scripts for the current user only:
-
-```powershell
-Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
-```
-
-Put `%APPDATA%\npm` on PATH. That is npm's default global bin directory on Windows.
-
-### Quick start
-
-**Recommended (one line)** when Node ≥ 22.16 is already installed:
-
-```bash
-npm install -g @rdk-moss/agent@latest
-moss --version
-moss
-```
-
-If an older unscoped `moss` package is already installed, run `npm uninstall -g moss` first. The two packages use the same `moss` bin, and npm stops with EEXIST. Do not pass `--force`. It leaves both the old package and `@rdk-moss/agent` installed, and a later `npm uninstall -g moss` removes the `moss` command.
-
-If npm reports EACCES on the global prefix, point prefix at your home directory, or use nvm (Node then lives under your home directory):
-
-```bash
-npm config set prefix ~/.npm-global
-```
-
-Add `~/.npm-global/bin` to PATH and open a new terminal.
-
-
-Develop from source (optional)
-
-For contributors. `npm ci` runs `prepare` (Node check, then `npm run build`), so there is no separate build step:
-
-```bash
-git clone https://github.com/D-Robotics/moss.git
-cd moss
-npm ci
-npm install -g --install-links .
-moss
-```
-
-npm 11 may print `npm warn install-scripts`. `npm ci` may name `ssh2` and `cpu-features`. The following `npm install -g --install-links .` may also name `@rdk-moss/agent` (its `preinstall` and `prepare` scripts). The warning is harmless: ssh2 works without its optional native addon, and the global copy already contains `dist/`. Do not run `npm audit fix --force`. It changes dependency versions and breaks the install.
-
-
-
-With no usable config, `moss` sets itself up in that screen (press a number to pick a provider, or Enter to use a key already in the environment; the value is not shown). The D-Robotics gateway is listed first and preselected (`https://ai-api.d-robotics.cc/v1`, default model `deepseek-flash`, key only). To keep the key out of the file, set the provider and then the variable name: `moss config set provider d-robotics`, then `moss config set apiKeyEnv `. A file that names only `apiKeyEnv` is not configured: setup asks which provider, and the key is not sent to the default DeepSeek endpoint. Then, in the same session:
-
-```text
-look around this folder and tell me what it is
-```
-
-### Custom OpenAI-compatible gateway
-
-For a gateway that is not a built-in preset, use `openai-compatible`. These four commands set the provider, model, address, and the key variable name (`apiKeyEnv` stores the name, not the secret):
-
-```bash
-moss config set provider openai-compatible
-moss config set model your-model-name
-moss config set baseUrl https://gw.example
-moss config set apiKeyEnv MY_GATEWAY_API_KEY
-export MY_GATEWAY_API_KEY=your-key
-moss doctor
-```
-
-A trailing `/v1` on `baseUrl` is optional. Both `https://gw.example` and `https://gw.example/v1` work. Moss appends `/v1/chat/completions` itself and never produces `/v1/v1`. Do not include `/chat/completions`; it is not needed, and Moss strips it if it is present. `moss doctor` checks the provider, model, address, and whether the key variable is set.
-
-Inside Moss: give it a job (`@` to reference files, `!` for shell), `Shift+Tab` to cycle modes
-(`plan` = read-only planning), `Ctrl+V` to attach a clipboard image / Finder file / local path
-(macOS; Linux: wl-paste/xclip; Windows: PowerShell), `/help` for keys and commands.
-
-
-Run the build output directly · one-shot mode · resume
-
-```bash
-node dist/cli.js --help # run the build output
-moss "check disk usage" # one-shot (or pipe: echo "list files" | moss)
-moss resume --last # continue the latest session
-moss --no-tty # force the readline REPL
-```
-
-
-
-### Upgrade
-
-If the `moss` command still comes from an older unscoped install, including `npm link`, run `npm uninstall -g moss` first. Otherwise a new global install stops with EEXIST even when `moss --version` already looks new.
-
-**npm global install** (recommended path):
-
-```bash
-npm install -g @rdk-moss/agent@latest
-```
-
-**From an existing source clone**, in the parent of that clone:
-
-```bash
-cd moss && git pull && npm ci && npm install -g --install-links .
-```
-
-`npm ci` rebuilds. `--install-links` installs that new copy into the global prefix, instead of only rebuilding the clone. `moss --version` includes the short commit and the build date, for example `moss v0.26.0 (e8dc2e3, 2026-10-10)`. A worktree with uncommitted changes at build time is marked `e8dc2e3+dirty`. The before and after strings differ.
-
-`moss update` prints the matching command: `@latest` for an npm global install, the clone upgrade line when it finds a checkout, and `git clone` only when neither applies. A global install only looks for a checkout in the current directory and in `./moss`. Point it somewhere else with `moss update --dir ` or `MOSS_SOURCE_DIR`. It prints the commands and does not run them.
-
-### Uninstall
-
-```bash
-npm uninstall -g @rdk-moss/agent
-```
-
-That removes the command. These stay behind:
-
-- Config: `~/.config/moss` on Linux and macOS (`$XDG_CONFIG_HOME/moss` when that variable is set), or `%APPDATA%\moss` on Windows. Also check `~/.moss`
-- The npx cache: `~/.moss/cache/npx`
-- Per-project `.moss/` directories (tasks, sessions, project config)
-- The source clone
-- The npm cache, usually `~/.npm`
-- The native-build cache: `~/.cache/node-gyp`
-- An empty `@rdk-moss` directory left under the prefix: `$(npm prefix -g)/lib/node_modules/@rdk-moss` on Linux and macOS, or `%APPDATA%\npm\node_modules\@rdk-moss` on Windows
-
-Safe to delete: `~/.moss/cache/npx`, `~/.cache/node-gyp`, the npm cache (`npm cache clean --force`), that empty `@rdk-moss` directory, and the clone once you no longer need the source. Deleting `~/.config/moss`, `~/.moss`, or a project's `.moss/` also removes keys, settings, and task history. Delete those only when you want that data gone.
-
-### Board has no outbound network / proxy setup
-
-Lab and classroom RDK boards often **cannot** reach GitHub, npm, Node mirrors, or the model gateway directly (TCP timeouts). The first `git clone` hangs.
-
-**Required environment variables (official recommendation — these only):**
-
-```bash
-export HTTPS_PROXY=http://127.0.0.1:1080
-export HTTP_PROXY=http://127.0.0.1:1080
-export NO_PROXY=localhost,127.0.0.1,::1
-# Current Moss main (O83 / #81 onward) routes Node fetch through these proxies
-# automatically. Do NOT set NODE_USE_ENV_PROXY — that was a temporary workaround
-# on older tips and is not needed now.
-```
-
-Port and whether you need an `ssh -R` tunnel depend on your site.
-
-**First install on a board:**
-
-1. **git clone:** use `git clone --depth 1 https://github.com/D-Robotics/moss.git`. A full clone often fails with GnuTLS / early EOF on slow or proxied links.
-2. **npm:** in China you may `npm config set registry https://registry.npmmirror.com`. A registry mirror **does not replace** `HTTPS_PROXY` when the board still needs a proxy to leave the LAN.
-3. **Node:** many RDK images already ship Node ≥ 22.16. nvm’s install script needs `raw.githubusercontent.com` and will time out offline — prefer the system Node, or install via the proxy.
-4. **User-level prefix:** `npm i -g` as root writes under `/usr`. Prefer `npm config set prefix "$HOME/.npm-global"` and put that `bin` on PATH.
-
-**How to confirm the proxy works (both required):**
-
-1. The proxy log shows `CONNECT` to the model / gateway host; and
-2. End-to-end you get a **successful answer** (`moss -p "…"` exits 0 — not “network connection failed”).
-
-**CONNECT alone with a network failure is a FAIL.** For triage you can temporarily set `MOSS_NO_RDK_DOCS=1`. Root-cause work for “CONNECT without an answer” is backlog **O87**; this README does not replace that item. Treat connection errors as proxy / firewall issues — **not** “switch to a faster model”.
-
-### Why Moss
-
-A normal coding agent's definition of done is the model saying so. Moss makes that judgement an
-auditable machine process:
-
-```text
-task_define (goal + machine-checkable acceptance criteria)
- → device_deploy / device_exec / run_tests (real execution)
- → record_evidence (structured Expected / Observed / Result)
- → task_acceptance (evaluate against the latest evidence per metric)
-```
-
-- **PASS can only come from a verdict provider** (command verdict > contract verdict) — model
- prose is never an event.
-- Missing evidence = not done; `latest-wins` supports re-verification after a repair.
-- Every task runs the same event-sourced state machine:
- `draft → planning → executing → verifying → diagnosing → repairing → reverifying → accepted | failed`.
-- The acceptance gate is built in: a run that defined a task but produced no verdict gets its
- final answer intercepted once and corrected — it does not hijack indefinitely.
-
-Artifacts land in the workspace `.moss/`: `tasks.jsonl` · `task-events.jsonl` · `evidence.jsonl` ·
-`deployments.jsonl` · `acceptance.jsonl` · `task-failures.jsonl` · `task-repairs.jsonl`.
-
-### Capabilities
-
-**Coding.** Turn control, context compaction and budgets, nudges, loop guards, and **45 built-in
-tools** (incl. **12 SSH device tools**) covering files and patches, code search, processes and
-background jobs, web, diagnostics, tests, sub-agents, and the task contract; mutating tools go
-through approval. Sessions are saved, searchable, exportable, forkable; `/compact`, `/rewind`,
-`/diff`, `/review`.
-
-**Sub-agents.** `create_subagent` with scoped children (explore / plan / verify / full),
-background runs, `fan_out_subagents` with aggregated summaries, and worktree-isolated writers
-merged back via 3-way patch.
-
-**Robots (RDK first).** Read-only `device_info` · `device_processes` · `device_resources` ·
-`device_temperature` · `device_network` · `device_cameras` · `device_robotics_status` ·
-`device_file_read` · `device_file_list`; approved mutations `device_exec` · `device_file_write` ·
-`device_deploy`. Register a fleet and fan out:
-
-```bash
-export MOSS_DEVICE_HOST= MOSS_DEVICE_PORT=22 MOSS_DEVICE_USER=root MOSS_DEVICE_PASSWORD=...
-
-moss device add rdk-01 192.168.1.10 --user root --kind rdk
-moss device fleet info --devices rdk-01,rdk-02,rdk-03 --concurrency 4
-moss --print "define a task: camera pipeline keeps 30 FPS for 60s; deploy, run, record evidence, accept"
-```
-
-Board manuals (flashing, pinouts, TROS / hobot_dnn, specs) come from the built-in rdk-docs MCP,
-defaulting to the pinned `rdk-docs-mcp@0.3.0` (BM25 + title fusion, `noGoodMatch`, `alt_queries`,
-and board filtering; default hits are title, url, anchor, and snippet, with scores only when
-`verbose`; `get_page` returns the matching section unless `full`). Moss connects it in the background by default, with or
-without a device target (`MOSS_DEVICE_HOST` or `.moss/devices.json` is not required),
-so a cold npx download does not block the interactive shell. To test an unpublished build, set
-`"rdkDocs": {"package": "../rdk-docs-mcp"}` or `MOSS_RDK_DOCS_PACKAGE` to an npm spec, local
-directory, or tarball. Overrides execute code; use only trusted sources. `MOSS_NO_RDK_DOCS=1`,
-`"rdkDocs": false`, or `"rdkDocs": {"enabled": false}` turns it off. A same-named `mcp.json` entry
-replaces the builtin. Tool features vary by package version, so Moss discovers names and schemas
-before use. If the server cannot be reached, this session does not look up manuals — there is no
-cache or offline copy. See [`docs/superpowers/plans/2026-10-09-rdk-knowledge-via-mcp.md`](docs/superpowers/plans/2026-10-09-rdk-knowledge-via-mcp.md).
-
-**Extensibility.** MCP client (stdio + streamable HTTP, lazy tool loading), lightweight skills
-(`.moss/skills//SKILL.md`, `$ARGUMENTS` interpolation), custom slash commands
-(`.moss/commands/.md`), persona (`.moss/soul.md`), lifecycle hooks.
-
-**Embedding.** Headless output is a stable contract for scripts and CI; a task's exit code is 0
-**only when accepted**:
-
-```bash
-moss --print "summarize this repository"
-moss --output-format json "..." # or stream-json (system/init → assistant → user → result)
-
-moss task run --goal "create hello.txt containing MOSS_OK and verify its content" --accept "grep -q MOSS_OK hello.txt"
-moss task status && moss task timeline
-moss tasks list # read-only robotics artifacts
-```
-
-The `src/index.ts` export surface is a semver-protected contract, snapshotted by
-`test/sdk-contract.spec.mjs`; three runnable integrations live in `examples/` (`npm run examples`).
-
-### Board-side inference dependencies (RDK)
-
-> **Do not** use `http://sdk.d-robotics.cc:8080/simple/` (offline; connection refused).
-> **Do not** use `http(s)://archive.d-robotics.cc/simple/` (not a working pip index; HTTPS returns 404).
-
-#### Pick the interface by OS / board
-
-| Board / scenario | Use |
-| ----------------------------------------------------- | -------------------------------------------------------------------- |
-| RDK X5, RDK OS ≥ 3.5.0 (`rdk_x5`) | Board-bundled **`hbm_runtime`**; **never** `pip install hbm_runtime` |
-| RDK X5 legacy demos (`rdk_x5_legacy` / OS < 3.5.0) | Verified X5 1.0.3 whl below, or board `hobot_dnn` |
-| RDK X3 | Verified X3 1.0.3 whl below, or board `hobot_dnn` |
-| RDK S series | Board `hbm_runtime`; do not install X5 `hobot_dnn` |
-
-Official reference: [Model Zoo infer API](https://developer.d-robotics.cc/model_zoo_doc/infer_api_ref).
-
-#### Legacy whl only (verified HTTP 200)
-
-Only these two direct URLs appear in Moss docs. Moss **will not** auto-install packages.
-
-```bash
-# X5 legacy
-wget -nc https://archive.d-robotics.cc/downloads/rdk_model_zoo/rdk_x5/bpu_infer_lib_x5-1.0.3-py3-none-any.whl
-pip install bpu_infer_lib_x5-1.0.3-py3-none-any.whl
-
-# X3
-wget -nc https://archive.d-robotics.cc/downloads/rdk_model_zoo/rdk_x3/bpu_infer_lib_x3-1.0.3-py3-none-any.whl
-pip install bpu_infer_lib_x3-1.0.3-py3-none-any.whl
-```
-
-#### Missing libhbucp.so
-
-Usually a pip overlay of system hobot / hbm packages, or OS < 3.5.0 running a new Model Zoo. **Do not** `pip install hbm_runtime`. Prefer an image ≥ 3.5.0 with the board-bundled runtime; if pip overwrote packages, uninstall the pip copies, reinstall `hobot-dnn` via apt, and run `ldconfig`. `moss doctor` warns on deprecated pip indexes and can optionally probe the whl URLs above.
-
-### Command reference
-
-| Subcommand | Purpose |
-| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
-| `moss` (or `moss "prompt"`) | Interactive shell / one-shot |
-| `moss setup` · `moss auth status\|logout` | Setup wizard · check or clear stored credentials |
-| `moss doctor` (alias `moss status`) | Health-check config / credentials / workspace / runtime |
-| `moss config show\|env\|init\|set\|unset\|validate` | Config I/O; `moss config --help` lists every key, `moss config env` is the env list |
-| `moss resume` · `moss fork` · `moss sessions list\|delete\|search\|export` | Resume / fork / manage sessions |
-| `moss task run\|resume\|status\|timeline` | Unified task runtime (intent in → verified result out) |
-| `moss tasks list\|evidence\|deployments\|acceptance\|device` | Read-only robotics artifacts |
-| `moss device add\|list\|remove\|test\|fleet` | Device registry + read-only fleet probe |
-| `moss mcp add\|list\|remove\|test` | MCP server management |
-| `moss skill create\|list` | Skill management |
-| `moss update` | Print the upgrade command (npm global or git clone); it does not run it |
-
-> `plugins` / `migrate` / `web` / `agent` belong to removed subsystems and fail loudly
-> in this build rather than silently falling back to chat. `moss --help` is that
-> command's own usage.
-
-Everyday slash commands: `/model` `/compact` `/goal` `/plan` `/review` `/doctor` `/diff`
-`/permissions` `/clear` `/help`. Shift+Tab cycles modes; `/plan` enters plan mode; `/goal `
-works until the condition is met and `/goal clear` cancels it. `/resume` restores a saved
-conversation; `/tasks` lists background shell jobs and sub-agents. Esc interrupts the current
-reply; a message typed during a run steers it, and queues above the composer when steering is
-refused (Up edits that queue). `/mode` `/steer` `/queue` `/loop` stay as hidden aliases for one
-version (`/loop` is now `/goal`). Without `--accept`, `/goal` proposes acceptance commands from
-test entry points that exist in the workspace (`package.json` test, Makefile, pytest, `go.mod`)
-and says so when it finds none. `/stop` (alias `/abort`) stops only background processes this
-session started. The hidden `/task` is the Task OS entry (status / timeline / resume / view /
-verify); `/task verify` takes one verdict without a model turn. A PASS still comes only from the
-verdict provider.
-
-Key flags: `-m/--model`, `--provider`, `--base-url`, `-C/--cd`, `-c/--config k=v`,
-`--read-only` · `--workspace-write` · `--full-access` (workspace-write confines Moss's own file tools; shell commands run normally without an OS sandbox), `--trust-device`, `--accept-edits`,
-`--ask-for-approval `, `-p/--print`, `--json`, `--output-format `.
-
-Key env vars (full list: `moss config env`): `MOSS_PROFILE` · `MOSS_WORKSPACE` ·
-`MOSS_SAFETY_MODE` · `MOSS_APPROVAL_POLICY` · `MOSS_MAX_AGENT_TURNS` · `MOSS_CONTEXT_TOKENS` ·
-`MOSS_BUDGET_MAX_*` · `MOSS_DEVICE_*` · `MOSS_NO_RDK_DOCS`.
-
-> **Gotcha:** model settings are config-only. `MOSS_MODEL` / `MOSS_PROVIDER` / `MOSS_BASE_URL` /
-> `MOSS_API_KEY` are read but ignored — use `moss setup` or `moss config set`.
-
-Without an explicit file, Moss reads the user config and merges `.moss/config.json` from the
-workspace as project defaults (the user file wins). `--config-file` or `MOSS_CONFIG_FILE` loads
-only that file, so the project `.moss/config.json` layer is not part of the run.
-
-### UI language
-
-**English is the default UI language.** Chinese is a complete, optional UI language. It covers
-chrome, help, errors, and setup text. Assistant replies still follow the language of the user's
-message.
-
-```bash
-moss --lang zh # this run only
-MOSS_LANG=zh moss # process env (a project .env cannot set this)
-moss config set language zh # user config ~/.config/moss/config.json
-moss config set language auto # default: Chinese only when the locale starts with zh
-```
-
-Precedence: `--lang` > `MOSS_LANG` > user config `language` > system locale. Neutral tags
-(`C`, `POSIX`, `C.UTF-8`) are not a language and fall through to the next of `LC_ALL`,
-`LC_MESSAGES`, and `LANG`. If none names a language, the UI stays English. A project
-`.moss/config.json` and a project `.env` cannot set the UI language.
-
-In the shell, `/language` (alias `/lang`) switches the session. `/language zh save` writes the
-user config. On a Chinese system locale that has not chosen yet, the first `moss setup` offers
-one line: press `e` to switch to English.
-
-### Safety and privacy
-
-- **Full by default since v0.26**: local writes and **reversible** device changes skip the
- prompt. Destructive device operations (reboot, flashing, writes to `/boot` or `/etc`, network
- changes, removing system packages, stopping ssh) still confirm. Full matches Claude Code's
- "ask less by default", not `--dangerously-skip-permissions` against a robot board.
-- **Four interaction modes** (Shift+Tab cycles, or `/plan` to enter plan mode; `/mode` remains for one version):
-
- | Mode | Behavior |
- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
- | `manual` | mutations and device changes ask one by one |
- | `acceptEdits` | workspace file-tool edits auto-approve; shell and device changes still ask |
- | `plan` | read-only planning; mutations and device changes blocked |
- | `full` (default) | local writes and reversible device work skip the prompt; destructive device work confirms on a TTY and is refused headless. Deny rules and host hard blocks still apply |
-
-- **`workspace-write` is not an OS sandbox.** workspace-write confines Moss's own file tools. Shell commands run normally without an OS sandbox. `write_file`, `edit_file`, `multi_edit`, `move_file`, and `apply_patch` stay inside the workspace. `exec` is not wrapped in Landlock, bubblewrap, or seatbelt. A static scan rejects some out-of-workspace shell writes it can see (redirections, `cp`, `mv`); a child `node` or `python` process can still write outside, for example under `/tmp`. Shell safety is output redaction and write-back guards. An opt-in OS sandbox is specified in [`docs/design/os-sandbox.md`](docs/design/os-sandbox.md) and stays off.
-- **Permission rules** (`/permissions`, effective in any mode, deny beats everything incl. full):
- - three levels `allow` / `ask` / `deny`, priority deny > ask > allow;
- - syntax `ToolName(pattern)` with moss-native tool names:
- `/permissions add deny "read_file(./.env)"`, `/permissions add allow "exec(npm run *)"`;
- - session rules take effect on the next tool call; `/permissions persist` writes the user config
- and survives restarts;
- - `--read-only` / `MOSS_SAFETY_MODE=read-only` is a read-only ceiling that compresses any mode
- including full.
-- **Host hard blocks never lift**: destructive host `exec` commands (`rm -rf /` …) and path
- escapes stay blocked in full mode — full skips the asking, not the checking. The same shapes on
- the device are not a permanent hard block: a TTY confirmation, an allow rule, or explicit trust
- runs them for real.
-- **Trust a device** (any one; deny rules still win): `--trust-device` (this process only),
- `MOSS_DEVICE_TRUST=full`, `permissions.deviceTrust=full`, or `permissions.trustedDevices` /
- `MOSS_DEVICE_TRUST_DEVICES` (comma-separated host or device id). Answering `a` trusts only the
- scope named in the prompt (for example `systemctl restart` or `stop` of that unit, or the same
- command prefix) until the session ends. Reading `/etc/shadow`, private keys, `sshd_config`, or
- `authorized_keys` is a `sensitive` tier: it still confirms, and the copy does not call it
- destructive. Prompts and refusals follow the UI language (`--lang`, `MOSS_LANG`, or the user
- config `language`; otherwise Simplified Chinese when the system locale starts with `zh`).
- Every decision is appended to `.moss/evidence.jsonl`
- (`metric: device_policy`) and, when a task is in progress, to its timeline as a `note`.
- Policy: [`docs/superpowers/plans/2026-10-09-device-safety-policy.md`](docs/superpowers/plans/2026-10-09-device-safety-policy.md).
-- **Legacy keys** (one release of grace): `profile` / `trustedTools` / `deniedTools` /
- `safetyMode` / `approvalPolicy` are translated on read (cautious→manual+read-only ceiling,
- balanced→manual, autonomous→full, trustedTools→allow rules, deniedTools→deny rules); writing
- them prints a deprecation notice — use the `permissions.*` block for new config.
-- Credentials come only from `.env` or the environment: never hardcoded, never logged, never
- passed to child processes, never written to the device registry. No account, no cloud, no
- telemetry.
-
-### Quality gates and benchmarks
-
-```bash
-npm run check # prettier + eslint (0 warning) + typecheck
-npm run test # build + every test/*.spec.mjs (235 specs, run against dist/)
-npm run smoke # CLI smoke: --version / --help / PTY startup
-npm run verify # check + test + smoke — required before any release
-
-# Real terminals (tmux / GNU screen / Terminal.app); off by default, missing terminals are skipped
-npm run build && MOSS_REAL_TERMINALS=1 npm run test:filter -- --filter tui-real-terminals
-```
-
-Benchmarks stay out of git in `bench/results/`:
-
-- `npm run bench` · `npm run bench:ab -- reasoning-high` · `npm run bench:noise`: agent capability, A/B, noise band
-- `npm run bench:swe` · `npm run bench:tb`: locked SWE-bench Verified subset · Terminal-Bench
-- `npm run bench:deepswe`: DeepSWE v1.1 with the model held fixed against other harnesses (runs
- through Pier; published scores in `bench/boards/deepswe-v1.1-harness.json`)
-- `npm run bench:device -- --dry` (or `--target sim` / `--target real`): RDK board-task success
- rate. A row passes only when Moss's verdict passes and an independent probe matches the
- evidence — see [`docs/bench/device-bench.md`](docs/bench/device-bench.md)
-- `npm run bench:tui-feel`: TUI feel · `node scripts/task-os-metrics.mjs`: Task OS metrics
- (turns / tools / success rate)
-
-A version number states the **current capability level**: `main` is the rolling line, tags are cut
-only after `verify` is green and `examples/` pass for real — see
-[`docs/release-policy.md`](docs/release-policy.md).
-
-### Support matrix
-
-| Dimension | Supported | Verified by |
-| --------- | ------------------------------------------------------------------------ | ----------------------------------- |
-| Node | ≥ 22.16.0 | CI matrix (22.16 / 24) |
-| Platform | Linux / macOS / Windows | CI matrix (no PTY smoke on Windows) |
-| Providers | deepseek / qwen / openai / anthropic / openai-compatible | unit tests + smoke |
-| Surfaces | TTY: full-screen TUI; non-TTY / `MOSS_NO_TUI=1` / Windows: readline REPL | TUI specs + PTY smoke |
-
-### Documentation
-
-[`AGENTS.md`](AGENTS.md) (architecture and conventions) ·
-[`docs/release-policy.md`](docs/release-policy.md) ·
-[`docs/capability-layer.md`](docs/capability-layer.md) ·
-[`docs/design/os-sandbox.md`](docs/design/os-sandbox.md) (opt-in OS sandbox for `exec`, default off) ·
-[`docs/cli-parity/`](docs/cli-parity/) (real-terminal checklist:
-[`tui-real-terminals.md`](docs/cli-parity/tui-real-terminals.md)) ·
-[`docs/bench/device-bench.md`](docs/bench/device-bench.md) ·
-[`CHANGELOG.md`](CHANGELOG.md) ·
-[`docs/superpowers/plans/`](docs/superpowers/plans/).
-
-### License
-
-MIT — see [`LICENSE`](LICENSE).
diff --git a/bench/device-tasks/sim/bin/dpkg-deb b/bench/device-tasks/sim/bin/dpkg-deb
index 0f9eba7b..8a5fc382 100755
--- a/bench/device-tasks/sim/bin/dpkg-deb
+++ b/bench/device-tasks/sim/bin/dpkg-deb
@@ -7,6 +7,7 @@ if [ "${1:-}" != "--build" ] || [ -z "${2:-}" ]; then
fi
dir="$2"
dest="${3:-$dir.deb}"
+dir="$(cd "$dir" && pwd)"
mkdir -p "$(dirname "$dest")"
# Stream via stdout. GNU tar treats "D:/..." as host:path, so -f "$dest" never writes the archive on Windows.
tar -C "$dir" -cf - . > "$dest"
diff --git a/docs/plan/moss-roadmap.md b/docs/plan/moss-roadmap.md
index 6da07b1c..7ca3f652 100644
--- a/docs/plan/moss-roadmap.md
+++ b/docs/plan/moss-roadmap.md
@@ -1,6 +1,6 @@
# Moss 下一阶段总体规划(PLAN)
-> 负责人:Moss 产品负责人(Grok Bot)|起草:2026-10-10(北京时间)|状态:v1,执行中
+> 负责人:Moss 产品负责人(Grok Bot)|起草:2026-10-10(北京时间)|状态:v1.1,执行中(2026-10-12 现实同步:第 2、3、6 节对齐上游 main `2a7d1873` 与 npm `@rdk-moss/agent@0.26.1`;基线数字未重测的保持原样)
> 适用范围:fork `QiaolongLi1201/moss` → 上游 `D-Robotics/moss`。本文是迭代的总纲。进度流水记在 `rsi/state.md`,每个任务结束后同步更新第 6 节的优化项状态。
> 数字口径:**[实测]** 有报告出处;**[提议]** 是目标或阈值,还没实测;**未知** 表示还没测,不估。文中路径除仓库内路径外都是 box 上的路径。
@@ -23,8 +23,8 @@
## 2. 现状基线(截至 2026-10-10 17:50)
-**代码**:fork main `bf72b0b9`,已含 #53 #56 #65 #66 #67 #68 #69 #74。D-Robotics/moss main 仍是 `1533eb2a`,要等 #71 修好 Mac 上的检查、在 Mac 上全量验证后再推。rdk-docs-mcp 0.3.0 已发布,Moss 钉的就是 0.3.0(#74)。
-**未合 PR(11 个,都是 draft)**:#57 bench 检查修复、#58 CLI 杂项、#59 CRLF 编辑、#60 Windows pwsh7、#62 文档问答省 token、#63 env key + rdk-docs 按需连接、#64 语言、#70 代理先就绪、#71 macOS 可移植、#72 argv 打码、#73 `.env` 安全变量。
+**代码(2026-10-12 同步)**:上游 D-Robotics/moss main = `2a7d1873`,已合入战役 PR #76–#92(含 #82 O13 thinking-only 恢复、#85 O43 一行安装、#90 O86、#92 O97 等),并完成 **npm 首发 `@rdk-moss/agent@0.26.1`**(dist-tags latest,2026-10-11T16:43Z;证据链见 `docs/release-policy.md`)。fork `QiaolongLi1201/moss` 的 11 个 draft PR 已全部有结论:#57 #58 #59 #60 #62 #64 #70 #71 #72 #73 合入,#63 关闭(另行落地)。rdk-docs-mcp 0.3.0 已发布,Moss 钉的就是 0.3.0(#74)。
+**在途(上游 open PR)**:#93 = O94 protected paths(full 模式下 `.git`/`.ssh`/shell rc/`.env`/Moss 配置写入需确认一次)、#94 = O98 收尾不虚报成功(`--print` 未完成非 0 退出)、#68(draft,SDK 嵌入宿主 / codex 线,未裁决不得擅合)。
| 目标 | 当前(出处) | 差距 |
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
@@ -91,15 +91,11 @@
3. RDB M1 至少 2 个应用跑完 5 个对照组,结果配对比较。
- **依赖**:专用可写板,以及预算决策(第 8 节)。P2 和 P3 可以交错推进,按第 4.2 节的评分选任务。
-### P4 发布
+### P4 发布(2026-10-12 更新:已按滚动口径首发)
- **目标**:npm 一条命令安装 Moss。
-- **退出条件(高质量门槛)**:
- 1. P2、P3 的退出条件都满足;
- 2. 7 天内没有新的 P0/P1;
- 3. Linux、macOS、Windows 三平台 CI 全绿;
- 4. 安装旅程 J01 不比三家差:1 条命令,耗时与三家同一量级;
- 5. 用户在 Mac 上输验证码发布。
+- **现实**:发布口径已被 2026-10-01 的 `docs/release-policy.md` 取代——main 是滚动线,版本号表示当前能力级别,tag 只在 verify 全绿 + examples 实跑后打,不为未验收的中间版本补 tag。按此口径 `@rdk-moss/agent@0.26.1` 已于 2026-10-12 首发(dist-tags latest;pack 同树全绿 verify、examples 三例实跑、发布后临时前缀实装冒烟,证据见 release-policy)。本节原"P2/P3 全满足才发布"的门槛不再适用。
+- **仍然欠的(发布后义务)**:三平台安装矩阵(目前仅发布后本机冒烟);J01 与三家的同量级耗时对比;7 天无新 P0/P1 的观察窗。这三条并入 P2/P3 的退出条件继续追踪。
---
@@ -214,6 +210,8 @@
**按顺序的前 8 项**:O01 → O13 → O14 → O02 → O03 → O04 → O05 → O06,接着是 O22(重测 U1–U8,为 P2 定基线)。
+**2026-10-12 同步(上游 main `2a7d1873`)**:战役编号已推进到 O98;本表之后新增项的流水账在 box 的 `rsi/state.md`,下表未收录。已在上游落地:O01/O02/O03/O04/O05(fork PR #71/#72/#73/#64 + PR 积压清理,全部合入或关闭)、O06(CI 真跑,含 Windows 修复 PR #86)、O13(PR #82,thinking-only 轮次降推理恢复;自举重跑记录未入仓库)、O43(PR #85 一行安装 + npm 首发 0.26.1)、O57/O58/O66/O67/O70/O83/O85/O87(PR #80–#85);O86→#90、O88→#88、O90→#89、O97→#92。在途:O94(PR #93)、O98(PR #94);PR #68(SDK 嵌入宿主,draft,codex 线)待用户裁决。两笔待收的口子:CI test jobs 目前 non-blocking(PR #87,恢复 blocking 待决定);O22 的 U1–U8 全量重测仍未做,P2 基线还缺。
+
---
## 7. 运作方式
diff --git a/docs/release-policy.md b/docs/release-policy.md
index f4153fff..8d3ac034 100644
--- a/docs/release-policy.md
+++ b/docs/release-policy.md
@@ -47,8 +47,11 @@ package.json 已 0.13.0)。v0.14–v0.20 期间欠账扩大:代码交付到
- TUI OSC 11 修复红绿取证(PTY + pyte,`scratch/tui-drive.py` raw chunks):修复前注入分片应答
(`\x1b]11` + `;rgb:ffff/ffff/ffff\x07`)在 composer 复现 `❯ ]11;rgb:ffff/ffff/ffff`;修复后同一
探针 composer 干净、后续键入正常、整体中应答同样吸收(截图不入库)。
- - 发布后冒烟:`npm install -g @rdk-moss/agent@latest` 实装并跑 `moss --version` / `--help`(发布动作
- 完成后立即执行,结果回填本节)。
+ - 发布后冒烟:`npm install -g --prefix @rdk-moss/agent@latest` 实装(55 包 / 16s),
+ `moss --version` 输出 `v0.26.1 (4cf10a9, 2026-10-11)`(与 policy 提交一致)、`--help` 正常渲染。
+ 实际发布经 `npm publish rdk-moss-agent-0.26.1.tgz`(pack 与全绿 verify 同树),2FA 走 Web 认证
+ PUT 202;交互终端跑 `npm publish` 的目录级 verify 会因 `cli-permission-defaults` 的 TTY 耦合
+ 在无头断言上失败(测试环境问题,非产品缺陷,转 PM 立单)。
- **未执行、因此不主张**:真机(非 in-process SSH)设备闭环;SWE-bench/Terminal-Bench/全量 bench
复跑(同 v0.23 口径);Windows/macOS/Linux 三平台安装矩阵(仅发布后本机冒烟)。
diff --git a/docs/studio-sdk-task-acceptance.md b/docs/studio-sdk-task-acceptance.md
new file mode 100644
index 00000000..2f77977a
--- /dev/null
+++ b/docs/studio-sdk-task-acceptance.md
@@ -0,0 +1,77 @@
+# Task acceptance authority and persistence
+
+The workspace task creation event retains its acceptance command. `resumeTask`
+and `task_acceptance` use that same command, and execute it in the requested
+workspace without changing the host process working directory. A nonzero,
+timed-out, or cancelled command cannot be replaced by passing contract evidence.
+A successful command remains authoritative even if contract criteria do not pass;
+the contract verdict remains in the audit history with its actual result.
+The latest command row records the actual external result; an empty contract
+does not add a duplicate trivial PASS. A command row without a contract is an
+audit fact and carries no native completion attestation.
+
+Native verdicts attest completion only after the acceptance ledger, contract
+status, and (for Task OS tasks) lifecycle acceptance have completed their real
+persistence barriers. These are separate appends, not a multi-file transaction.
+If a later lifecycle append fails, earlier successful audit records remain facts,
+but this incomplete contract settlement is compensated to its prior status. A
+previously completed lifecycle acceptance is never undone by this compensation.
+Custom verdict objects, source labels, and tool output strings carry no native
+commit attestation.
+
+Cancellation before the append dispatch prevents acceptance. Once the actual
+acceptance commit has begun, it completes its persistence sequence despite later
+cancellation. Only a successful completed commit wins that cancellation race;
+an IO failure carries no successful attestation. Tool execution scopes consume
+that attestation and stop remaining tools and model cycles in the accepting SDK
+agent. A nested SDK agent owns a separate scope, including in a shared workspace.
+This completion boundary does not synthesize a user abort.
+
+Each artifact append prepares a sidecar containing the previous confirmed byte
+length before changing the JSONL file. Files are fsynced on every platform; POSIX
+also fsyncs the directory entries. Node on Windows does not provide directory
+fsync, so Windows does not claim that additional directory durability guarantee.
+Failed writes attempt to truncate and sync their own tail under the existing
+workspace writer lock. If compensation also fails, the prepare sidecar remains:
+readers, including fresh processes, replay only its confirmed prefix. Invalid or
+unreadable prepare metadata produces a storage error. A later writer refuses an
+unresolved tail rather than guessing that it committed. Recovery of such a tail
+requires inspection and storage repair; there is no automatic marker deletion.
+If dispatch was rejected before any append, successful exclusive marker creation
+proves this writer owns the prepare record. While still holding the mutex, it can
+remove that record without altering the original data prefix, including after
+prepare write, sync or close fails. Failed cleanup retains recovery metadata;
+unknown markers and dispatched IO outcomes remain fail closed.
+
+Session recovery similarly claims the observed dead owner's exact generation
+before rechecking its token and liveness and replacing the recovery gate. Unknown,
+live, or stranded dead reclaim claims are retained and fail closed. A process
+crashing while it holds that claim can require operator recovery; recursively
+reclaiming the claim would recreate the unsafe replacement race.
+
+Public regressions cover persistent command failure across resume and tool
+acceptance, workspace cwd, native loop completion and nested-agent isolation,
+actual file/directory barriers, second and third append faults, failed rollback
+read from a fresh Node process, cancellation, and competing recovery owners.
+
+Task artifact readers now hold the same generation-protected mutex as writers,
+so a cached tail cannot survive an intervening append rollback unnoticed. Time
+alone never evicts a live task lock. Identifiable legacy pid:nonce owners require
+a real ESRCH probe; unknown ownership is retained. Nested reads reuse only an
+active owner, and inherited callbacks must reacquire after that owner releases.
+Nested workspaces retain their active parent ownership without permitting a
+recursive write.
+
+A tool deadline still fires at its configured time. Only a native acceptance
+append already dispatched under the lock registers its remaining persistence
+settlement; before another mutation or model cycle, the SDK drains that actual
+settlement and consumes a successful attestation. Failed settlement grants no
+stop. Ordinary tool timeouts, evidence reads, and lock waits do not register this
+drain. Neither a native append nor queued suite evidence can begin later from an
+already ended tool scope. Acceptance commands propagate cancellation before
+recording suite evidence, and queued evidence checks the signal again under the
+workspace lock immediately before dispatch. An
+already dispatched OS write/fsync that never returns can delay completion even
+after cancellation or a run budget: this is an explicit availability limitation
+of commit priority, not a claim of bounded cancellation for uninterruptible IO.
+No uncertain or failed write is reported as accepted to manufacture an exit.
diff --git a/package-lock.json b/package-lock.json
index 1c882d77..f9ba601d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,13 @@
{
"name": "@rdk-moss/agent",
- "version": "0.26.1",
+ "version": "0.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@rdk-moss/agent",
- "version": "0.26.1",
+ "version": "0.27.0",
+ "hasInstallScript": true,
"license": "MIT",
"dependencies": {
"ink": "^7.1.1",
@@ -19,7 +20,7 @@
"undici": "^7.29.0"
},
"bin": {
- "moss": "dist/cli.js"
+ "moss": "bin/moss.cjs"
},
"devDependencies": {
"@eslint/js": "10.0.1",
diff --git a/package.json b/package.json
index 2abb5d7e..41ff1fd5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@rdk-moss/agent",
- "version": "0.26.1",
+ "version": "0.27.0",
"private": false,
"type": "module",
"license": "MIT",
@@ -22,6 +22,7 @@
"scripts/check-node-version.cjs",
"bin",
"README.md",
+ "README.en.md",
"LICENSE"
],
"publishConfig": {
diff --git a/scripts/check-node-version.cjs b/scripts/check-node-version.cjs
index 3fde3d8f..6fac0ce5 100644
--- a/scripts/check-node-version.cjs
+++ b/scripts/check-node-version.cjs
@@ -64,6 +64,27 @@ function upgradeCommands() {
].join('\n');
}
+/**
+ * npm prints its own EBADENGINE warnings before preinstall runs, then replays
+ * this script's stderr as `npm error` lines at the very end. Close with a
+ * short block so the upgrade and reinstall commands sit on the last screen.
+ */
+function nextSteps(zh) {
+ return (
+ zh
+ ? [
+ '下一步(Node >= 22.16):',
+ ' nvm install 22 && nvm use 22 # 或用上面的 brew / apt / winget',
+ ' npm install -g @rdk-moss/agent@latest',
+ ]
+ : [
+ 'Next steps (Node >= 22.16):',
+ ' nvm install 22 && nvm use 22 # or brew / apt / winget above',
+ ' npm install -g @rdk-moss/agent@latest',
+ ]
+ ).join('\n');
+}
+
function upgradeMessage(version, env) {
var shown = String(version || '').replace(/^v/, '');
var locale = preferredLocale(env || process.env);
@@ -77,7 +98,7 @@ function upgradeMessage(version, env) {
'Moss needs Node >= 22.16, but this is Node ' + shown + '.',
'Upgrade Node, then install again with the copyable commands below:',
].join('\n');
- return lead + '\n' + upgradeCommands() + '\n';
+ return lead + '\n' + upgradeCommands() + '\n\n' + nextSteps(zh) + '\n';
}
function versionUnderTest() {
@@ -91,4 +112,4 @@ if (require.main === module && !nodeIsSupported(versionUnderTest())) {
process.exit(1);
}
-module.exports = { nodeIsSupported, upgradeMessage, upgradeCommands };
+module.exports = { nodeIsSupported, upgradeMessage, upgradeCommands, nextSteps };
diff --git a/scripts/check-windows-host-shell.mjs b/scripts/check-windows-host-shell.mjs
new file mode 100644
index 00000000..9e307e64
--- /dev/null
+++ b/scripts/check-windows-host-shell.mjs
@@ -0,0 +1,45 @@
+import cp from 'node:child_process';
+import { syncBuiltinESMExports } from 'node:module';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+/** Observe the original native probe without changing arguments, receiver or return identity. */
+export function observeHostShellProbe(delegate, rows) {
+ return function (...args) {
+ const started = performance.now();
+ const result = Reflect.apply(delegate, this, args);
+ rows.push({
+ executable: args[0],
+ args: args[1],
+ timeoutMs: args[2]?.timeout,
+ durationMs: Math.round(performance.now() - started),
+ status: result.status,
+ signal: result.signal,
+ errorCode: result.error?.code ?? null,
+ stdout: String(result.stdout ?? '').slice(0, 2048),
+ stderr: String(result.stderr ?? '').slice(0, 2048),
+ });
+ return result;
+ };
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ const original = cp.spawnSync;
+ const rows = [];
+ cp.spawnSync = observeHostShellProbe(original, rows);
+ syncBuiltinESMExports();
+ try {
+ // Install the delegate before this first import captures runProcessSync.
+ const { resolveHostShell } = await import('../dist/utils/host-shell.js');
+ const shell = resolveHostShell();
+ if (shell.kind !== 'pwsh') {
+ console.error(JSON.stringify({ shell, originalNativeProbes: rows }));
+ process.exitCode = 1;
+ } else {
+ console.log(`${shell.kind} ${shell.executable}`);
+ }
+ } finally {
+ cp.spawnSync = original;
+ syncBuiltinESMExports();
+ }
+}
diff --git a/scripts/lib/device-bench.mjs b/scripts/lib/device-bench.mjs
index e83c7386..3542b5c8 100644
--- a/scripts/lib/device-bench.mjs
+++ b/scripts/lib/device-bench.mjs
@@ -530,13 +530,83 @@ function installProviderConfigCleanup() {
}
}
+export const PROVIDER_CONFIG_PREFIX = 'moss-device-bench-provider-';
+export const PROVIDER_CONFIG_OWNER_FILE = 'owner.pid';
+
+/**
+ * Whether the pid recorded in `dir/owner.pid` is still running. A missing,
+ * unreadable, or unparsable pid file counts as alive: only a recorded owner
+ * that is provably gone marks the dir as orphaned. Live owners are respected,
+ * so two benches running at once never delete each other's config dir.
+ */
+function providerConfigOwnerIsAlive(dir) {
+ let raw;
+ try {
+ raw = fs.readFileSync(path.join(dir, PROVIDER_CONFIG_OWNER_FILE), 'utf8');
+ } catch {
+ return true;
+ }
+ const pid = Number.parseInt(raw.trim(), 10);
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return true;
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (err) {
+ // ESRCH: no such process (dead owner). EPERM: the process exists but is not
+ // ours to signal, so treat it as alive.
+ return !(err instanceof Error && err.code === 'ESRCH');
+ }
+}
+
+/**
+ * Delete config dirs left behind by a run killed before its signal handlers
+ * could run (SIGKILL, or SIGTERM on Windows). Only entries with the bench
+ * provider prefix, that are real directories, owned by this uid, and whose
+ * recorded owner pid is dead are removed; live runs and every other tmpdir
+ * entry are left untouched.
+ */
+export function sweepStaleProviderConfigDirs(tmpdir = os.tmpdir()) {
+ let removed = 0;
+ let entries;
+ try {
+ entries = fs.readdirSync(tmpdir);
+ } catch {
+ return removed;
+ }
+ for (const name of entries) {
+ if (!name.startsWith(PROVIDER_CONFIG_PREFIX)) continue;
+ const dir = path.join(tmpdir, name);
+ if (providerConfigDirs.has(dir)) continue;
+ let stat;
+ try {
+ stat = fs.lstatSync(dir);
+ } catch {
+ continue;
+ }
+ if (!stat.isDirectory()) continue;
+ const uid = process.getuid?.();
+ if (process.platform !== 'win32' && uid !== undefined && stat.uid !== uid) continue;
+ if (providerConfigOwnerIsAlive(dir)) continue;
+ try {
+ fs.rmSync(dir, { recursive: true, force: true });
+ removed += 1;
+ } catch {
+ // Best effort: another process may have removed it already.
+ }
+ }
+ return removed;
+}
+
/** Temp config holding the bench API key. Removed on finish, exit, and signals. */
export function writeBenchProviderConfig(provider) {
- const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-device-bench-provider-'));
+ const configDir = fs.mkdtempSync(path.join(os.tmpdir(), PROVIDER_CONFIG_PREFIX));
providerConfigDirs.add(configDir);
installProviderConfigCleanup();
try {
fs.chmodSync(configDir, 0o700);
+ fs.writeFileSync(path.join(configDir, PROVIDER_CONFIG_OWNER_FILE), `${process.pid}\n`, {
+ mode: 0o600,
+ });
fs.writeFileSync(
path.join(configDir, 'config.json'),
`${JSON.stringify({
@@ -884,6 +954,9 @@ export async function runDeviceBench(options = {}) {
if (mode !== 'dry' && mode !== 'sim' && mode !== 'real') {
throw new Error(`unknown mode ${mode}`);
}
+ // Clear provider config dirs (they hold the API key) orphaned by a run that
+ // was killed before its signal handlers could run.
+ sweepStaleProviderConfigDirs();
const tasks = filterTasks(options.tasks ?? loadDeviceTasks(options.taskDir), options.filters);
const problems = validateDeviceTasks(tasks);
if (problems.length > 0) throw new Error(`device task suite invalid:\n${problems.join('\n')}`);
diff --git a/scripts/run-package-tests.mjs b/scripts/run-package-tests.mjs
index c6d1acf7..9878dd38 100644
--- a/scripts/run-package-tests.mjs
+++ b/scripts/run-package-tests.mjs
@@ -213,6 +213,12 @@ function runSpec(file) {
});
}
+function isCiEnv(value) {
+ if (!value) return false;
+ const v = String(value).trim().toLowerCase();
+ return v !== '' && v !== '0' && v !== 'false';
+}
+
function leakedHomes() {
return listMossTempHomes(runRoot);
}
@@ -225,16 +231,32 @@ function reportLeaks() {
return 1;
}
+// Under CI run every file and summarize failures, so one red file cannot hide
+// later results (e.g. other platform evidence). Locally keep fail-fast.
+const runAll = isCiEnv(process.env.CI);
+const failedFiles = [];
for (const file of testFiles) {
console.error(`[test] ${file}`);
const result = await runSpec(file);
if (shuttingDown) break;
if (result.status !== 0) {
+ if (runAll) {
+ failedFiles.push({ file, status: result.status });
+ continue;
+ }
const leakStatus = reportLeaks();
process.exit(result.status ?? (leakStatus || 1));
}
}
+if (failedFiles.length > 0) {
+ console.error(`[test] ${failedFiles.length} of ${testFiles.length} file(s) failed:`);
+ for (const { file, status } of failedFiles)
+ console.error(` ${file} (exit ${status ?? 'signal'})`);
+ reportLeaks();
+ process.exit(failedFiles[0].status || 1);
+}
+
const leakStatus = reportLeaks();
if (leakStatus !== 0) process.exit(leakStatus);
diff --git a/scripts/smoke-moss-cli.mjs b/scripts/smoke-moss-cli.mjs
index 22d3715e..85d77fce 100644
--- a/scripts/smoke-moss-cli.mjs
+++ b/scripts/smoke-moss-cli.mjs
@@ -21,7 +21,13 @@ function run(command, args, options = {}) {
shell: options.shell ?? (process.platform === 'win32' && /\.cmd$/i.test(command)),
});
if (result.status !== 0) {
- const rendered = [`$ ${command} ${args.join(' ')}`, result.stdout, result.stderr]
+ const rendered = [
+ `$ ${command} ${args.join(' ')}`,
+ result.error ? `launch error: ${result.error.name}: ${result.error.message}` : null,
+ `status: ${String(result.status)}; signal: ${result.signal ?? 'none'}`,
+ result.stdout,
+ result.stderr,
+ ]
.filter(Boolean)
.join('\n');
throw new Error(rendered);
@@ -138,7 +144,7 @@ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-cli-smoke-'));
try {
log('building the CLI');
- run('npm', ['run', 'build'], { stdio: 'inherit' });
+ run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'build'], { stdio: 'inherit' });
if (!fs.existsSync(cliEntry)) throw new Error('dist/cli.js was not produced by the build');
log('checking moss --version / --help');
diff --git a/src/cli-main.ts b/src/cli-main.ts
index 4b4c3899..748e2146 100644
--- a/src/cli-main.ts
+++ b/src/cli-main.ts
@@ -44,6 +44,7 @@ import {
formatUserPromptHookContext,
setLifecycleHookRunner,
} from './cli/hooks.js';
+import { hookEventZhName } from './cli/hook-event-names.js';
import {
ancestorRoutingIgnoredLine,
resolveFolderTrust,
@@ -928,6 +929,8 @@ async function main() {
deviceTrust: resolvedConfig.permissions.deviceTrust,
trustedDevices: resolvedConfig.permissions.trustedDevices,
detailMode: resolveCliDetailMode(argv),
+ // O94: -p/--print must auto-deny protected paths even when stdin is a TTY.
+ printMode: parsedArgs.print === true,
});
const configPreHook = configuredHooks.onBeforeToolExec;
const onBeforeToolExec: AgentHooks['onBeforeToolExec'] = (req) =>
@@ -946,7 +949,12 @@ async function main() {
if (submitted.blocked) {
return {
approved: false,
- reason: submitted.reason ?? 'Blocked by UserPromptSubmit hook',
+ // O91: the zh fallback names the hook in Chinese; en keeps the id.
+ reason:
+ submitted.reason ??
+ (isZhLocale()
+ ? `已拦截:${hookEventZhName('UserPromptSubmit')}钩子`
+ : 'Blocked by UserPromptSubmit hook'),
};
}
if (submitted.extraContext) {
diff --git a/src/cli/approval.ts b/src/cli/approval.ts
index fe5c199c..67dd845e 100644
--- a/src/cli/approval.ts
+++ b/src/cli/approval.ts
@@ -18,6 +18,16 @@ import {
} from '../safety/device-trust.js';
import { assertSandboxPath } from '../safety/sandbox-paths.js';
import { sanitizeSecrets } from '../safety/secret-sanitizer.js';
+import { findProtectedPathHits } from '../safety/protected-paths.js';
+import {
+ mossConfigDirs,
+ protectedGrantKey,
+ protectedPathDeniedReason,
+ protectedPathHeadlessNotice,
+ protectedPathHeadlessReason,
+ protectedPathStringPrompt,
+ protectedPathView,
+} from './protected-path-prompt.js';
import { recordDevicePolicyDecision } from './device-policy-log.js';
import { deviceDestructivePrompt } from './device-safety-prompt.js';
import { formatHeadlessBlockNotice, formatHeadlessBlockReason, isZhLocale } from './cli-locale.js';
@@ -25,6 +35,7 @@ import {
normalizeSafetyModeConfig,
loadConfigFile,
saveConfigFile,
+ resolveConfigDir,
type ConfigApprovalPolicy,
} from './config.js';
import { deriveEngineQuantas, type CliInteractionMode } from './interaction-mode.js';
@@ -151,6 +162,14 @@ export interface CliToolApprovalOptions {
* Safety checks still apply to trusted tools.
*/
persistTrust?: boolean;
+
+ /**
+ * O94: CLI was started with `-p` / `--print`. Protected-path writes must
+ * deny immediately without prompting — even when stdin is a TTY (interactive
+ * terminals still set isTTY under `moss -p`). Prefer this flag over reading
+ * argv; argv is a fallback when the flag was not wired.
+ */
+ printMode?: boolean;
}
/** A CLI policy hook whose interactive asker can be scoped by an embedding host. @beta */
@@ -212,6 +231,23 @@ export function resolveCliSafetyMode(
return 'full-access';
}
+/**
+ * True when CLI argv includes `-p` / `--print` (stops at `--`).
+ * Used so protected-path deny does not depend only on `stdin.isTTY`.
+ */
+export function argvRequestsPrintMode(argv: readonly string[] = process.argv.slice(2)): boolean {
+ for (const arg of argv) {
+ if (arg === '--') break;
+ if (arg === '-p' || arg === '--print') return true;
+ }
+ return false;
+}
+
+/** O94: `-p`/`--print` option or argv — deny protected writes without prompting. */
+function isApprovalPrintMode(options: CliToolApprovalOptions): boolean {
+ return options.printMode === true || argvRequestsPrintMode();
+}
+
function inferSideEffectClass(tool: Tool): ToolSideEffectClass {
const explicit = tool.metadata?.sideEffectClass;
if (explicit) return explicit;
@@ -1074,6 +1110,8 @@ export function createCliToolApprovalHook(
// edit family is a workspace-wide grant, design §3.4).
const sessionAllowRules = new Set();
const sessionTrustedWorkspaces = new Set();
+ /** O94: "always for this path" grants — session only, never persisted. */
+ const sessionProtectedGrants = new Set();
/** `${deviceKey}\0${scopeId}` — `a` trusts one command scope, not the whole tier. */
const sessionTrustedScopes = new Set();
const workspaceRoot = workspaceTrustRoot(options.workspaceDir);
@@ -1208,6 +1246,60 @@ export function createCliToolApprovalHook(
// ── the permission decision order (single implementation) ──
const outcome = resolvePermissionDecision(decisionInput, resolvedRules);
+ // ── O94: protected paths (.git/, ~/.ssh, shell rc, .env*, Moss config)
+ // need one confirmation in EVERY mode, full included; -p denies. Deny
+ // rules and the plan/read-only blocks above still win.
+ if (outcome.decision !== 'deny' && outcome.decision !== 'block') {
+ const protectedHits = findProtectedPathHits(
+ tool.name,
+ request.input,
+ {
+ cwd: options.workspaceDir || workspaceRoot,
+ workspaceRoot,
+ configDirs: mossConfigDirs(resolveConfigDir(env), env),
+ env,
+ },
+ sideEffect === 'readonly'
+ ).filter((hit) => !sessionProtectedGrants.has(protectedGrantKey(hit)));
+ const firstHit = protectedHits[0];
+ if (firstHit) {
+ const zh = isZhLocale();
+ const protectedAsker = instanceAsker ?? interactiveAsker;
+ const protectedViewAsker = getCliApprovalViewAsker();
+ // O94: `-p`/`--print` must deny even when stdin is a TTY (QA BLOCK on
+ // #93). Non-TTY with no asker stays the non-interactive deny path.
+ if (
+ isApprovalPrintMode(options) ||
+ (!process.stdin.isTTY && protectedAsker === null && !protectedViewAsker)
+ ) {
+ console.error(protectedPathHeadlessNotice(firstHit, zh));
+ return settle({ approved: false, reason: protectedPathHeadlessReason(firstHit, zh) });
+ }
+ const view = protectedPathView(tool.name, protectedHits, zh);
+ let protectedAnswer: string;
+ if (protectedViewAsker) {
+ protectedAnswer = await protectedViewAsker(view, request.abortSignal);
+ } else {
+ protectedAnswer = (
+ await (protectedAsker ?? defaultAskUser)(
+ protectedPathStringPrompt(view, zh),
+ request.abortSignal
+ )
+ )
+ .trim()
+ .toLowerCase();
+ }
+ if (protectedAnswer === 'a' || protectedAnswer === 'always') {
+ for (const hit of protectedHits) sessionProtectedGrants.add(protectedGrantKey(hit));
+ return settle({ approved: true });
+ }
+ if (protectedAnswer === 'y' || protectedAnswer === 'yes') {
+ return settle({ approved: true });
+ }
+ return settle({ approved: false, reason: protectedPathDeniedReason(firstHit, zh) });
+ }
+ }
+
switch (outcome.decision) {
case 'deny':
return settle({ approved: false, reason: `Tool "${tool.name}" ${outcome.reason}.` });
@@ -1258,7 +1350,8 @@ export function createCliToolApprovalHook(
: undefined;
const asker = instanceAsker ?? interactiveAsker;
- if (!process.stdin.isTTY && asker === null) {
+ // Same `-p` rule as O94: print mode cannot prompt even on a TTY.
+ if ((isApprovalPrintMode(options) || !process.stdin.isTTY) && asker === null) {
if (options.detailMode !== 'quiet' && !headlessNoticeShown) {
console.error(
deviceGatedAsk && isZhLocale()
diff --git a/src/cli/cli-locale.ts b/src/cli/cli-locale.ts
index 73306878..1f68fad0 100644
--- a/src/cli/cli-locale.ts
+++ b/src/cli/cli-locale.ts
@@ -354,14 +354,14 @@ export function formatInteractionModeNotice(mode: string, locale?: string): stri
plan: 'plan(只读演练,不改文件、不跑命令)· /mode 可切换',
acceptEdits: 'accept-edits(自动通过本文件夹内的文件编辑;终端命令仍需确认)· /mode 可切换',
manual: 'manual(逐条确认)· /mode 可切换',
- full: 'full(完全执行,不逐条询问)· /mode 可切换',
+ full: 'full(多数操作直接执行;受保护路径仍会确认一次)· /mode 可切换',
}
: {
plan: 'plan (dry-run: no edits, no commands) · /mode to change',
acceptEdits:
'accept-edits (file edits in this folder auto-approve; shell commands still ask) · /mode to change',
manual: 'manual (asks before each action) · /mode to change',
- full: 'full (no approval prompts) · /mode to change',
+ full: 'full (runs most actions; protected paths still ask once) · /mode to change',
};
const label = labels[mode] ?? mode;
return zh ? `[moss] 交互模式:${label}` : `[moss] Interaction mode: ${label}`;
diff --git a/src/cli/commands/task-verify.ts b/src/cli/commands/task-verify.ts
index 37709e3b..ab7c8120 100644
--- a/src/cli/commands/task-verify.ts
+++ b/src/cli/commands/task-verify.ts
@@ -11,22 +11,13 @@ import {
listTaskStateSnapshots,
} from '../../core/task/task-store.js';
import { appendTaskRecord, listTaskRecords } from '../../core/task-runtime/artifacts.js';
+import { acceptanceCommandFromEvents } from '../../core/task/acceptance-authority.js';
export interface TaskVerifyResult {
exitCode: number;
summary: string;
}
-function acceptanceCommandFromEvents(
- events: readonly { data?: Record }[]
-): string | undefined {
- for (let i = events.length - 1; i >= 0; i -= 1) {
- const command = events[i]?.data?.acceptanceCommand;
- if (typeof command === 'string' && command.trim()) return command.trim();
- }
- return undefined;
-}
-
export async function verifyTaskOnce(
workspace: string,
options: {
@@ -66,6 +57,8 @@ export async function verifyTaskOnce(
...(command ? { command } : {}),
});
const verdict = await provider.evaluate(snapshot.taskId);
+ const alreadySettled =
+ !wasAccepted && (await getTaskStateSnapshot(workspace, snapshot.taskId))?.phase === 'accepted';
// acceptance_* is illegal from failed/abandoned (only task_resumed leaves
// those phases). Resume into executing, then let verification open.
if (wasAccepted) {
@@ -73,22 +66,26 @@ export async function verifyTaskOnce(
await appendTaskEvent(workspace, snapshot.taskId, 'verification_started', {
reason: '/task verify',
});
- } else if (snapshot.phase === 'failed' || snapshot.phase === 'abandoned') {
+ } else if (!alreadySettled && (snapshot.phase === 'failed' || snapshot.phase === 'abandoned')) {
await appendTaskEvent(workspace, snapshot.taskId, 'task_resumed', {
reason: '/task verify',
});
- } else if (['draft', 'understanding', 'planning', 'blocked'].includes(snapshot.phase)) {
+ } else if (
+ !alreadySettled &&
+ ['draft', 'understanding', 'planning', 'blocked'].includes(snapshot.phase)
+ ) {
// acceptance_* is only valid from a verification phase. A draft has to
// enter execution first; emitAcceptanceLifecycle opens verification.
await appendTaskEvent(workspace, snapshot.taskId, 'execution_started');
}
- await emitAcceptanceLifecycle(
- workspace,
- snapshot.taskId,
- verdict.passed,
- verdict.detail,
- verdict.source
- );
+ if (!alreadySettled)
+ await emitAcceptanceLifecycle(
+ workspace,
+ snapshot.taskId,
+ verdict.passed,
+ verdict.detail,
+ verdict.source
+ );
if (verdict.passed) {
return {
exitCode: 0,
diff --git a/src/cli/config-commands.ts b/src/cli/config-commands.ts
index 7f3f9993..6ae3af21 100644
--- a/src/cli/config-commands.ts
+++ b/src/cli/config-commands.ts
@@ -22,6 +22,8 @@ import {
type CliConfigOverrides,
type CliProviderPreset,
type ConfigFile,
+ parseReasoningBudget,
+ REASONING_BUDGET_VALUES,
} from './config.js';
import { errorMessage } from '../errors.js';
import { isZhLocale, parseLanguageSetting, uiText } from './cli-locale.js';
@@ -29,6 +31,7 @@ import { guessModelProvider, print, renderAuthStatus, sanitizeBaseUrl } from './
import { withoutSecret } from './config-snapshot.js';
import { parsePermissionRuleSpec } from './permission-rules.js';
import { parseCliInteractionMode } from './interaction-mode.js';
+import { untrustedFolderLine } from './workspace-trust.js';
function serializeResolvedConfig(
resolved: ReturnType
@@ -75,6 +78,8 @@ function serializeResolvedConfig(
maxAgentTurnsSource: resolved.maxAgentTurnsSource,
contextTokens: resolved.contextTokens,
contextTokensSource: resolved.contextTokensSource,
+ reasoningBudget: resolved.reasoningBudget ?? 'adaptive',
+ reasoningBudgetSource: resolved.reasoningBudgetSource ?? 'default',
compactionSettings: { ...resolved.compactionSettings },
compactionSettingsSource: resolved.compactionSettingsSource,
configWarnings: auditResolvedCliConfig(resolved),
@@ -378,7 +383,7 @@ export const MOSS_ENV_REFERENCE: ReadonlyArray<{ group: string; vars: readonly s
group: 'providers, models & fallback',
vars: [
'MOSS_BEST_OF_N',
- 'MOSS_REASONING_BUDGET',
+ 'MOSS_REASONING_BUDGET (off|adaptive|high; overrides config agent.reasoningBudget)',
'MOSS_MODEL_BALANCED',
'MOSS_MODEL_CHEAP',
'MOSS_MODEL_STRONG',
@@ -416,6 +421,7 @@ export const MOSS_ENV_REFERENCE: ReadonlyArray<{ group: string; vars: readonly s
'MOSS_TOOL_LOOP_EDIT_PATH_FAILURE_LIMIT',
'MOSS_TOOL_LOOP_FAILURE_LIMIT',
'MOSS_TOOL_LOOP_IDENTICAL_LIMIT',
+ 'MOSS_TOOL_LOOP_SAME_COMMAND_LIMIT (same-command consecutive failure limit; default 3)',
'MOSS_TOOL_LOOP_SINGLE_TOOL_LIMIT',
'MOSS_TOOL_LOOP_TOTAL_LIMIT',
'MOSS_TOOL_NAME',
@@ -435,6 +441,7 @@ export const MOSS_ENV_REFERENCE: ReadonlyArray<{ group: string; vars: readonly s
'MOSS_TUI_RENDERER_CONFIG',
'MOSS_TUI_THEME',
'MOSS_LANG (auto|en|zh; process env or --lang; a project .env cannot set this; flag > env > config language > system locale)',
+ 'MOSS_LOCALE (optional zh locale hint for protected-path notices; falls back to system locale)',
'MOSS_NO_COLOR',
'MOSS_LOG_LEVEL',
'MOSS_LOG_JSON',
@@ -476,6 +483,8 @@ const ENV_GROUP_ZH: Record = {
};
const ENV_NOTE_ZH: Record = {
+ 'off|adaptive|high; overrides config agent.reasoningBudget':
+ 'off|adaptive|high;优先于配置 agent.reasoningBudget',
'process env only; a project .env cannot set this': '仅进程环境;项目 .env 不能设置',
'process env or --config-file; a project .env cannot set this':
'进程环境或 --config-file;项目 .env 不能设置',
@@ -516,6 +525,9 @@ const ENV_NOTE_ZH: Record = {
'auto|en|zh; process env or --lang; a project .env cannot set this; flag > env > config language > system locale':
'auto|en|zh;进程环境或 --lang;项目 .env 不能设置;优先级为参数、环境变量、配置 language、系统区域',
'checkout for moss update; --dir wins': 'moss update 要升级的克隆;--dir 优先',
+ 'same-command consecutive failure limit; default 3': '同命令连续失败上限;默认 3',
+ 'optional zh locale hint for protected-path notices; falls back to system locale':
+ '可选;受保护路径提示的中文区域启发,未设时回退到系统语言环境',
};
function localizeEnvEntry(entry: string): string {
@@ -596,6 +608,13 @@ export function renderConfigHelp(zh = false): string {
' moss config set guardrails.input.redactPatterns SECRET=[^\\\\s]+',
' moss config set agent.maxTurns 96',
' moss config set agent.contextTokens 200000',
+ ' # agent.reasoningBudget — Moss adaptive-reasoning knob (off|adaptive|high).',
+ ' # high forces reasoning=high on outbound calls; adaptive may escalate to high under',
+ " # verify/turn-error pressure; off means do NOT escalate (keeps the model's own",
+ ' # reasoning level — it does not disable provider-native thinking).',
+ ' # Higher budgets spend more tokens, take longer, and cost more.',
+ ' # An untrusted project can only lower it; a raise is ignored with a notice.',
+ ' moss config set agent.reasoningBudget high # off|adaptive|high',
' moss config set agent.compaction.reserveTokens 20000',
' moss config set rdkDocs false',
' moss config set rdkDocs.package ../rdk-docs-mcp',
@@ -649,6 +668,13 @@ function renderConfigHelpZh(): string {
' `moss config set baseUrl https://your-gateway.example` # /v1 可写可不写(https://主机/v1 也可以);不必带 /chat/completions',
' `moss config set rdkDocs false`',
' `moss config set rdkDocs.package ../rdk-docs-mcp`',
+ ' `moss config set agent.maxTurns 96`',
+ ' `moss config set agent.contextTokens 200000`',
+ ' # agent.reasoningBudget — Moss 自适应推理档位(可选值 off|adaptive|high)。',
+ ' # high:出站推理档固定为 high;adaptive:验证失败或回合出错时可能升到 high;',
+ ' # off:不升档,保留模型自身推理档(不是关掉模型自带思考)。越高越慢、费用越高。',
+ ' # 未信任的项目只能把它调低;调高会被忽略并提示。',
+ ' `moss config set agent.reasoningBudget high` # 可选值:off|adaptive|high',
' `moss config set language auto|en|zh` # 只写用户配置(不能 --project,项目 .env 也不能设置)',
' # 界面语言优先级:--lang > MOSS_LANG > `language` > 系统区域。auto 仅在区域以 zh 开头时用中文。',
].join('\n');
@@ -659,7 +685,15 @@ export function runConfigShow(
options: { json?: boolean; overrides?: CliConfigOverrides } = {}
): void {
const overrides = options.overrides ?? {};
+ // Tip when an untrusted project tried to set routing fields (incl. reasoningBudget).
+ const loaded = loadCliConfigFile(process.env, process.argv.slice(2), startDir);
+ const ignored = loaded.ignoredProjectRouting ?? [];
+ if (ignored.length > 0) {
+ const line = untrustedFolderLine(ignored, isZhLocale());
+ if (line) console.error(line);
+ }
if (options.json) {
+ // Pass undefined so renderConfigJson reloads with fieldSources intact.
standardOutput.write(`${renderConfigJson(undefined, process.env, startDir, overrides)}\n`);
return;
}
@@ -901,8 +935,8 @@ function buildProjectConfigTemplate(): ConfigFile {
function supportedConfigKeys(): string {
return uiText(
- 'Supported keys — model: provider, model, baseUrl, apiKey, apiKeyEnv; operational: profile, workspace, safetyMode, approvalPolicy, trustedTools, deniedTools, permissions.defaultMode, permissions.allow, permissions.ask, permissions.deny, permissions.deviceTrust, permissions.trustedDevices, rdkDocs, rdkDocs.enabled, rdkDocs.package, promptCache, promptCacheDebug, guardrails.input.blockPatterns, guardrails.input.redactPatterns, guardrails.output.blockPatterns, guardrails.output.redactPatterns, agent.maxTurns, agent.contextTokens, agent.compaction.reserveTokens, agent.compaction.keepRecentTokens, language',
- '支持的键:provider、model、baseUrl、apiKey、apiKeyEnv、profile、workspace、safetyMode、approvalPolicy、trustedTools、deniedTools、permissions.defaultMode、permissions.allow、permissions.ask、permissions.deny、permissions.deviceTrust、permissions.trustedDevices、rdkDocs、rdkDocs.enabled、rdkDocs.package、promptCache、promptCacheDebug、guardrails.input.blockPatterns、guardrails.input.redactPatterns、guardrails.output.blockPatterns、guardrails.output.redactPatterns、agent.maxTurns、agent.contextTokens、agent.compaction.reserveTokens、agent.compaction.keepRecentTokens、language'
+ 'Supported keys — model: provider, model, baseUrl, apiKey, apiKeyEnv; operational: profile, workspace, safetyMode, approvalPolicy, trustedTools, deniedTools, permissions.defaultMode, permissions.allow, permissions.ask, permissions.deny, permissions.deviceTrust, permissions.trustedDevices, rdkDocs, rdkDocs.enabled, rdkDocs.package, promptCache, promptCacheDebug, guardrails.input.blockPatterns, guardrails.input.redactPatterns, guardrails.output.blockPatterns, guardrails.output.redactPatterns, agent.maxTurns, agent.contextTokens, agent.reasoningBudget, agent.compaction.reserveTokens, agent.compaction.keepRecentTokens, language',
+ '支持的键:provider、model、baseUrl、apiKey、apiKeyEnv、profile、workspace、safetyMode、approvalPolicy、trustedTools、deniedTools、permissions.defaultMode、permissions.allow、permissions.ask、permissions.deny、permissions.deviceTrust、permissions.trustedDevices、rdkDocs、rdkDocs.enabled、rdkDocs.package、promptCache、promptCacheDebug、guardrails.input.blockPatterns、guardrails.input.redactPatterns、guardrails.output.blockPatterns、guardrails.output.redactPatterns、agent.maxTurns、agent.contextTokens、agent.reasoningBudget、agent.compaction.reserveTokens、agent.compaction.keepRecentTokens、language'
);
}
@@ -1260,6 +1294,29 @@ function applyConfigSetPair(
} catch (err) {
return { ok: false, messages: [errorMessage(err)] };
}
+ } else if (key === 'agent.reasoningBudget') {
+ if (!value.trim()) {
+ return {
+ ok: false,
+ messages: [
+ isZhLocale()
+ ? `agent.reasoningBudget:值不能为空。可选值:${REASONING_BUDGET_VALUES.join('、')}`
+ : `agent.reasoningBudget: value must not be empty. Supported values: ${REASONING_BUDGET_VALUES.join(', ')}`,
+ ],
+ };
+ }
+ const budget = parseReasoningBudget(value);
+ if (!budget) {
+ return {
+ ok: false,
+ messages: [
+ isZhLocale()
+ ? `agent.reasoningBudget 取值无效:${value}。可选值:${REASONING_BUDGET_VALUES.join('、')}`
+ : `Unsupported agent.reasoningBudget value: ${value}. Supported values: ${REASONING_BUDGET_VALUES.join(', ')}`,
+ ],
+ };
+ }
+ next.agent = { ...next.agent, reasoningBudget: budget };
} else if (key === 'agent.maxTurns' || key === 'agent.contextTokens') {
const parsed = parseConfigPositiveInteger(value, key);
if (!parsed.ok) {
@@ -1322,9 +1379,17 @@ export function runConfigSet(args: string[], startDir = process.cwd()): void {
return;
}
if (!value) {
- print(
- `config ${key}: value must not be empty. Run \`moss config --help\` for supported keys and usage.`
- );
+ if (key === 'agent.reasoningBudget') {
+ print(
+ isZhLocale()
+ ? `config agent.reasoningBudget:值不能为空。可选值:${REASONING_BUDGET_VALUES.join('、')}`
+ : `config agent.reasoningBudget: value must not be empty. Supported values: ${REASONING_BUDGET_VALUES.join(', ')}`
+ );
+ } else {
+ print(
+ `config ${key}: value must not be empty. Run \`moss config --help\` for supported keys and usage.`
+ );
+ }
process.exitCode = 1;
return;
}
@@ -1485,6 +1550,9 @@ export function runConfigUnset(args: string[], startDir = process.cwd()): void {
} else if (key === 'agent.contextTokens') {
next.agent = { ...current.agent };
delete next.agent.contextTokens;
+ } else if (key === 'agent.reasoningBudget') {
+ next.agent = { ...current.agent };
+ delete next.agent.reasoningBudget;
} else if (key === 'agent.compaction.reserveTokens') {
next.agent = { ...current.agent, compaction: { ...current.agent?.compaction } };
delete next.agent.compaction?.reserveTokens;
diff --git a/src/cli/config-errors.ts b/src/cli/config-errors.ts
index 0aeb8507..7fa72ce2 100644
--- a/src/cli/config-errors.ts
+++ b/src/cli/config-errors.ts
@@ -8,6 +8,21 @@ export class CliConfigFileError extends Error {
}
}
+/**
+ * O89: a config file error whose displayed message is fully localized. Under
+ * the zh UI the base class's English frame (`Invalid moss config at …`) would
+ * itself put non-whitelisted English words into the copy, so this subclass
+ * replaces the whole message while keeping `name`, `configPath`, and the
+ * `instanceof CliConfigFileError` checks cli-main and the exit-code mapping
+ * rely on.
+ */
+export class CliLocalizedConfigFileError extends CliConfigFileError {
+ constructor(configPath: string, localizedMessage: string) {
+ super(configPath, localizedMessage);
+ this.message = localizedMessage;
+ }
+}
+
export class CliConfigWriteError extends Error {
readonly configPath: string;
diff --git a/src/cli/config-snapshot.ts b/src/cli/config-snapshot.ts
index 4aa192a2..deb336bf 100644
--- a/src/cli/config-snapshot.ts
+++ b/src/cli/config-snapshot.ts
@@ -88,6 +88,7 @@ const ZH_FIELD: Record = {
guardrails: '护栏',
'max turns': '最大轮次',
'context tokens': '上下文词元',
+ 'reasoning budget': '推理强度',
'max output': '最大输出',
compaction: '压缩',
'model tiers': '模型分级',
@@ -276,6 +277,11 @@ const FIELDS = {
'context tokens',
(c: ResolvedCliConfig) => `${c.contextTokens} ${sourceNote(c.contextTokensSource)}`,
],
+ reasoningBudget: [
+ 'reasoning budget',
+ (c: ResolvedCliConfig) =>
+ `${c.reasoningBudget ?? 'adaptive'} ${sourceNote(c.reasoningBudgetSource ?? 'default')}`,
+ ],
maxOutput: [
'max output',
(c: ResolvedCliConfig) =>
diff --git a/src/cli/config.ts b/src/cli/config.ts
index b267afa5..badaaffe 100644
--- a/src/cli/config.ts
+++ b/src/cli/config.ts
@@ -14,7 +14,11 @@ import {
} from '../utils/safe-cwd.js';
import { errorMessage, throwMoss, ErrorCode } from '../errors.js';
import { maybeDecryptApiKeyInConfig, maybeEncryptApiKeyInConfig } from './config-api-key-crypto.js';
-import { CliConfigFileError, CliConfigWriteError } from './config-errors.js';
+import {
+ CliConfigFileError,
+ CliConfigWriteError,
+ CliLocalizedConfigFileError,
+} from './config-errors.js';
import { writeConfigFileAtomic } from './config-durable-write.js';
import {
type CliProviderPreset,
@@ -51,7 +55,7 @@ import {
recordDotenvOrigin,
} from '../utils/startup-env.js';
import { officialEnvOffers } from './env-credentials.js';
-import { setupCopy } from './cli-locale.js';
+import { isZhLocale, setupCopy } from './cli-locale.js';
import { isDeviceTrustEnv, parseDeviceTrustList } from '../safety/device-trust.js';
import type { PricingConfig } from './model-pricing.js';
import type { StatusLineConfig } from './status-line.js';
@@ -589,6 +593,35 @@ export function resolveProjectConfigPath(startDir = safeProcessCwd(), maxHops =
return null;
}
+/**
+ * O89: a Moss-format hook whose `command` is not a string (object, array,
+ * number, boolean, null) used to load fine and only surface at run time, where
+ * the failure notice printed `Command: [object Object]` with no clue which file
+ * was broken. Reject it at load/parse time instead, naming the config file, the
+ * event, and the entry index. A missing `command` keeps its existing behavior.
+ */
+function assertHookCommandsAreStrings(hooks: unknown, configPath: string): void {
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return;
+ const byEvent = hooks as Partial>;
+ for (const event of HOOK_EVENT_KEYS) {
+ const list = byEvent[event];
+ if (!Array.isArray(list)) continue;
+ for (let index = 0; index < list.length; index += 1) {
+ const entry = list[index];
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
+ const command = (entry as Record).command;
+ if (command === undefined || typeof command === 'string') continue;
+ if (isZhLocale()) {
+ throw new CliLocalizedConfigFileError(
+ configPath,
+ `配置文件 ${configPath} 无效:钩子 ${event}[${index}] 的 command 必须是字符串。`
+ );
+ }
+ throw new CliConfigFileError(configPath, `hooks.${event}[${index}].command must be a string`);
+ }
+ }
+}
+
export function loadConfigFile(configPath = resolveConfigPath()): ConfigFile {
if (!fs.existsSync(configPath)) return {};
let raw: string;
@@ -609,6 +642,9 @@ export function loadConfigFile(configPath = resolveConfigPath()): ConfigFile {
throw new CliConfigFileError(configPath, 'expected a JSON object');
}
const config = parsed as ConfigFile;
+ // O89: a non-string hook command must never reach runHookCommand /
+ // formatHookFailure, so it fails here with a localized, file-naming error.
+ assertHookCommandsAreStrings(config.hooks, configPath);
const configDir = path.dirname(configPath);
return maybeDecryptApiKeyInConfig(config, configDir, configPath);
@@ -660,6 +696,30 @@ function mergeModelOutputConfigs(
return Object.keys(merged).length > 0 ? merged : undefined;
}
+export const REASONING_BUDGET_VALUES = ['off', 'adaptive', 'high'] as const;
+export type ReasoningBudget = (typeof REASONING_BUDGET_VALUES)[number];
+
+export function parseReasoningBudget(value: unknown): ReasoningBudget | undefined {
+ if (typeof value !== 'string') return undefined;
+ const normalized = value.toLowerCase().trim();
+ return (REASONING_BUDGET_VALUES as readonly string[]).includes(normalized)
+ ? (normalized as ReasoningBudget)
+ : undefined;
+}
+
+const warnedInvalidReasoningBudgetEnv = new Set();
+
+/** MOSS_LANG-style: warn once per process for an invalid MOSS_REASONING_BUDGET. */
+function warnInvalidReasoningBudgetEnv(raw: string): void {
+ if (warnedInvalidReasoningBudgetEnv.has(raw)) return;
+ warnedInvalidReasoningBudgetEnv.add(raw);
+ const zh = isZhLocale();
+ const msg = zh
+ ? `[moss] MOSS_REASONING_BUDGET 只能是 off、adaptive 或 high,收到「${raw}」,已回退到配置或默认 adaptive。`
+ : `[moss] MOSS_REASONING_BUDGET must be off|adaptive|high, got "${raw}"; falling back to config or default adaptive.`;
+ console.error(msg);
+}
+
function mergeAgentRuntimeConfig(
userAgent: ConfigFile['agent'],
projectAgent: ConfigFile['agent']
@@ -715,6 +775,26 @@ export function listProjectRoutingConfigFields(project: ConfigFile): string[] {
return ignored;
}
+const REASONING_BUDGET_RANK: Record = { off: 0, adaptive: 1, high: 2 };
+
+/**
+ * An untrusted project may only lower reasoning intensity (off < adaptive <
+ * high). The user's effective value is the user config value, or the
+ * runtime default `adaptive` when unset. Same shape as projectProfileLoosens.
+ */
+function projectReasoningBudgetRaises(user: ConfigFile, project: ConfigFile): boolean {
+ const projectBudget = parseReasoningBudget(project.agent?.reasoningBudget);
+ if (!projectBudget) return false;
+ const userBudget = parseReasoningBudget(user.agent?.reasoningBudget) ?? 'adaptive';
+ return REASONING_BUDGET_RANK[projectBudget] > REASONING_BUDGET_RANK[userBudget];
+}
+
+function omitRaisingProjectReasoningBudget(user: ConfigFile, project: ConfigFile): ConfigFile {
+ if (!project.agent || !projectReasoningBudgetRaises(user, project)) return project;
+ const { reasoningBudget: _rb, ...rest } = project.agent;
+ return { ...project, agent: Object.keys(rest).length > 0 ? rest : undefined };
+}
+
function omitProjectRoutingConfig(project: ConfigFile): ConfigFile {
const next: ConfigFile = { ...project };
for (const key of PROJECT_ROUTING_CONFIG_KEYS) delete next[key];
@@ -858,6 +938,11 @@ export function fieldSourcesFor(
mark('promptCache', user.promptCache !== undefined, project.promptCache !== undefined, true);
mark('guardrails', user.guardrails !== undefined, project.guardrails !== undefined, true);
mark('agent.maxTurns', user.agent?.maxTurns !== undefined, project.agent?.maxTurns !== undefined);
+ mark(
+ 'agent.reasoningBudget',
+ user.agent?.reasoningBudget !== undefined,
+ project.agent?.reasoningBudget !== undefined
+ );
mark(
'agent.contextTokens',
user.agent?.contextTokens !== undefined,
@@ -1216,12 +1301,18 @@ export function loadCliConfigFile(
const rawProject = loadConfigFile(projectConfigPath);
const trusted = projectRoutingTrusted(env, argv, startDir, options?.trustProjectRouting);
const ignoredProjectRouting = trusted ? [] : listProjectRoutingConfigFields(rawProject);
+ if (!trusted && projectReasoningBudgetRaises(userConfig, rawProject)) {
+ ignoredProjectRouting.push('reasoningBudget');
+ }
const droppedProjectPermissions = trusted
? []
: listDroppedProjectPermissionFields(userConfig, rawProject);
const projectConfig = trusted
? rawProject
- : omitLooseningProjectPermissions(userConfig, omitProjectRoutingConfig(rawProject));
+ : omitRaisingProjectReasoningBudget(
+ userConfig,
+ omitLooseningProjectPermissions(userConfig, omitProjectRoutingConfig(rawProject))
+ );
const merged = mergeConfigFiles(projectConfig, userConfig, options);
const sources = fieldSourcesFor(userConfig, projectConfig);
const keyLayer =
@@ -1535,6 +1626,9 @@ export interface ResolvedCliConfig {
bestOfN?: number;
/** v0.10 W4 (config agent.reasoningBudget + MOSS_REASONING_BUDGET env). */
reasoningBudget?: 'off' | 'adaptive' | 'high';
+ reasoningBudgetSource?: string;
+ /** Set when MOSS_REASONING_BUDGET was present but not off|adaptive|high. */
+ invalidReasoningBudgetEnv?: string;
/** v0.12 (agent.modelTiers + MOSS_MODEL_CHEAP/BALANCED/STRONG env). */
modelTiers?: { cheap?: string; balanced?: string; strong?: string };
modelTiersSource?: string;
@@ -1605,9 +1699,21 @@ export function auditResolvedCliConfig(
| 'deniedTools'
| 'deniedToolsSource'
| 'permissions'
- >
+ > &
+ Partial>
): CliConfigAuditWarning[] {
const warnings: CliConfigAuditWarning[] = [];
+ if (config.invalidReasoningBudgetEnv) {
+ warnings.push({
+ code: 'reasoningBudget.env_invalid',
+ severity: 'warn',
+ source: 'MOSS_REASONING_BUDGET',
+ message: uiText(
+ `MOSS_REASONING_BUDGET "${config.invalidReasoningBudgetEnv}" is not off|adaptive|high; using config or default adaptive`,
+ `MOSS_REASONING_BUDGET「${config.invalidReasoningBudgetEnv}」不是 off|adaptive|high;已回退到配置或默认 adaptive`
+ ),
+ });
+ }
// v0.26 audit re-check (PRD W1 告警重校): the old blanket auto-approval
// warning would fire for every factory-default user now that full is the
// default. The warning only fires when the auto-approval stance came from
@@ -2411,13 +2517,26 @@ export function resolveCliConfig(
apiKeyFromProjectEnv = true;
}
}
- const rawReasoningBudget = (env.MOSS_REASONING_BUDGET ?? '').toLowerCase().trim();
- const reasoningBudget =
- rawReasoningBudget === 'off' ||
- rawReasoningBudget === 'adaptive' ||
- rawReasoningBudget === 'high'
- ? rawReasoningBudget
- : activeConfig.agent?.reasoningBudget;
+ // Same precedence as agent.maxTurns / agent.contextTokens: a valid
+ // MOSS_REASONING_BUDGET env wins over config agent.reasoningBudget
+ // (project layer over user layer), then the runtime default (adaptive).
+ const rawReasoningBudgetEnv = env.MOSS_REASONING_BUDGET;
+ const envReasoningBudget = parseReasoningBudget(rawReasoningBudgetEnv);
+ const invalidReasoningBudgetEnv =
+ typeof rawReasoningBudgetEnv === 'string' &&
+ rawReasoningBudgetEnv.trim() !== '' &&
+ envReasoningBudget === undefined
+ ? rawReasoningBudgetEnv.trim()
+ : undefined;
+ if (invalidReasoningBudgetEnv) warnInvalidReasoningBudgetEnv(invalidReasoningBudgetEnv);
+ const configReasoningBudget = parseReasoningBudget(activeConfig.agent?.reasoningBudget);
+ const reasoningBudget = envReasoningBudget ?? configReasoningBudget;
+ const reasoningBudgetSource =
+ envReasoningBudget !== undefined
+ ? 'MOSS_REASONING_BUDGET'
+ : configReasoningBudget !== undefined
+ ? fileLayer('agent.reasoningBudget')
+ : 'default';
const runBudget =
budgetMaxTokens !== undefined ||
budgetMaxToolCalls !== undefined ||
@@ -2526,6 +2645,8 @@ export function resolveCliConfig(
...(runBudget ? { budget: runBudget } : {}),
...(bestOfN !== undefined ? { bestOfN } : {}),
...(reasoningBudget ? { reasoningBudget } : {}),
+ reasoningBudgetSource,
+ ...(invalidReasoningBudgetEnv ? { invalidReasoningBudgetEnv } : {}),
...(hasModelTiers ? { modelTiers, modelTiersSource } : { modelTiersSource }),
compactionSettingsSource,
configPath: configPaths?.configPath ?? resolveConfigPath(undefined, env),
diff --git a/src/cli/exit-codes.ts b/src/cli/exit-codes.ts
index b12c31b9..f97b3341 100644
--- a/src/cli/exit-codes.ts
+++ b/src/cli/exit-codes.ts
@@ -10,6 +10,8 @@ export enum ExitCode {
PROVIDER_UPSTREAM = 6,
SESSION = 7,
TOOL_EXECUTION = 8,
+ /** O98/O109: incomplete, blocked, verify_failed, or soft Unverified (claimed pass without a real check). */
+ INCOMPLETE = 9,
USER_ABORTED = 11,
INTERNAL = 12,
}
diff --git a/src/cli/hook-event-names.ts b/src/cli/hook-event-names.ts
new file mode 100644
index 00000000..b548b25b
--- /dev/null
+++ b/src/cli/hook-event-names.ts
@@ -0,0 +1,85 @@
+import type { HooksConfig } from './config.js';
+
+/**
+ * O91: user-visible Chinese display names for Moss-format hook events.
+ *
+ * Event ids are the machine surface — JSON config keys, TypeScript types, the
+ * `MOSS_HOOK_EVENT` env value — and stay English everywhere. This module only
+ * feeds user-visible copy (stderr `[hooks]` lines, blocked reasons,
+ * `formatHookFailure` heads, `/hooks` listings). A wording change is a
+ * table-only edit (plus the O91 spec expectations); it never changes hook
+ * behavior, config keys, or exit codes.
+ */
+
+/** Hook event ids exactly as they appear as `hooks` config keys. */
+export type MossHookEventId = keyof HooksConfig;
+
+/**
+ * The O91 copy table — the single source of zh hook event names. No value
+ * carries a 「钩子」 suffix (UX-final wording from specs/O91.md; the extras
+ * beyond the spec table follow the same pattern) — the sentence templates
+ * that consume a name append 「钩子」 themselves (「已拦截:停止前钩子未能
+ * 启动。」), so copy never doubles into 「钩子钩子」. Typed as a full
+ * `Record` so a new config event key without a
+ * display name is a compile error here (drift guard).
+ */
+export const HOOK_EVENT_ZH_NAMES: Readonly> = Object.freeze({
+ PreToolUse: '工具使用前',
+ PostToolUse: '工具使用后',
+ UserPromptSubmit: '提交用户提示前',
+ PermissionRequest: '权限请求时',
+ SessionStart: '会话开始时',
+ Stop: '停止前',
+ SubagentStop: '子代理停止前',
+ PreCompact: '压缩前',
+ PostCompact: '压缩后',
+ SessionEnd: '会话结束时',
+ Notification: '通知时',
+});
+
+/** Every event id, in `/hooks` listing order (same order as HOOK_EVENT_KEYS). */
+export const MOSS_HOOK_EVENT_IDS: readonly MossHookEventId[] = Object.freeze([
+ 'PreToolUse',
+ 'PostToolUse',
+ 'UserPromptSubmit',
+ 'PermissionRequest',
+ 'SessionStart',
+ 'Stop',
+ 'SubagentStop',
+ 'PreCompact',
+ 'PostCompact',
+ 'SessionEnd',
+ 'Notification',
+]);
+
+const KNOWN_EVENT_IDS: ReadonlySet = new Set(MOSS_HOOK_EVENT_IDS);
+
+/** Type guard for arbitrary strings (e.g. keys of a user-authored hooks object). */
+export function isMossHookEventId(value: string): value is MossHookEventId {
+ return KNOWN_EVENT_IDS.has(value);
+}
+
+/** The zh display name for a known event id (the table is exhaustive). */
+export function hookEventZhName(event: MossHookEventId): string {
+ return HOOK_EVENT_ZH_NAMES[event];
+}
+
+/** Locale-aware label: the zh display name when `zh`, the English id otherwise. */
+export function hookEventDisplayName(event: MossHookEventId, zh: boolean): string {
+ return zh ? hookEventZhName(event) : event;
+}
+
+/**
+ * The `/hooks` empty-state catalog: which event keys the config accepts.
+ * en lists the bare keys (they are the literals the user types); zh pairs each
+ * key with its Chinese display name so no bare id is the only label.
+ */
+export function hookEventCatalogLines(zh: boolean, indent = ' '): string[] {
+ const labels = MOSS_HOOK_EVENT_IDS.map((id) => (zh ? `${id}(${HOOK_EVENT_ZH_NAMES[id]})` : id));
+ const perLine = zh ? 2 : 4;
+ const lines: string[] = [];
+ for (let i = 0; i < labels.length; i += perLine) {
+ lines.push(`${indent}${labels.slice(i, i + perLine).join(' · ')}`);
+ }
+ return lines;
+}
diff --git a/src/cli/hooks.ts b/src/cli/hooks.ts
index 85dc9237..10b84375 100644
--- a/src/cli/hooks.ts
+++ b/src/cli/hooks.ts
@@ -8,6 +8,8 @@ import { safeChildEnv } from '../utils/safe-child-env.js';
import { errorMessage } from '../errors.js';
import { ProcessError, runProcess } from '../utils/run-process.js';
import { isZhLocale } from './cli-locale.js';
+import { hookEventZhName } from './hook-event-names.js';
+import type { MossHookEventId } from './hook-event-names.js';
const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
@@ -38,7 +40,11 @@ export function hookShell(
if (platform === 'win32') {
return {
cmd: process.env.COMSPEC || 'cmd.exe',
- args: ['/d', '/s', '/c', `"${command}"`],
+ // cmd /c normalizes an unknown command's ERRORLEVEL 9009 to exit 1.
+ // Exit explicitly with the current ERRORLEVEL so a missing hook cannot
+ // impersonate the non-blocking Claude exit-1 verdict. Explicit user
+ // exit commands still terminate before this suffix.
+ args: ['/d', '/s', '/c', `"${command} & exit /b"`],
windowsVerbatimArguments: true,
};
}
@@ -63,18 +69,7 @@ interface HookRunResult {
}
interface HookPayload {
- event:
- | 'PreToolUse'
- | 'PostToolUse'
- | 'UserPromptSubmit'
- | 'PermissionRequest'
- | 'SessionStart'
- | 'Stop'
- | 'SubagentStop'
- | 'PreCompact'
- | 'PostCompact'
- | 'SessionEnd'
- | 'Notification';
+ event: MossHookEventId;
toolName?: string;
/** Claude-format alias of toolName (Bash, Edit, …). */
tool_name?: string;
@@ -260,7 +255,9 @@ export function formatHookFailure(
: result.outcome === 'timeout'
? `超时(${timeoutFor(hook)} 毫秒)`
: `非零退出(退出码 ${result.exitCode})`;
- const name = event === 'PreToolUse' ? '工具调用前钩子' : `${event} 钩子`;
+ // O91: zh names the hook 「<中文名>钩子」 — the table is suffix-free and
+ // the bare English event id never appears as the only label in zh copy.
+ const name = `${hookEventZhName(event)}钩子`;
const head = blocked ? `已拦截:${name}${why}。` : `${name}${why},未拦截。`;
const lines = [
head,
@@ -306,7 +303,7 @@ function hookReason(result: HookRunResult, parsed?: ParsedHookJson): string {
const stdout = result.stdout.trim();
if (stdout) return stdout.slice(0, 500);
}
- return `hook exited ${result.exitCode}`;
+ return isZhLocale() ? `退出码 ${result.exitCode}` : `hook exited ${result.exitCode}`;
}
function promptBlocks(
@@ -337,6 +334,33 @@ function timeoutFor(hook: HookCommandConfig): number {
return MAX_TIMER_DELAY_MS;
}
+/**
+ * O91: the `[hooks] exited : ` notice. The zh UI names
+ * the hook with its Chinese display name instead of the bare English event id.
+ */
+function hookExitedLine(
+ event: HookEventName,
+ exitCode: number,
+ stderr: string,
+ toolLabel?: string
+): string {
+ const detail = (stderr || '').trim().slice(0, 200);
+ if (isZhLocale()) {
+ const name = `${hookEventZhName(event)}钩子`;
+ const who = toolLabel ? `${name}(${toolLabel})` : name;
+ return `[hooks] ${who}以退出码 ${exitCode} 结束:${detail}`;
+ }
+ const who = toolLabel ? `${event} (${toolLabel})` : event;
+ return `[hooks] ${who} exited ${exitCode}: ${detail}`;
+}
+
+/** O91: the `[hooks] hook failed: ` wrapper notice. */
+function hookFailedLine(event: HookEventName, message: string): string {
+ return isZhLocale()
+ ? `[hooks] ${hookEventZhName(event)}钩子执行失败:${message}`
+ : `[hooks] ${event} hook failed: ${message}`;
+}
+
export interface ConfiguredHookCallbacks {
onBeforeToolExec?: (request: ToolApprovalRequest) => Promise;
@@ -434,10 +458,13 @@ export function createConfiguredHookCallbacks(
hook: HookCommandConfig,
r: HookRunResult,
parsed?: ParsedHookJson
- ): string =>
- r.outcome !== 'exit'
- ? notice(event, hook, r, true)
+ ): string => {
+ if (r.outcome !== 'exit') return notice(event, hook, r, true);
+ // O91: zh blocked reasons use 「中文名+钩子」, never the bare English id.
+ return isZhLocale()
+ ? `已拦截:${hookEventZhName(event)}钩子:${hookReason(r, parsed)}`
: `Blocked by ${event} hook: ${hookReason(r, parsed)}`;
+ };
const onBeforeToolExec =
pre.length === 0
@@ -486,7 +513,7 @@ export function createConfiguredHookCallbacks(
.then((r) => {
if (r.exitCode !== 0) {
process.stderr.write(
- `[hooks] PostToolUse (${call.name}) exited ${r.exitCode}: ${(r.stderr || '').trim().slice(0, 200)}\n`
+ `${hookExitedLine('PostToolUse', r.exitCode, r.stderr, call.name)}\n`
);
}
})
@@ -503,9 +530,7 @@ export function createConfiguredHookCallbacks(
timeoutFor(hook)
);
if (r.exitCode !== 0) {
- process.stderr.write(
- `[hooks] SessionStart exited ${r.exitCode}: ${(r.stderr || '').trim().slice(0, 200)}\n`
- );
+ process.stderr.write(`${hookExitedLine('SessionStart', r.exitCode, r.stderr)}\n`);
}
}
};
@@ -544,9 +569,7 @@ export function createConfiguredHookCallbacks(
timeoutFor(hook)
);
if (r.exitCode !== 0) {
- process.stderr.write(
- `[hooks] SubagentStop exited ${r.exitCode}: ${(r.stderr || '').trim().slice(0, 200)}\n`
- );
+ process.stderr.write(`${hookExitedLine('SubagentStop', r.exitCode, r.stderr)}\n`);
}
}
};
@@ -567,9 +590,7 @@ export function createConfiguredHookCallbacks(
timeoutFor(hook)
);
if (r.exitCode !== 0) {
- process.stderr.write(
- `[hooks] PreCompact exited ${r.exitCode}: ${(r.stderr || '').trim().slice(0, 200)}\n`
- );
+ process.stderr.write(`${hookExitedLine('PreCompact', r.exitCode, r.stderr)}\n`);
}
});
}
@@ -588,9 +609,7 @@ export function createConfiguredHookCallbacks(
timeoutFor(hook)
);
if (r.exitCode !== 0) {
- process.stderr.write(
- `[hooks] PostCompact exited ${r.exitCode}: ${(r.stderr || '').trim().slice(0, 200)}\n`
- );
+ process.stderr.write(`${hookExitedLine('PostCompact', r.exitCode, r.stderr)}\n`);
}
});
}
@@ -606,9 +625,7 @@ export function createConfiguredHookCallbacks(
timeoutFor(hook)
);
if (r.exitCode !== 0) {
- process.stderr.write(
- `[hooks] SessionEnd exited ${r.exitCode}: ${(r.stderr || '').trim().slice(0, 200)}\n`
- );
+ process.stderr.write(`${hookExitedLine('SessionEnd', r.exitCode, r.stderr)}\n`);
}
}
};
@@ -626,9 +643,7 @@ export function createConfiguredHookCallbacks(
timeoutFor(hook)
);
if (r.exitCode !== 0) {
- process.stderr.write(
- `[hooks] Notification exited ${r.exitCode}: ${(r.stderr || '').trim().slice(0, 200)}\n`
- );
+ process.stderr.write(`${hookExitedLine('Notification', r.exitCode, r.stderr)}\n`);
}
}
};
@@ -774,7 +789,7 @@ export async function runStopHooks(info: StopHookInfo): Promise
try {
return (await lifecycleRunner?.runStop(info)) ?? { blocked: false };
} catch (err) {
- process.stderr.write(`[hooks] Stop hook failed: ${errorMessage(err)}\n`);
+ process.stderr.write(`${hookFailedLine('Stop', errorMessage(err))}\n`);
return { blocked: false };
}
}
@@ -786,7 +801,7 @@ export async function runNotificationHooks(info: {
try {
await lifecycleRunner?.runNotification?.(info);
} catch (err) {
- process.stderr.write(`[hooks] Notification hook failed: ${errorMessage(err)}\n`);
+ process.stderr.write(`${hookFailedLine('Notification', errorMessage(err))}\n`);
}
}
@@ -794,6 +809,6 @@ export async function runSubagentStopHooks(info: SubagentStopHookInfo): Promise<
try {
await lifecycleRunner?.runSubagentStop(info);
} catch (err) {
- process.stderr.write(`[hooks] SubagentStop hook failed: ${errorMessage(err)}\n`);
+ process.stderr.write(`${hookFailedLine('SubagentStop', errorMessage(err))}\n`);
}
}
diff --git a/src/cli/moss-binary-path.ts b/src/cli/moss-binary-path.ts
index b4d6b569..7f2e7844 100644
--- a/src/cli/moss-binary-path.ts
+++ b/src/cli/moss-binary-path.ts
@@ -22,14 +22,14 @@ export interface MossBinaryHit {
export interface MossBinaryScan {
/** First occurrence of each real path, in PATH order. */
binaries: MossBinaryHit[];
- /** First PATH entry, or null when nothing named moss is executable. */
+ /** First scanned candidate, or null. This does not identify a shell's command winner. */
winner: MossBinaryHit | null;
/** True when `winner` resolves to the same file as `runningPath`. */
winnerIsRunning: boolean;
/**
* Set only when more than one distinct real path is on PATH.
- * Names every hit in PATH order, says which one wins, and whether that
- * winner is the process that is running.
+ * Names every candidate in scan order and the actual process entry when known.
+ * File identities alone do not establish npm shim targets or install count.
*/
warning: string | null;
}
@@ -142,27 +142,10 @@ function warningText(
const shown = binaries.map((hit) => displayHit(platform, hit));
const list = shown.join('; ');
const zhList = shown.join('、');
- const winnerIsRunning =
- runningPath !== undefined &&
- runningPath !== '' &&
- (sameFile(platform, winner.realPath, runningPath) ||
- sameFile(platform, winner.listed, runningPath));
- if (runningPath === undefined || runningPath === '') {
- return uiText(
- `${binaries.length} moss executables on PATH: ${list}. ${winnerLabel} wins (first on PATH).`,
- `PATH 上有 ${binaries.length} 个不同的 moss:${zhList}。先出现的会生效:${winnerLabel}。`
- );
- }
- if (winnerIsRunning) {
- return uiText(
- `${binaries.length} moss executables on PATH: ${list}. ${winnerLabel} wins (first on PATH) and is the one currently running.`,
- `PATH 上有 ${binaries.length} 个不同的 moss:${zhList}。先出现的会生效:${winnerLabel}。它就是当前正在运行的这个。`
- );
- }
- const running = displayMossPath(runningPath);
+ const running = runningPath ? displayMossPath(runningPath) : undefined;
return uiText(
- `${binaries.length} moss executables on PATH: ${list}. ${winnerLabel} wins (first on PATH) and is not the one currently running (${running}).`,
- `PATH 上有 ${binaries.length} 个不同的 moss:${zhList}。先出现的会生效:${winnerLabel}。它不是当前正在运行的(${running})。`
+ `${binaries.length} moss candidate files on PATH: ${list}. First scanned candidate: ${winnerLabel}.${running ? ` Current process entry: ${running}.` : ''}`,
+ `PATH 上扫描到 ${binaries.length} 个 moss 候选文件:${zhList}。首个扫描候选:${winnerLabel}。${running ? `当前进程入口:${running}。` : ''}`
);
}
@@ -171,7 +154,7 @@ function emptyScan(): MossBinaryScan {
}
/**
- * Every executable `moss` on PATH. Symlinks that share a realpath count once
+ * Every candidate `moss` file on PATH (POSIX requires execute bits). Symlinks that share a realpath count once
* (the earlier PATH entry). Does not spawn. Returns an empty scan if the
* walk itself throws.
*/
diff --git a/src/cli/onboarding.ts b/src/cli/onboarding.ts
index f4a569ca..51b70f7f 100644
--- a/src/cli/onboarding.ts
+++ b/src/cli/onboarding.ts
@@ -298,6 +298,7 @@ export function renderCliStatus(
'guardrails',
'maxTurns',
'contextTokens',
+ 'reasoningBudget',
'maxOutput',
'compaction',
]),
@@ -533,6 +534,7 @@ export function renderCliPermissions(
'guardrails',
'maxTurns',
'contextTokens',
+ 'reasoningBudget',
'maxOutput',
'compaction',
'warnings',
diff --git a/src/cli/oneshot.ts b/src/cli/oneshot.ts
index c2528131..47019b02 100644
--- a/src/cli/oneshot.ts
+++ b/src/cli/oneshot.ts
@@ -24,11 +24,19 @@ import {
} from './print.js';
import { createCliSessionKey } from './session.js';
import { loadCliConfigFile } from './config.js';
+import { hookEventZhName } from './hook-event-names.js';
import { configuredBaseUrl, pricingOverridesFromConfig } from './model-pricing.js';
import { buildGitStatusSnapshot } from '../context/git-status-snapshot.js';
import { acceptanceShell } from '../core/task/acceptance-command.js';
import { runStopHooks, type StopHookResult } from './hooks.js';
-import { runProcess } from '../utils/run-process.js';
+import { ProcessError, runProcess } from '../utils/run-process.js';
+import {
+ createRunCompletionTracker,
+ noteGoalVerifyResult,
+ noteRunCompletionEvent,
+} from './run-completion.js';
+import { formatOutcomeLine, resolveRunOutcome } from './run-outcome.js';
+import { createRunProgressTracker } from './run-progress.js';
/** Format the oneshot exit warning when background work outlives the wait window. */
export function formatOneshotStillRunningBackgroundNotice(
@@ -405,6 +413,15 @@ export async function runOneShot(
const stdout = options.stdout ?? process.stdout;
const workspaceDir = options.cwd ?? process.cwd();
const renderer = outputFormat === 'text' ? createCliRunRenderer({ workspaceDir }) : null;
+ // O99: json / stream-json have no text renderer — still emit progress on stderr.
+ const jsonProgress =
+ outputFormat === 'text'
+ ? null
+ : createRunProgressTracker({
+ sink: { write: (line) => process.stderr.write(`${line}\n`) },
+ zh: isZhLocale(),
+ emitToolBoundaries: true,
+ });
let pricingOverrides: ReturnType | undefined;
try {
@@ -422,6 +439,19 @@ export async function runOneShot(
});
let finalResult: HeadlessResultEvent | undefined;
let runError: unknown = undefined;
+ const completionTracker = createRunCompletionTracker();
+ // O121: JSON / stream-json derive failure straight from the same
+ // resolveRunOutcome the text last line uses, so every non-completed outcome
+ // (blocked, loop-guard stuck, unverified, …) is is_error + exit 9 there too.
+ state.safetyBlockConclusion = () => {
+ if (outputFormat === 'text') return undefined; // text: the O98 last line owns it
+ const o = resolveRunOutcome({
+ tracker: completionTracker,
+ finalText: state.finalText,
+ request: message,
+ });
+ return o.exitCode !== 0 ? formatOutcomeLine(o, isZhLocale() ? 'zh' : 'en') : undefined;
+ };
const routedToolFilter = oneShotToolFilterForMessage(message);
const focusedInspection = focusedInspectionRunOptions(message);
const fastNews = fastNewsRunPolicy(message);
@@ -529,11 +559,13 @@ export async function runOneShot(
};
for await (const event of agent.streamChat(sessionKey, message, streamOptions)) {
options.onAgentEvent?.(event);
+ noteRunCompletionEvent(completionTracker, event);
const structuredEvents = formatHeadlessStreamEvent(state, event);
if (outputFormat === 'text') {
renderer?.handle(event);
rememberStructuredResult(structuredEvents);
} else {
+ jsonProgress?.onEvent(event);
writeStructured(structuredEvents);
}
}
@@ -620,23 +652,21 @@ export async function runOneShot(
} finally {
try {
renderer?.dispose?.();
+ jsonProgress?.dispose();
} catch {
/* ignore */
}
}
// Headless goal-verify loop (MOSS_GOAL_VERIFY_LOOP=1): the headless
- // counterpart of `/goal --accept`. After the primary run, execute
- // MOSS_GOAL_VERIFY_CMD; a non-zero exit triggers ONE continuation turn
- // carrying the failure evidence.
+ // counterpart of `/goal --accept`. After every run (including the one
+ // continuation turn), execute MOSS_GOAL_VERIFY_CMD and record the result on
+ // the tracker. A non-zero exit on the first pass triggers ONE continuation;
+ // the continuation always re-verifies. Spawn/timeout failures are
+ // verify_failed — never swallowed (O98).
const verifyDepth = options.goalVerifyDepth ?? 0;
- if (
- process.env.MOSS_GOAL_VERIFY_LOOP === '1' &&
- process.env.MOSS_GOAL_VERIFY_CMD &&
- verifyDepth < 1
- ) {
- // Task OS M6: mirror the verify verdict onto the latest live task so
- // headless goal runs land in unified task history too.
+ const verifyCmd = process.env.MOSS_GOAL_VERIFY_CMD;
+ if (process.env.MOSS_GOAL_VERIFY_LOOP === '1' && verifyCmd) {
const mirrorGoalVerdict = async (passed: boolean, exitCode: number, tail: string) => {
try {
const { findLatestLiveTaskSnapshot, emitAcceptanceLifecycle } =
@@ -657,55 +687,133 @@ export async function runOneShot(
}
};
try {
- const shell = acceptanceShell(process.env.MOSS_GOAL_VERIFY_CMD);
- const verify = await runProcess(shell.cmd, {
+ const shell = acceptanceShell(verifyCmd);
+ // runProcess resolves only on exit 0; non-zero / timeout / spawn reject.
+ // A plain non-zero exit must stay a rejection so the catch below routes
+ // it to the one-continuation branch (normalizing it to a resolved value
+ // here would record the run as passed and exit 0).
+ await runProcess(shell.cmd, {
args: shell.args,
cwd: workspaceDir,
timeout: 5 * 60_000,
...(shell.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
});
- if (verify.exitCode !== 0) {
- const tail = `${verify.stdout}\n${verify.stderr}`.trim().slice(-2000);
- await mirrorGoalVerdict(false, verify.exitCode, tail);
+ noteGoalVerifyResult(completionTracker, {
+ command: verifyCmd,
+ passed: true,
+ exitCode: 0,
+ });
+ await mirrorGoalVerdict(true, 0, '');
+ } catch (err) {
+ const pe = err instanceof ProcessError ? err : undefined;
+ const timedOut = pe?.timedOut === true;
+ const exitCode = pe && typeof pe.exitCode === 'number' ? pe.exitCode : 1;
+ const tail = pe ? `${pe.stdout}\n${pe.stderr}`.trim().slice(-2000) : '';
+ const detail = pe?.message || (err instanceof Error ? err.message : String(err));
+ // Infrastructure failure (timeout / could not start): record and stop — no continue.
+ const infraFailure = timedOut || !pe;
+ if (infraFailure) {
+ noteGoalVerifyResult(completionTracker, {
+ command: verifyCmd,
+ passed: false,
+ exitCode,
+ error: detail,
+ timedOut,
+ spawnFailed: !timedOut,
+ });
+ process.stderr.write(`[goal-verify] ${detail}\n`);
+ } else {
+ // Ordinary non-zero exit: record, continue once, re-verify after.
+ noteGoalVerifyResult(completionTracker, {
+ command: verifyCmd,
+ passed: false,
+ exitCode,
+ ...(tail ? { error: tail.slice(0, 400) } : { error: detail }),
+ });
+ await mirrorGoalVerdict(false, exitCode, tail || detail);
+ if (verifyDepth < 1) {
+ process.stderr.write(
+ `[goal-verify] command failed (exit ${exitCode}); running one continuation turn\n`
+ );
+ return runOneShot(
+ agent,
+ [
+ `[verification failed] The verification command exited ${exitCode}.`,
+ 'Fix the underlying cause — do not work around or edit the verification command.',
+ '',
+ 'Last output (tail):',
+ tail || detail || '(no output)',
+ '',
+ 'Complete the original task so the verification passes.',
+ ].join('\n'),
+ { ...options, goalVerifyDepth: verifyDepth + 1 }
+ );
+ }
process.stderr.write(
- `[goal-verify] command failed (exit ${verify.exitCode}); running one continuation turn\n`
- );
- return runOneShot(
- agent,
- [
- `[verification failed] The verification command exited ${verify.exitCode}.`,
- 'Fix the underlying cause — do not work around or edit the verification command.',
- '',
- 'Last output (tail):',
- tail || '(no output)',
- '',
- 'Complete the original task so the verification passes.',
- ].join('\n'),
- { ...options, goalVerifyDepth: verifyDepth + 1 }
+ `[goal-verify] command still failing after continuation (exit ${exitCode})\n`
);
}
- await mirrorGoalVerdict(true, 0, '');
- } catch {
- /* verification infrastructure failures never break the run */
}
}
// Stop lifecycle hook: fires after every completed run. A blocking non-zero
// exit vetoes the stop — the REPL turns that into one forced continuation
- // turn; headless reports it on stderr.
+ // turn; headless also exits non-zero (O98).
+ let stopResult: StopHookResult = { blocked: false };
try {
- const stop = await runStopHooks({
+ stopResult = await runStopHooks({
sessionKey,
...(finalResult?.subtype !== undefined ? { stopReason: String(finalResult.subtype) } : {}),
...(typeof finalResult?.result === 'string' ? { response: finalResult.result } : {}),
});
- if (stop.blocked) {
- process.stderr.write(
- `[hooks] ${stop.reason ?? 'Stop hook blocked the run ending.'} (headless mode reports but does not force-continue)\n`
- );
+ if (stopResult.blocked) {
+ // O91: the zh fallback names the Stop hook with its Chinese display name
+ // instead of the bare English event id; behavior and exit codes unchanged.
+ const zh = isZhLocale();
+ const fallback = zh
+ ? `${hookEventZhName('Stop')}钩子拦住了本次运行的结束。`
+ : 'Stop hook blocked the run ending.';
+ const suffix = options.headless
+ ? ''
+ : zh
+ ? '(交互模式仅报告;宿主可能强制继续)'
+ : ' (interactive mode reports; host may force-continue)';
+ process.stderr.write(`[hooks] ${stopResult.reason ?? fallback}${suffix}\n`);
}
- return stop;
} catch {
- return { blocked: false };
+ stopResult = { blocked: false };
+ }
+
+ // O98: never end a text `--print` run on a false success. Resolve a stable
+ // RunOutcome; always print the conclusion line (完成 / 未完成 / 未验证 / …).
+ if (outputFormat === 'text') {
+ const lang = isZhLocale() ? 'zh' : 'en';
+ const outcome = resolveRunOutcome({
+ tracker: completionTracker,
+ finalText: typeof finalResult?.result === 'string' ? finalResult.result : state.finalText,
+ request: message,
+ ...(finalResult && isHeadlessResultError(finalResult)
+ ? { runError: finalResult.error ?? String(finalResult.subtype) }
+ : {}),
+ ...(typeof process.exitCode === 'number' ? { errorExitCode: process.exitCode } : {}),
+ ...(stopResult.blocked
+ ? { blocked: true, ...(stopResult.reason ? { blockedReason: stopResult.reason } : {}) }
+ : {}),
+ });
+ const line = formatOutcomeLine(outcome, lang);
+ if (line) stdout.write(`\n${line}\n`);
+ if (outcome.exitCode !== 0) {
+ process.exitCode = outcome.exitCode;
+ } else if (options.headless && stopResult.blocked) {
+ process.exitCode = ExitCode.INCOMPLETE;
+ }
+ } else {
+ // O121: non-text formats — the result was failed by the run outcome → exit 9.
+ if (state.outcomeConcluded && !runError) {
+ process.exitCode = ExitCode.INCOMPLETE;
+ }
+ if (options.headless && stopResult.blocked) process.exitCode = ExitCode.INCOMPLETE;
}
+
+ return stopResult;
}
diff --git a/src/cli/output.ts b/src/cli/output.ts
index 9f603bda..14956099 100644
--- a/src/cli/output.ts
+++ b/src/cli/output.ts
@@ -30,6 +30,14 @@ import {
tui,
} from './tui/copy.js';
import { noteToolForVerifyHint } from './verify-hint.js';
+import {
+ createRunProgressTracker,
+ formatProgressOutcomeLine,
+ formatToolRunningLine,
+ unfinishedReasonFromStop,
+ upstreamStallReason,
+ type RunProgressTracker,
+} from './run-progress.js';
const CODE_EDIT_TOOLS = new Set([
'write_file',
@@ -81,6 +89,8 @@ interface RendererState {
toolLineIds: Map;
/** Whether any JS/TS files were edited (to avoid false-positive npm test hints for Python/docs). */
editedJsTs: boolean;
+ /** O114: last surfaced error text, so the footer can name an upstream stall. */
+ lastErrorText?: string;
ranTests: boolean;
/** Buffer for the current answer segment — flushed on segment end. */
answerBuffer: string;
@@ -453,6 +463,9 @@ export function createCliRunRenderer(options: CliRunRendererOptions = {}) {
const detailMode = options.detailMode ?? resolveCliDetailMode();
const interactive = options.interactive ?? Boolean((stderr as NodeJS.WriteStream).isTTY);
const spinner = interactive ? new CliSpinner(stderr) : null;
+ // O99: heartbeat + long-tool progress on stderr. Tool start/end stay with this
+ // renderer so we do not double-print; empty "working…" slogans are not used.
+ let progress: RunProgressTracker | undefined;
const state: RendererState = {
answerOpen: false,
answerStarted: false,
@@ -561,7 +574,27 @@ export function createCliRunRenderer(options: CliRunRendererOptions = {}) {
}
});
+ function ensureProgress(): RunProgressTracker {
+ if (!progress) {
+ progress = createRunProgressTracker({
+ sink: {
+ write(line: string) {
+ if (isQuiet) return;
+ breakAnswerForStatus();
+ spinner?.stop();
+ stderrLine(`${mark()} ${line}`);
+ },
+ },
+ zh: isZhLocale(),
+ emitToolBoundaries: false,
+ });
+ }
+ return progress;
+ }
+
function handle(event: MossAgentEvent): void {
+ // O99 progress tracker (thinking wait / tool_progress). Quiet mode skips it.
+ if (!isQuiet) ensureProgress().onEvent(event);
switch (event.type) {
case 'turn_start':
if (!isQuiet) {
@@ -570,13 +603,11 @@ export function createCliRunRenderer(options: CliRunRendererOptions = {}) {
if (isVerbose) {
stderrLine(`${mark()} thinking${ui.dim(` (turn ${event.turn})`)}`);
} else if (interactive) {
- spinner?.start(`Working${event.turn > 1 ? `… (turn ${event.turn})` : ''}`);
- } else if (event.turn > 2) {
- // Oneshot / headless: suppress the first two turns (normal single-tool
- // calls don't need a "working" message). Only signal turn 3+ which
- // indicates a genuine multi-step tool loop where the user needs feedback.
- stderrLine(ui.dim(`working…`));
+ // Real label comes from tool_start / thinking-wait; keep spinner alive
+ // without empty rotating slogans.
+ spinner?.start(isZhLocale() ? '处理中' : 'Working');
}
+ // Non-interactive: no empty "working…" — O99 heartbeat covers silence.
state.thinkingNoted = true;
}
break;
@@ -624,6 +655,11 @@ export function createCliRunRenderer(options: CliRunRendererOptions = {}) {
spinner?.stop();
state.toolStartTimes.set(event.toolCallId, Date.now());
state.toolInputs.set(event.toolCallId, event.input);
+ if (!isQuiet && interactive) {
+ const target = extractToolTarget(event.toolName, event.input);
+ const label = target ? `${event.toolName}(${target})` : event.toolName;
+ spinner?.start(isZhLocale() ? `正在调用 ${label}` : `Calling ${label}`);
+ }
noteToolForVerifyHint(
state,
event.toolName,
@@ -652,6 +688,15 @@ export function createCliRunRenderer(options: CliRunRendererOptions = {}) {
}
break;
}
+ case 'tool_progress': {
+ if (!isQuiet && interactive) {
+ const input = state.toolInputs.get(event.toolCallId) ?? {};
+ const line = formatToolRunningLine(event.toolName, input, event.elapsedSec, isZhLocale());
+ spinner?.stop();
+ spinner?.start(line);
+ }
+ break;
+ }
case 'tool_end': {
if (!isQuiet) {
const startedAt = state.toolStartTimes.get(event.toolCallId);
@@ -963,6 +1008,7 @@ export function createCliRunRenderer(options: CliRunRendererOptions = {}) {
noteInterrupt();
break;
}
+ state.lastErrorText = text;
spinner?.stop();
breakAnswerForStatus();
const stdoutStream = stdout as { isTTY?: boolean; columns?: number };
@@ -993,10 +1039,34 @@ export function createCliRunRenderer(options: CliRunRendererOptions = {}) {
state.answerStarted = false;
const stopReason = event.result?.stopReason;
- if (stopReason === 'max_turns_reached' || stopReason === 'tool_followup_cap_reached') {
+ const unfinished = unfinishedReasonFromStop(stopReason, isZhLocale());
+ if (unfinished) {
+ // O99 / O98: progress-path footer via formatOutcomeLine (never empty).
+ // Exit code is owned by oneshot resolveRunOutcome.
+ // O113: pass the progress completionTracker so budget/turn-limit
+ // stderr matches stdout (e.g. 「卡在:运行预算(read_file: …)」).
+ const zh = isZhLocale();
+ const prog = ensureProgress();
+ // O114: when the run died on an exhausted mid-stream stall, the
+ // last line must say so instead of a generic "error during execution".
+ const stallReason =
+ stopReason === 'error' ? upstreamStallReason(state.lastErrorText, zh) : undefined;
stderrLine(
- `${mark('fail')} stopped at the turn limit before finishing — the task is paused, not complete. Continue with ${ui.bold('moss resume --last')} (or ${ui.bold('moss --continue')}).`
+ `${mark('fail')} ${formatProgressOutcomeLine({
+ stopReason,
+ ...(stallReason ? { errorMessage: stallReason } : {}),
+ finalText: event.result?.response ?? '',
+ tracker: prog.completionTracker(),
+ zh,
+ })}`
);
+ if (stopReason === 'max_turns_reached' || stopReason === 'tool_followup_cap_reached') {
+ stderrLine(
+ isZhLocale()
+ ? `${mark('fail')} 可用 ${ui.bold('moss resume --last')}(或 ${ui.bold('moss --continue')})继续。`
+ : `${mark('fail')} Continue with ${ui.bold('moss resume --last')} (or ${ui.bold('moss --continue')}).`
+ );
+ }
} else if (!isQuiet && state.editedJsTs && !state.ranTests) {
// Only suggest npm test when JS/TS files were edited — avoid false
// positives when the agent only writes Python, docs, or other files.
@@ -1026,6 +1096,11 @@ export function createCliRunRenderer(options: CliRunRendererOptions = {}) {
} catch {
/* ignore */
}
+ try {
+ progress?.dispose();
+ } catch {
+ /* ignore */
+ }
try {
unsubscribeBackground();
} catch {
diff --git a/src/cli/permission-request.ts b/src/cli/permission-request.ts
index f131db75..ba563d53 100644
--- a/src/cli/permission-request.ts
+++ b/src/cli/permission-request.ts
@@ -10,6 +10,8 @@ import { AsyncLocalStorage } from 'node:async_hooks';
import type { ToolApprovalRequest } from '../core/agent/agent-hooks.js';
import type { AskUser } from './approval.js';
import type { CliApprovalViewAsker } from './approval-view.js';
+import { isZhLocale } from './cli-locale.js';
+import { hookEventZhName } from './hook-event-names.js';
const pending = new AsyncLocalStorage();
@@ -35,7 +37,13 @@ async function denial(): Promise {
if (!request || !runner) return undefined;
const result = await runner(request);
if (!result.denied) return undefined;
- return result.reason ?? 'Blocked by PermissionRequest hook';
+ // O91: the zh fallback names the hook in Chinese; en keeps the event id.
+ return (
+ result.reason ??
+ (isZhLocale()
+ ? `已拦截:${hookEventZhName('PermissionRequest')}钩子`
+ : 'Blocked by PermissionRequest hook')
+ );
}
export function wrapApprovalAsker(asker: AskUser): AskUser {
diff --git a/src/cli/print.ts b/src/cli/print.ts
index 5bb79f7c..ecf2d23d 100644
--- a/src/cli/print.ts
+++ b/src/cli/print.ts
@@ -212,6 +212,14 @@ export interface HeadlessPrintState {
pricingOverrides?: Readonly>;
/** Official-host gate for built-in prices. Gateways stay unpriced. */
baseUrl?: string;
+ /**
+ * O121: conclusion line when the run's block record is non-empty (a safety /
+ * approval refusal happened). The JSON result then reports is_error +
+ * recoverable: false with the same wording as the text last line.
+ */
+ safetyBlockConclusion?: () => string | undefined;
+ /** O121: set when the result event was failed by {@link safetyBlockConclusion}. */
+ outcomeConcluded?: boolean;
}
export interface HeadlessPrintStateInput {
@@ -424,7 +432,11 @@ function formatResult(
: undefined);
const maxTurns = isMaxTurnsStopReason(result?.stopReason);
const outputLimit = isOutputLimitStop(result?.stopReason);
- const isError = Boolean(errorMessage) || isErrorStopReason(result?.stopReason);
+ const baseError = Boolean(errorMessage) || isErrorStopReason(result?.stopReason);
+ // O121: only for runs that otherwise look successful; real errors (budget,
+ // stall, max turns) keep their own subtype / error / exit code.
+ const blockLine = baseError ? undefined : state.safetyBlockConclusion?.();
+ const isError = baseError || Boolean(blockLine);
const subtype: HeadlessResultSubtype = !isError
? 'success'
: outputLimit
@@ -469,6 +481,12 @@ function formatResult(
event.error_code = state.lastErrorDetails.code;
event.recoverable = state.lastErrorDetails.recoverable;
}
+ if (blockLine) {
+ // O121: a refused action is never a success, whatever the answer says.
+ event.error = blockLine;
+ state.outcomeConcluded = true;
+ event.recoverable = false;
+ }
if (!isError && state.structuredOutputRequested) {
const structuredOutput = parseStructuredOutput(resultText);
if (structuredOutput !== undefined) {
@@ -613,6 +631,9 @@ export function formatHeadlessStreamEvent(
if (event.keptToolNames !== undefined) compaction.kept_tool_names = event.keptToolNames;
return [compaction];
}
+ case 'tool_progress':
+ // O99: progress is stderr-only; never appear on stdout/JSON stream.
+ return [];
case 'microcompact':
case 'retry':
return [];
diff --git a/src/cli/protected-path-prompt.ts b/src/cli/protected-path-prompt.ts
new file mode 100644
index 00000000..03a6a4e0
--- /dev/null
+++ b/src/cli/protected-path-prompt.ts
@@ -0,0 +1,184 @@
+/**
+ * O94: copy + dialog for protected-path confirmations. Deny is the first
+ * option (the TUI cursor starts at 0, so Enter denies) and the string prompt
+ * treats an empty answer as deny. "Always" is scoped to this path for this
+ * session only and is never persisted.
+ */
+import * as os from 'node:os';
+import * as path from 'node:path';
+
+import { PROTECTED_CATEGORY_LABEL, type ProtectedPathHit } from '../safety/protected-paths.js';
+import { sanitizeSecrets } from '../safety/secret-sanitizer.js';
+import type { CliApprovalView } from './approval-view.js';
+
+const OP_EN: Record = {
+ write: 'write to',
+ delete: 'delete',
+ move: 'move',
+ shell: 'run a shell command that writes to',
+};
+const OP_ZH: Record = {
+ write: '写入',
+ delete: '删除',
+ move: '移动',
+ shell: '用 shell 命令写入',
+};
+
+function shown(hit: ProtectedPathHit): string {
+ return sanitizeSecrets(hit.raw)
+ .replace(/[\u0000-\u001f\u007f]/g, '')
+ .slice(0, 200);
+}
+
+/** O101: "deploy.py line 12 would write ~/.ssh/authorized_keys". */
+function sourceClause(hit: ProtectedPathHit, zh: boolean): string {
+ const src = hit.source;
+ if (!src) return '';
+ const script = src.script.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 120);
+ if (src.outsideWorkspace) {
+ const where = src.line ? (zh ? ` 第 ${src.line} 行` : ` line ${src.line}`) : '';
+ return zh
+ ? `(脚本在工作区外(软链解析后):\`${script}\`${where})`
+ : ` (script is outside the workspace (after resolving symlinks): \`${script}\`${where})`;
+ }
+ if (src.kind === 'inline') {
+ return zh
+ ? `(内联 ${script}${src.line ? ` 第 ${src.line} 行` : ''})`
+ : ` (inline ${script}${src.line ? ` line ${src.line}` : ''})`;
+ }
+ return zh
+ ? `(\`${script}\`${src.line ? ` 第 ${src.line} 行` : ''})`
+ : ` (\`${script}\`${src.line ? ` line ${src.line}` : ''})`;
+}
+
+/**
+ * O103: why-clause per hit. Normal → "X 是受保护路径(label)"; hooksPath →
+ * "X 会改 git 钩子目录(core.hooksPath)"; unresolved download target → one
+ * honest line that it cannot be classified.
+ */
+function whyClause(hit: ProtectedPathHit, zh: boolean): string {
+ if (hit.kind === 'hooks-path') {
+ return zh
+ ? `${shown(hit)} 会改 git 钩子目录(core.hooksPath)`
+ : `${shown(hit)} would redirect git hooks (core.hooksPath)`;
+ }
+ if (hit.kind === 'unresolved-var') {
+ // O130 (UX): say why it was treated as protected and what to do instead.
+ return zh
+ ? `无法确定 \`${shown(hit)}\` 指向的路径(写入目标含变量或命令替换),按受保护路径处理;请改用字面路径,或用 edit 工具改文件`
+ : `cannot tell where \`${shown(hit)}\` points (write target built from a variable or command substitution), so it is treated as protected; use a literal path or the edit tool instead`;
+ }
+ const label = PROTECTED_CATEGORY_LABEL[hit.category];
+ if (hit.category === 'unresolved')
+ return `${shown(hit)}${zh ? ':' : ': '}${zh ? label.zh : label.en}`;
+ return zh
+ ? `${shown(hit)} 是受保护路径(${label.zh})`
+ : `${shown(hit)} is a protected path (${label.en})`;
+}
+
+/** First line: operation + the path as named + why (+ O101 script/line). */
+export function protectedPathHeadline(hit: ProtectedPathHit, zh: boolean): string {
+ const label = PROTECTED_CATEGORY_LABEL[hit.category];
+ const src = sourceClause(hit, zh);
+ if (hit.kind === 'hooks-path') {
+ return zh
+ ? `要执行 \`${shown(hit)}\`:会改 git 钩子目录(core.hooksPath)`
+ : `About to run \`${shown(hit)}\`: would redirect git hooks (core.hooksPath)`;
+ }
+ if (hit.kind === 'unresolved-var') {
+ return zh
+ ? `要${OP_ZH[hit.operation]}:${whyClause(hit, true)}`
+ : `About to ${OP_EN[hit.operation]}: ${whyClause(hit, false)}`;
+ }
+ if (hit.category === 'unresolved') {
+ return zh
+ ? `要${OP_ZH[hit.operation]} \`${shown(hit)}\`${src}:${label.zh}`
+ : `About to ${OP_EN[hit.operation]} \`${shown(hit)}\`${src}: ${label.en}`;
+ }
+ return zh
+ ? `要${OP_ZH[hit.operation]} \`${shown(hit)}\`${src}(${label.zh},受保护路径)`
+ : `About to ${OP_EN[hit.operation]} \`${shown(hit)}\`${src} (${label.en}, a protected path)`;
+}
+
+export function protectedPathView(
+ toolName: string,
+ hits: readonly ProtectedPathHit[],
+ zh: boolean
+): CliApprovalView {
+ const first = hits[0]!;
+ const extra = hits.slice(1).map((h) => protectedPathHeadline(h, zh));
+ return {
+ title: protectedPathHeadline(first, zh),
+ subject: shown(first),
+ preview: [
+ zh
+ ? `工具:${toolName}。受保护路径即使在 full 模式下也要确认一次。`
+ : `Tool: ${toolName}. Protected paths need one confirmation even in full mode.`,
+ ...extra,
+ ],
+ question: zh ? '允许这一次操作吗?(默认拒绝)' : 'Allow this one operation? (default: deny)',
+ options: [
+ { key: '1', answer: 'n', label: zh ? '拒绝(默认)' : 'No, deny (default)' },
+ { key: '2', answer: 'y', label: zh ? '允许这一次' : 'Yes, this once' },
+ {
+ key: '3',
+ answer: 'a',
+ label: zh ? '本会话内同一路径都允许' : 'Yes, for this path for the rest of this session',
+ },
+ ],
+ footer: zh ? 'Esc 或回车 = 拒绝 · ↑↓ 选择' : 'Esc or Enter = deny · ↑↓ to choose',
+ };
+}
+
+export function protectedPathStringPrompt(view: CliApprovalView, zh: boolean): string {
+ return [
+ view.title,
+ ...(view.preview ?? []),
+ zh
+ ? `${view.question} [y] 允许这一次 / [a] 本会话同一路径都允许 / [N] 拒绝: `
+ : `${view.question} [y] once / [a] this path this session / [N] deny: `,
+ ].join('\n');
+}
+
+export function protectedPathDeniedReason(hit: ProtectedPathHit, zh: boolean): string {
+ const special = hit.kind === 'hooks-path' || hit.category === 'unresolved';
+ const whatZh = special
+ ? ` ${whyClause(hit, true)}`
+ : `受保护路径 ${shown(hit)}(${PROTECTED_CATEGORY_LABEL[hit.category].zh})`;
+ const whatEn = special
+ ? `: ${whyClause(hit, false)}`
+ : ` of protected path ${shown(hit)} (${PROTECTED_CATEGORY_LABEL[hit.category].en})`;
+ return zh
+ ? `已拦截:用户拒绝${OP_ZH[hit.operation]}${whatZh}。不要改用 shell、node -e 或其他工具再写这个路径;如果确实需要,请告诉用户由用户自己处理。`
+ : `Blocked: the user denied the ${hit.operation}${whatEn}. Do not retry it through the shell, node -e, or another tool; if it is really needed, tell the user so they can do it themselves.`;
+}
+
+export function protectedPathHeadlessReason(hit: ProtectedPathHit, zh: boolean): string {
+ return zh
+ ? `已拦截:${whyClause(hit, true)},非交互模式下无法确认,已拒绝${OP_ZH[hit.operation]}。不要改用 shell、node -e 或其他工具再写这个路径。`
+ : `Blocked: ${whyClause(hit, false)}; Moss is running non-interactively and cannot confirm, so the ${hit.operation} was denied. Do not retry it through the shell, node -e, or another tool.`;
+}
+
+export function protectedPathHeadlessNotice(hit: ProtectedPathHit, zh: boolean): string {
+ const label = PROTECTED_CATEGORY_LABEL[hit.category];
+ // O129: name the outside-workspace script + reason in the -p notice too.
+ const outside = (z: boolean) => (hit.source?.outsideWorkspace ? sourceClause(hit, z) : '');
+ if (hit.kind === 'hooks-path' || hit.category === 'unresolved') {
+ return zh
+ ? `[moss] 已拦截:${whyClause(hit, true)},-p 模式无法确认。放行办法:不带 -p 交互运行 moss,在提示里确认这一次;或者自己手动处理。`
+ : `[moss] Blocked: ${whyClause(hit, false)}; -p cannot ask. To allow: run moss interactively (without -p) and confirm this once, or do it yourself.`;
+ }
+ return zh
+ ? `[moss] 已拦截:${OP_ZH[hit.operation]} ${shown(hit)}${outside(true)}。原因:它是受保护路径(${label.zh}),-p 模式无法确认。放行办法:不带 -p 交互运行 moss,在提示里确认这一次;或者自己手动改这个文件。`
+ : `[moss] Blocked: ${hit.operation} ${shown(hit)}${outside(false)}. Why: it is a protected path (${label.en}) and -p cannot ask. To allow: run moss interactively (without -p) and confirm this once, or make the change yourself.`;
+}
+
+/** Session grant key for "always for this path". */
+export function protectedGrantKey(hit: ProtectedPathHit): string {
+ return path.resolve(hit.resolved).replace(/\\/g, '/').toLowerCase();
+}
+
+export function mossConfigDirs(configDir: string, env: NodeJS.ProcessEnv): string[] {
+ const home = env.HOME ?? env.USERPROFILE ?? os.homedir();
+ return [configDir, path.join(home, '.moss')];
+}
diff --git a/src/cli/run-completion.ts b/src/cli/run-completion.ts
new file mode 100644
index 00000000..ccc3d5d2
--- /dev/null
+++ b/src/cli/run-completion.ts
@@ -0,0 +1,650 @@
+/**
+ * O98: no false success at the end of a headless run.
+ *
+ * O14 run2 ended with "All checks are green" although `format:check` never
+ * ran and `npm run check` was red; kimi-k3 r2 changed zero files and still
+ * exited 0. This module is the single place that decides whether a run is
+ * really finished:
+ *
+ * - {@link createRunCompletionTracker} / {@link noteRunCompletionEvent}
+ * record which verification commands actually ran (and whether they passed)
+ * and whether any file was written, from the agent event stream.
+ * - {@link findUnverifiedCheckClaims} lists success claims in the final
+ * answer that no run in this session backs up.
+ * - {@link assessRunCompletion} turns that into a verdict with a reason, the
+ * step it got stuck on, and the exit code.
+ * - {@link formatIncompleteLine} renders the mandatory last line
+ * (`未完成:…`[+`;卡在:…` when known] / `Incomplete: …`[+`; stuck at: …`]).
+ *
+ * O99 (progress) must import the stable bilingual outcome from
+ * {@link ./run-outcome.js} (`resolveRunOutcome` / `formatOutcomeLine`) rather
+ * than calling these helpers directly.
+ */
+import type { MossAgentEvent } from '../core/index.js';
+import { listBackgroundProcessSnapshots } from '../core/tools/background-process-registry.js';
+import { parseProtectedPathModMarker } from '../safety/protected-path-snapshot.js';
+import { ExitCode } from './exit-codes.js';
+import path from 'node:path';
+import { canonicalPath } from '../safety/shell-write-detect.js';
+import { rawMutationPaths } from '../core/tools/tool-loop-guard.js';
+
+export type VerificationCheck = 'format' | 'lint' | 'typecheck' | 'test' | 'check';
+
+export interface CheckRun {
+ command: string;
+ passed: boolean;
+}
+
+export interface GoalVerifyResult {
+ command: string;
+ passed: boolean;
+ exitCode?: number;
+ /** Short error / stdout+stderr tail for the outcome line. */
+ error?: string;
+ timedOut?: boolean;
+ spawnFailed?: boolean;
+}
+
+export interface RunCompletionTracker {
+ /** Latest run of each check kind in this run (later runs overwrite earlier ones). */
+ checks: Map;
+ /** True once a file-writing tool (or a write-like exec) succeeded. */
+ wroteFiles: boolean;
+ /** Last failed tool step, e.g. `exec: npm run check`. */
+ lastFailedStep?: string;
+ /**
+ * O113: last tool step blocked by safety / approval (not a normal tool fail).
+ * When set, the incomplete last line uses 「被拦:…」 / `Blocked by: …`.
+ */
+ lastBlockedStep?: string;
+ /** Last tool step that started, e.g. `multi_edit: src/cli/hooks.ts`. */
+ lastStep?: string;
+ /** Latest MOSS_GOAL_VERIFY_CMD result (O98 — must feed the tracker). */
+ goalVerify?: GoalVerifyResult;
+ /**
+ * O101 layer 3: protected paths modified by a shell command (post-run
+ * detection). When set, the run is incomplete regardless of model claims.
+ */
+ protectedPathChanges?: Array<{
+ path: string;
+ kind: string;
+ command?: string;
+ keys?: string[];
+ reason?: string;
+ }>;
+ /**
+ * O103: protected-path writes the approval gate refused before they ran
+ * (`-p` deny or user said no). Drives the outcome on its own — a run with a
+ * refused protected write is never「完成」, whatever the final answer says.
+ */
+ protectedPathBlocks?: Array<{ step: string; what: string }>;
+ /**
+ * O121: every safety / approval refusal of this run (dangerous command,
+ * protected path, partial-read edit, hook, loop-guard, -p approval deny …),
+ * in order. Any entry makes the run 「未完成」 exit 9, whatever the final
+ * answer claims. User-approved actions never land here (they were not
+ * refused). `en`/`zh` name what was refused for the last line.
+ */
+ safetyBlocks?: SafetyBlock[];
+ /**
+ * O121 (PM ruling): loop-guard short-circuits are NOT safety blocks. They
+ * still end the run 「未完成」 exit 9, but as 「卡在」/`stuck at`, never 被拦.
+ */
+ loopGuardStops?: Array<{ step: string; en: string; zh: string }>;
+ /** @internal pending tool_start inputs keyed by toolCallId. */
+ pending: Map }>;
+}
+
+export type SafetyBlockKind =
+ | 'dangerous'
+ | 'protected'
+ | 'partial-read'
+ | 'truncated-write'
+ | 'hook'
+ | 'loop-guard'
+ | 'approval'
+ | 'denied'
+ | 'policy'
+ | 'sandbox'
+ | 'other';
+
+export interface SafetyBlock {
+ kind: SafetyBlockKind;
+ /** Tool step, e.g. `exec: rm -rf /`. */
+ step: string;
+ /** What was refused, user-readable (UX): e.g. dangerous command `rm -rf /`. */
+ en: string;
+ zh: string;
+ /** Gate-type blocks (partial-read, truncated-write): the path as written. */
+ path?: string;
+ /**
+ * O128 (PM): gate-type refusal. Resolved — removed from the record — when
+ * the same file (realpath) is later changed successfully through the
+ * gate's prescribed tools ({@link RESOLVING_TOOLS}). Exec writes never
+ * resolve. Safety-type blocks never carry this.
+ */
+ resolvable?: boolean;
+ /** Realpath key of {@link path} for resolution. */
+ pathKey?: string;
+}
+
+export type RunCompletionStatus = 'complete' | 'incomplete';
+
+export interface RunCompletionVerdict {
+ status: RunCompletionStatus;
+ /** Human reasons (already localized); empty when complete. */
+ reasons: string[];
+ /** Where the run got stuck, when known. */
+ stuckAt?: string;
+ /** Claims in the final answer that were not backed by a real passing run. */
+ unverifiedClaims: VerificationCheck[];
+ /** 0 when complete, otherwise non-zero (existing error code or {@link ExitCode.INCOMPLETE}). */
+ exitCode: number;
+}
+
+export interface AssessRunCompletionInput {
+ tracker: RunCompletionTracker;
+ /** Final assistant answer. */
+ finalText: string;
+ /** Original user request; used to decide whether file changes were expected. */
+ request?: string;
+ /** The run already ended in error (max turns, budget, provider error …). */
+ runError?: string;
+ /** Exit code already chosen for that error. */
+ errorExitCode?: number;
+ zh?: boolean;
+}
+
+const FILE_WRITE_TOOLS = new Set([
+ 'write_file',
+ 'edit_file',
+ 'multi_edit',
+ 'apply_patch',
+ 'move_file',
+ 'delete_file',
+]);
+const EXEC_TOOLS = new Set(['exec', 'exec_background', 'device_exec']);
+const WRITE_LIKE_EXEC =
+ /(?:\bnode\s+-e\b|\bsed\s+-i|\bperl\s+-[a-z]*i|>>?\s*[^\s&|]|\btee\b|\bgit\s+(?:apply|am|checkout|restore|mv|rm)\b|\bpatch\b|\b(?:mv|cp|rm|touch|mkdir)\s|\bprettier\b[^|;&]*--write|\beslint\b[^|;&]*--fix|\bnpm\s+run\s+(?:format|lint:fix)\b(?!:check))/;
+
+/** Map a shell command to the verification checks it really runs. */
+export function checksForCommand(command: string): VerificationCheck[] {
+ const out = new Set();
+ if (/\bnpm\s+run\s+(?:check|verify)\b(?![:\w-])/.test(command)) {
+ out.add('check');
+ out.add('format');
+ out.add('lint');
+ out.add('typecheck');
+ }
+ if (/\bnpm\s+run\s+verify\b/.test(command)) out.add('test');
+ if (/\bnpm\s+run\s+format:check\b|\bprettier\b[^|;&]*--check\b/.test(command)) out.add('format');
+ if (/\bnpm\s+run\s+lint\b(?!:fix)|\beslint\b(?![^|;&]*--fix)/.test(command)) out.add('lint');
+ if (/\bnpm\s+run\s+typecheck\b|\btsc\b[^|;&]*--noEmit\b/.test(command)) out.add('typecheck');
+ if (
+ /\b(?:npm\s+(?:run\s+)?test(?::\w+)?|npm\s+t|yarn\s+test|pnpm\s+test|node\s+--test|pytest|vitest|jest|mocha|go\s+test|cargo\s+test|make\s+test)\b/.test(
+ command
+ ) ||
+ /\bnode\s+\S*\.spec\.[cm]?js\b/.test(command)
+ ) {
+ out.add('test');
+ }
+ return [...out];
+}
+
+export function createRunCompletionTracker(): RunCompletionTracker {
+ return { checks: new Map(), wroteFiles: false, pending: new Map() };
+}
+
+function describeStep(toolName: string, input: Record): string {
+ const command = typeof input.command === 'string' ? input.command : undefined;
+ const filePath = typeof input.path === 'string' ? input.path : undefined;
+ const detail = (command ?? filePath ?? '').replace(/\s+/g, ' ').trim().slice(0, 120);
+ return detail ? `${toolName}: ${detail}` : toolName;
+}
+
+function execFailed(result: string): boolean {
+ return /\bexit(?:ed)?(?:\s+code)?[\s:=]+[1-9]\d*\b/i.test(result);
+}
+
+/**
+ * O113: true when a tool_end result is a safety / approval / policy block
+ * (not a normal tool failure like exit 1 or missing file).
+ */
+export function isSafetyOrApprovalBlockResult(result: string | undefined): boolean {
+ if (!result) return false;
+ const head = result.slice(0, 600);
+ return (
+ /^\s*Command blocked:/i.test(head) ||
+ /^\s*Blocked dangerous command:/i.test(head) ||
+ /^\s*已拦截/u.test(head) ||
+ /^\s*Blocked\b/i.test(head) ||
+ /^\s*Tool execution denied\b/i.test(head) ||
+ /^\s*Operation blocked by workspace policy\b/i.test(head)
+ );
+}
+
+/** O103: the block came from the protected-path gate (O94 copy). */
+const PROTECTED_BLOCK_TEXT =
+ /受保护路径|protected path|会改 git 钩子目录|would redirect git hooks|下载目标由变量\/命令替换拼出|download target built from a variable/i;
+
+/** Short "what was refused" from a protected-path block result. */
+export function protectedBlockSummary(result: string): string | undefined {
+ if (!isSafetyOrApprovalBlockResult(result)) return undefined;
+ const head = result.slice(0, 600);
+ if (!PROTECTED_BLOCK_TEXT.test(head)) return undefined;
+ const body = head
+ .replace(/^\s*(?:已拦截[::]|Blocked:|Tool execution denied:)\s*/i, '')
+ .split(/,非交互|; Moss is running|。|\.\s/)[0]!
+ .replace(/\s+/g, ' ')
+ .trim();
+ return body.slice(0, 160) || undefined;
+}
+
+const BLOCK_LABEL: Record = {
+ dangerous: { en: 'dangerous command', zh: '危险命令' },
+ protected: { en: 'protected path', zh: '受保护路径' },
+ 'partial-read': { en: 'edit after partial read', zh: '局部读后编辑' },
+ 'truncated-write': { en: 'whole-file write after truncated read', zh: '截断读后整文件覆盖' },
+ hook: { en: 'hook blocked', zh: '钩子拦下' },
+ 'loop-guard': { en: 'loop guard', zh: '重复失败保护' },
+ approval: { en: 'needs approval (could not ask)', zh: '需要确认但无法询问' },
+ denied: { en: 'denied by user', zh: '用户拒绝' },
+ policy: { en: 'workspace policy', zh: '工作区策略' },
+ sandbox: { en: 'write outside the workspace', zh: '写到工作区外' },
+ other: { en: 'safety check', zh: '安全检查' },
+};
+
+/**
+ * O121: classify a finished tool call as a safety / approval refusal.
+ * Gate is structural first: the loop's `outcome` ('denied' | 'blocked') or the
+ * tool's own refusal prefix (`Command blocked:` / `Blocked dangerous command:`,
+ * which the exec tools return before running anything). Text is only used to
+ * pick the label and to drop non-safety guards (tool budget, read dedupe).
+ */
+/** O128: which successful tools resolve each gate-type block. Exec never does. */
+const RESOLVING_TOOLS: Partial>> = {
+ 'partial-read': new Set(['edit_file', 'multi_edit', 'apply_patch', 'write_file']),
+ 'truncated-write': new Set(['edit_file', 'multi_edit', 'apply_patch']),
+};
+
+function gatePathKey(raw: string): string {
+ return canonicalPath(path.resolve(process.cwd(), raw));
+}
+
+function gatePathExtra(filePath: string | undefined): Partial {
+ return filePath
+ ? { path: filePath, resolvable: true, pathKey: gatePathKey(filePath) }
+ : { resolvable: true };
+}
+
+export function classifySafetyBlock(
+ toolName: string,
+ input: Record,
+ result: string | undefined,
+ outcome: string | undefined
+): SafetyBlock | undefined {
+ const text = result ?? '';
+ const head = text.slice(0, 600);
+ const step = describeStep(toolName, input);
+ const command =
+ typeof input.command === 'string'
+ ? input.command.replace(/\s+/g, ' ').trim().slice(0, 120)
+ : undefined;
+ const filePath = typeof input.path === 'string' ? input.path : undefined;
+ const make = (kind: SafetyBlockKind, subject: string, extra: Partial = {}) => ({
+ kind,
+ step,
+ en: `${BLOCK_LABEL[kind].en} \`${subject}\``,
+ zh: `${BLOCK_LABEL[kind].zh} \`${subject}\``,
+ ...extra,
+ });
+ // O97: edit refused because only a partial window was read (edit tools
+ // return this as a plain error; the file is untouched).
+ if (
+ FILE_WRITE_TOOLS.has(toolName) &&
+ /^\s*(?:Error:\s*)?You only read a partial window of /.test(head)
+ ) {
+ return make('partial-read', filePath ?? step, gatePathExtra(filePath));
+ }
+ // O128: whole-file write_file refused because the full read was truncated.
+ if (
+ toolName === 'write_file' &&
+ /^\s*(?:Error:\s*)?`[^`]*` was truncated when read;/.test(head)
+ ) {
+ return make('truncated-write', filePath ?? step, gatePathExtra(filePath));
+ }
+ const structural = outcome === 'denied' || outcome === 'blocked';
+ const refusalPrefix =
+ /^\s*(?:已拦截[::]\s*)?(?:Command blocked:|Blocked dangerous command:)/i.test(head);
+ if (!structural && !refusalPrefix && !isSafetyOrApprovalBlockResult(text)) return undefined;
+ // Not safety: tool budget (run-budget class, O111) and loop-guard dedupe of
+ // redundant calls (`[moss-agent] Tool loop guard stopped …`).
+ if (/^\s*Tool budget reached/i.test(head)) return undefined;
+ if (/^\s*(?:已停止重试:|Stopped retrying:)/i.test(head)) return make('loop-guard', step);
+ if (/^\s*\[moss-agent\] Tool loop guard stopped/i.test(head)) return undefined;
+ if (!structural && !refusalPrefix) {
+ // Text-only matches (no structural outcome): keep the O113 set but only
+ // for explicit refusal copy.
+ if (
+ !/^\s*(?:已停止重试:|Stopped retrying:|已拦截|Blocked\b|Tool execution denied\b|Operation blocked by workspace policy\b)/i.test(
+ head
+ )
+ )
+ return undefined;
+ }
+ const what = protectedBlockSummary(text);
+ if (what) return { kind: 'protected', step, en: what, zh: what };
+ const subject = command ?? filePath ?? step;
+ if (/shell write escapes the workspace sandbox/i.test(head)) return make('sandbox', subject);
+ if (/^\s*(?:已拦截[::]\s*)?(?:Command blocked:|Blocked dangerous command:)/i.test(head))
+ return make('dangerous', subject);
+ if (/^\s*Operation blocked by workspace policy\b/i.test(head)) return make('policy', subject);
+ if (/\bhook\b|钩子/i.test(head)) return make('hook', step);
+ if (
+ /failed to start \d+ time|timed out \d+ time|was killed \d+ time|returned unusable (?:results|output)|edit-locked|which the edit tools just refused|未能启动|连续(?:超时|被信号|返回)/i.test(
+ head
+ )
+ )
+ return make('loop-guard', step);
+ if (/requires approval|needs approval|non-interactively|cannot ask|无法询问|非交互/i.test(head))
+ return make('approval', step);
+ if (/denied by user|用户拒绝/i.test(head)) return make('denied', step);
+ // Not a recognized safety / approval refusal (e.g. `Unknown tool: …`, a
+ // pre-blocked execution error) — a normal failure, not a block.
+ return undefined;
+}
+
+/** O121: 「exec 未能启动(找不到命令 `X`)」 / `exec failed to start (command `X` not found)`. */
+function loopGuardStuck(toolName: string, text: string, step: string): { en: string; zh: string } {
+ const missing = /\(missing command: ([^\s)]+)\)/.exec(text)?.[1];
+ if (missing)
+ return {
+ en: `${toolName} failed to start (command \`${missing}\` not found)`,
+ zh: `${toolName} 未能启动(找不到命令 \`${missing}\`)`,
+ };
+ if (/failed to start \d+ time/i.test(text))
+ return { en: `${toolName} failed to start`, zh: `${toolName} 未能启动` };
+ if (/timed out \d+ time/i.test(text))
+ return { en: `${toolName} timed out repeatedly`, zh: `${toolName} 连续超时` };
+ if (/was killed \d+ time/i.test(text))
+ return { en: `${toolName} was killed repeatedly`, zh: `${toolName} 连续被信号杀掉` };
+ return { en: step, zh: step };
+}
+
+/** Feed one agent event into the tracker. Safe to call for every event. */
+export function noteRunCompletionEvent(tracker: RunCompletionTracker, event: MossAgentEvent): void {
+ if (event.type === 'tool_start') {
+ tracker.pending.set(event.toolCallId, { toolName: event.toolName, input: event.input ?? {} });
+ tracker.lastStep = describeStep(event.toolName, event.input ?? {});
+ return;
+ }
+ if (event.type !== 'tool_end') return;
+ const started = tracker.pending.get(event.toolCallId);
+ tracker.pending.delete(event.toolCallId);
+ const input = started?.input ?? {};
+ const toolName = started?.toolName ?? event.toolName;
+ const failed = event.isError || (EXEC_TOOLS.has(toolName) && execFailed(event.result));
+ // O121: every safety / approval refusal goes into the block record.
+ const block = classifySafetyBlock(toolName, input, event.result, event.outcome);
+ if (block?.kind === 'loop-guard') {
+ tracker.loopGuardStops = [
+ ...(tracker.loopGuardStops ?? []),
+ { step: block.step, ...loopGuardStuck(toolName, event.result ?? '', block.step) },
+ ];
+ } else if (block) {
+ tracker.safetyBlocks = [...(tracker.safetyBlocks ?? []), block];
+ tracker.lastBlockedStep = block.step;
+ }
+ // O128 (PM): a gate-type block is resolved once the same file (realpath)
+ // is changed successfully through the gate's prescribed tools.
+ if (!failed && !block && tracker.safetyBlocks?.some((b) => b.resolvable)) {
+ const keys = new Set(rawMutationPaths(toolName, input).map(gatePathKey));
+ tracker.safetyBlocks = tracker.safetyBlocks.filter(
+ (b) =>
+ !(
+ b.resolvable &&
+ b.pathKey &&
+ keys.has(b.pathKey) &&
+ RESOLVING_TOOLS[b.kind]?.has(toolName)
+ )
+ );
+ }
+ if (failed || block) {
+ const step = describeStep(toolName, input);
+ tracker.lastFailedStep = step;
+ // O113: remember safety/approval blocks separately so the last line can say 被拦.
+ if (block?.kind !== 'loop-guard' && isSafetyOrApprovalBlockResult(event.result)) {
+ tracker.lastBlockedStep = step;
+ const what = protectedBlockSummary(event.result);
+ if (what) {
+ tracker.protectedPathBlocks = [...(tracker.protectedPathBlocks ?? []), { step, what }];
+ }
+ }
+ }
+
+ if (FILE_WRITE_TOOLS.has(toolName) && !failed) tracker.wroteFiles = true;
+ if (toolName === 'run_tests')
+ tracker.checks.set('test', { command: 'run_tests', passed: !failed });
+ if (EXEC_TOOLS.has(toolName) && typeof input.command === 'string') {
+ const command = input.command;
+ if (!failed && WRITE_LIKE_EXEC.test(command)) tracker.wroteFiles = true;
+ for (const check of checksForCommand(command)) {
+ tracker.checks.set(check, { command, passed: !failed });
+ }
+ }
+
+ // O101 layer 3: post-run protected-path modifications → incomplete.
+ if (typeof event.result === 'string') {
+ const mods = parseProtectedPathModMarker(event.result);
+ if (mods && mods.length > 0) {
+ const command = typeof input.command === 'string' ? input.command : toolName;
+ mergeProtectedPathChanges(
+ tracker,
+ mods.map((m) => ({ ...m, command })),
+ describeStep(toolName, input)
+ );
+ }
+ }
+}
+
+/**
+ * O108: fold layer-3 hits from finished `exec_background` processes into the
+ * tracker. Call before {@link resolveRunOutcome} so late exits (after the tool
+ * returned a job id) still mark the run incomplete. Dedupes by path+kind.
+ */
+export function noteBackgroundProtectedPathMods(tracker: RunCompletionTracker): void {
+ for (const snap of listBackgroundProcessSnapshots()) {
+ if (snap.status === 'running') continue;
+ const changes = snap.protectedPathChanges;
+ if (!changes || changes.length === 0) continue;
+ mergeProtectedPathChanges(
+ tracker,
+ changes.map((c) => ({ ...c, keys: c.keys ? [...c.keys] : undefined, command: snap.command })),
+ `exec_background: ${snap.command.replace(/\s+/g, ' ').trim().slice(0, 120)}`
+ );
+ }
+}
+
+function mergeProtectedPathChanges(
+ tracker: RunCompletionTracker,
+ additions: Array<{
+ path: string;
+ kind: string;
+ command?: string;
+ keys?: string[];
+ reason?: string;
+ }>,
+ failedStep?: string
+): void {
+ if (additions.length === 0) return;
+ const existing = tracker.protectedPathChanges ?? [];
+ const seen = new Set(existing.map((c) => `${c.path}\0${c.kind}`));
+ const extra = additions.filter((a) => !seen.has(`${a.path}\0${a.kind}`));
+ if (extra.length === 0) return;
+ tracker.protectedPathChanges = [...existing, ...extra];
+ if (failedStep) tracker.lastFailedStep = failedStep;
+}
+
+/**
+ * Record a MOSS_GOAL_VERIFY_CMD result on the tracker and mirror it into the
+ * check map so claim detection sees the real verification outcome.
+ */
+export function noteGoalVerifyResult(
+ tracker: RunCompletionTracker,
+ result: GoalVerifyResult
+): void {
+ tracker.goalVerify = result;
+ const step = `VERIFY_CMD: ${result.command.replace(/\s+/g, ' ').trim().slice(0, 120)}`;
+ tracker.lastStep = step;
+ if (!result.passed) tracker.lastFailedStep = step;
+ for (const check of checksForCommand(result.command)) {
+ tracker.checks.set(check, { command: result.command, passed: result.passed });
+ }
+ // A bare `npm run check`-style verify still covers the umbrella "check" claim
+ // even when checksForCommand finds nothing (e.g. custom scripts).
+ if (!checksForCommand(result.command).length) {
+ tracker.checks.set('check', { command: result.command, passed: result.passed });
+ }
+}
+
+const CLAIM_PATTERNS: Array<{ re: RegExp; requires: VerificationCheck[] }> = [
+ {
+ re: /\ball\s+(?:the\s+)?checks?\s+(?:are\s+|is\s+)?(?:green|pass(?:ed|ing)?|clean|ok)\b|(?:全部|所有)(?:的)?检查(?:都|均|全部)?(?:已)?(?:通过|是绿的|绿了|变绿)|检查(?:都|均|全部)(?:已)?(?:通过|是绿的|绿了|变绿)|检查(?:都|均)?全绿|\bnpm\s+run\s+check\b[^.\n。]*\b(?:pass(?:es|ed)?|green|succeed(?:s|ed)?|clean)\b|npm\s+run\s+check[^。\n]*(?:通过|绿)/i,
+ requires: ['check'],
+ },
+ {
+ re: /\b(?:prettier(?:\s+--check)?|format:check|formatting)\b[^.\n。]*\b(?:pass(?:es|ed)?|green|clean|ok)\b|(?:prettier|format:check|格式检查)[^。\n]*(?:通过|绿)/i,
+ requires: ['format'],
+ },
+ {
+ re: /\b(?:eslint|lint(?:ing)?)\b[^.\n。]*\b(?:pass(?:es|ed)?|green|clean|ok)\b|(?:eslint|lint)[^。\n]*(?:通过|绿)/i,
+ requires: ['lint'],
+ },
+ {
+ re: /\b(?:typecheck|tsc|type\s*check(?:ing)?)\b[^.\n。]*\b(?:pass(?:es|ed)?|green|clean|ok)\b|(?:typecheck|tsc|类型检查)[^。\n]*(?:通过|绿)/i,
+ requires: ['typecheck'],
+ },
+ {
+ re: /\b(?:all\s+)?tests?\s+(?:are\s+|now\s+)?(?:pass(?:es|ed|ing)?|green)\b|\d+\s*\/\s*\d+\s+(?:tests?\s+)?passed\b|测试(?:都|均|全部)?(?:已)?(?:通过|绿了|变绿)|\bnpm\s+(?:run\s+)?test\b[^。\n]*(?:通过|绿)/i,
+ requires: ['test'],
+ },
+];
+
+/**
+ * Success claims in `text` that the run does not back up: the matching check
+ * never ran, or its latest run failed.
+ */
+/** Explanatory / conditional framing — not a success claim (O110). */
+const CLAIM_NON_ASSERTIVE =
+ /才算|表示|意味着|说明(?:了|着)?|等.{0,16}后再|等.{0,16}后|如果|的话|\bmeans?\b|\bmust\b[\s\S]{0,40}?\bbefore\b|\bwait\s+until\b|\bbefore\s+(?:it\s+)?counts\b|\bthen\s+merge\b/i;
+
+export function findUnverifiedCheckClaims(
+ text: string,
+ tracker: RunCompletionTracker
+): VerificationCheck[] {
+ const missing = new Set();
+ const sentences = text.split(/(?<=[.!?。!?])\s+|\n+/);
+ for (const sentence of sentences) {
+ for (const { re, requires } of CLAIM_PATTERNS) {
+ const match = re.exec(sentence);
+ if (!match) continue;
+ if (/\b(?:not|n't|never)\b|未|没|尚未|无法/.test(match[0])) continue;
+ // Questions / explanations / conditionals are not claims that checks passed.
+ if (CLAIM_NON_ASSERTIVE.test(sentence)) continue;
+ for (const check of requires) {
+ const run = tracker.checks.get(check);
+ if (!run || !run.passed) missing.add(check);
+ }
+ }
+ }
+ return [...missing];
+}
+
+const CHECK_LABEL: Record = {
+ check: 'npm run check',
+ format: 'format:check',
+ lint: 'lint',
+ typecheck: 'typecheck',
+ test: 'test',
+};
+
+const EXPECTS_CHANGES =
+ /(?:修复|修好|修一下|修掉|改好|修改|实现|改成|改为|改掉|新增|添加|增加|补上|补一|重构|删掉|删除|\bfix(?:es)?\b|\bimplement\b|\brefactor\b|\bchange\b|\bupdate\b|\bedit\b|\bwrite\s+(?:a\s+)?(?:test|file|function)|\badd\s+(?:a\s+|an\s+)?(?:test|option|flag|function|file|check|support))/i;
+const QUESTION_ONLY =
+ /^(?:how|why|what|which|explain|describe|can you explain|为什么|怎么|如何|解释|说明一下|是什么)|[??]\s*$/i;
+
+/**
+ * Soft ZH change verbs (修好/改好/修一下/修掉) only count in imperative use.
+ * Strip question / hypothetical frames (能不能/是否/之后…) before matching (O110).
+ */
+const SOFT_ZH_CHANGE_NON_IMPERATIVE =
+ /(?:能不能|可不可以|是否|能否)[^。!?\n?]{0,24}?(?:修好|改好|修一下|修掉)|(?:修好|改好|修一下|修掉)(?:之后|了没有|了吗|了没)/g;
+
+/** Heuristic: does this request expect the run to change files? */
+export function requestExpectsFileChanges(request: string): boolean {
+ const text = request.trim();
+ if (!text || QUESTION_ONLY.test(text)) return false;
+ if (!EXPECTS_CHANGES.test(text)) return false;
+ // If the only hits were soft ZH verbs in Q/hypothetical frames, do not expect writes.
+ const scrubbed = text.replace(SOFT_ZH_CHANGE_NON_IMPERATIVE, ' ');
+ return EXPECTS_CHANGES.test(scrubbed);
+}
+
+/** Decide whether the run is complete; never upgrades an error run to success. */
+export function assessRunCompletion(input: AssessRunCompletionInput): RunCompletionVerdict {
+ const zh = input.zh ?? false;
+ const { tracker } = input;
+ const reasons: string[] = [];
+ const unverifiedClaims = findUnverifiedCheckClaims(input.finalText, tracker);
+
+ if (input.runError) reasons.push(input.runError);
+ if (unverifiedClaims.length > 0) {
+ const labels = unverifiedClaims.map((c) => CHECK_LABEL[c]).join(', ');
+ const failedRun = unverifiedClaims.some((c) => tracker.checks.get(c));
+ reasons.push(
+ zh
+ ? `收尾声称检查已通过,但本次运行${failedRun ? '中这些检查未通过或' : ''}未实际跑过:${labels}`
+ : `final answer claims checks passed, but this run did not actually pass: ${labels}`
+ );
+ }
+ if (input.request && requestExpectsFileChanges(input.request) && !tracker.wroteFiles) {
+ reasons.push(zh ? '没有改动任何文件' : 'no files were modified');
+ }
+
+ if (reasons.length === 0) {
+ return { status: 'complete', reasons, unverifiedClaims, exitCode: ExitCode.SUCCESS };
+ }
+ const stuckAt =
+ tracker.lastFailedStep ??
+ (unverifiedClaims.length > 0
+ ? zh
+ ? `验证(未运行 ${unverifiedClaims.map((c) => CHECK_LABEL[c]).join(', ')})`
+ : `verification (did not run ${unverifiedClaims.map((c) => CHECK_LABEL[c]).join(', ')})`
+ : tracker.lastStep);
+ const exitCode =
+ input.errorExitCode && input.errorExitCode !== 0 ? input.errorExitCode : ExitCode.INCOMPLETE;
+ return {
+ status: 'incomplete',
+ reasons,
+ ...(stuckAt ? { stuckAt } : {}),
+ unverifiedClaims,
+ exitCode,
+ };
+}
+
+/** The mandatory last line for an incomplete run; '' when complete. */
+export function formatIncompleteLine(
+ verdict: RunCompletionVerdict,
+ opts: { zh?: boolean } = {}
+): string {
+ if (verdict.status === 'complete') return '';
+ const reason = verdict.reasons
+ .join(opts.zh ? ';' : '; ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ // O111: omit 「卡在:…」 when stuckAt is not set — no placeholder.
+ const stuck = verdict.stuckAt?.replace(/\s+/g, ' ').trim() || undefined;
+ const base = opts.zh ? `未完成:${reason}` : `Incomplete: ${reason}`;
+ if (!stuck) return base;
+ return opts.zh ? `${base};卡在:${stuck}` : `${base}; stuck at: ${stuck}`;
+}
diff --git a/src/cli/run-outcome.ts b/src/cli/run-outcome.ts
new file mode 100644
index 00000000..ba4d4250
--- /dev/null
+++ b/src/cli/run-outcome.ts
@@ -0,0 +1,468 @@
+/**
+ * O98 → O99 stable run-outcome surface.
+ *
+ * Tracker / claim detection stay in {@link ./run-completion.js}; this module
+ * maps them to a single bilingual outcome O99 can read without re-deriving:
+ *
+ * { status: 'completed' | 'incomplete' | 'verify_failed' | 'blocked' | 'unverified',
+ * reason, reasonZh, exitCode }
+ *
+ * - `completed` — honest finish (formatOutcomeLine always returns「完成:…」)
+ * - `incomplete` — unfinished work (no edits when expected, run errors, …) → non-0
+ * - `verify_failed` — MOSS_GOAL_VERIFY_CMD failed / timed out / would not start → non-0
+ * - `blocked` — Stop hook blocked the run ending → non-0
+ * - `unverified` — final answer claimed checks passed without a real pass, and no
+ * VERIFY_CMD hard failure (exit ExitCode.INCOMPLETE=9 so scripts can tell
+ * it from success; last line still「未验证」/ Unverified — not Completed / verify_failed)
+ */
+import { ExitCode } from './exit-codes.js';
+import {
+ createRunCompletionTracker,
+ findUnverifiedCheckClaims,
+ noteBackgroundProtectedPathMods,
+ noteGoalVerifyResult,
+ noteRunCompletionEvent,
+ requestExpectsFileChanges,
+ type GoalVerifyResult,
+ type RunCompletionTracker,
+ type SafetyBlock,
+ type VerificationCheck,
+} from './run-completion.js';
+import {
+ protectedPathModReason,
+ type GitConfigChangeReason,
+} from '../safety/protected-path-snapshot.js';
+import { hookEventZhName } from './hook-event-names.js';
+
+export type { GoalVerifyResult, RunCompletionTracker, VerificationCheck };
+export {
+ createRunCompletionTracker,
+ noteBackgroundProtectedPathMods,
+ noteGoalVerifyResult,
+ noteRunCompletionEvent,
+};
+
+export type RunOutcomeStatus =
+ | 'completed'
+ | 'incomplete'
+ | 'verify_failed'
+ | 'blocked'
+ | 'unverified';
+export type OutcomeLang = 'zh' | 'en';
+
+export interface RunOutcome {
+ status: RunOutcomeStatus;
+ /** English reason. */
+ reason: string;
+ /** Chinese reason. */
+ reasonZh: string;
+ exitCode: number;
+ /** Where the run got stuck (English), when known. */
+ stuckAt?: string;
+ /** Where the run got stuck (Chinese), when known. */
+ stuckAtZh?: string;
+ /**
+ * O113: when true, the stuck half uses 「被拦:…」 / `Blocked by: …`
+ * (safety/approval block). Otherwise 「卡在:…」 / `stuck at: …`.
+ */
+ stuckWasBlocked?: boolean;
+}
+
+export interface ResolveRunOutcomeInput {
+ tracker: RunCompletionTracker;
+ finalText: string;
+ request?: string;
+ runError?: string;
+ errorExitCode?: number;
+ /** Stop hook blocked the run ending. */
+ blocked?: boolean;
+ blockedReason?: string;
+}
+
+const CHECK_LABEL: Record = {
+ check: 'npm run check',
+ format: 'format:check',
+ lint: 'lint',
+ typecheck: 'typecheck',
+ test: 'test',
+};
+
+function joinReasons(parts: string[], zh: boolean): string {
+ return parts
+ .filter(Boolean)
+ .join(zh ? ';' : '; ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+function goalVerifyFailReason(
+ tracker: RunCompletionTracker
+): { en: string; zh: string; stuckEn: string; stuckZh: string } | undefined {
+ const gv = tracker.goalVerify;
+ if (!gv || gv.passed) return undefined;
+ const cmd = gv.command.replace(/\s+/g, ' ').trim().slice(0, 120);
+ const detail = (gv.error ?? '').replace(/\s+/g, ' ').trim().slice(0, 200);
+ if (gv.timedOut) {
+ const en = detail
+ ? `verification command timed out: ${cmd} (${detail})`
+ : `verification command timed out: ${cmd}`;
+ const zh = detail ? `验证命令超时:${cmd}(${detail})` : `验证命令超时:${cmd}`;
+ return { en, zh, stuckEn: `VERIFY_CMD timeout: ${cmd}`, stuckZh: `VERIFY_CMD 超时:${cmd}` };
+ }
+ if (gv.spawnFailed) {
+ const en = detail
+ ? `verification command failed to start: ${cmd} (${detail})`
+ : `verification command failed to start: ${cmd}`;
+ const zh = detail ? `验证命令未能启动:${cmd}(${detail})` : `验证命令未能启动:${cmd}`;
+ return {
+ en,
+ zh,
+ stuckEn: `VERIFY_CMD spawn failed: ${cmd}`,
+ stuckZh: `VERIFY_CMD 启动失败:${cmd}`,
+ };
+ }
+ const tail = detail ? ` — ${detail}` : '';
+ const en = `model claimed checks passed, but verification failed: ${cmd} (exit ${gv.exitCode ?? 'non-zero'})${tail}`;
+ const zh = `模型声称通过,但验证失败:${cmd}(退出码 ${gv.exitCode ?? '非 0'})${tail}`;
+ return {
+ en,
+ zh,
+ stuckEn: `VERIFY_CMD: ${cmd}`,
+ stuckZh: `VERIFY_CMD:${cmd}`,
+ };
+}
+
+/** O128: last-line reason for an unresolved resolvable gate block (never 「被拦」). */
+function unresolvedGateReason(block: SafetyBlock): { en: string; zh: string } {
+ const path = block.path?.trim() || block.step;
+ if (block.kind === 'truncated-write') {
+ return {
+ en: `\`${path}\` not changed (truncated when read; use edit)`,
+ zh: `\`${path}\` 还没改(读取被截断,请改用 edit)`,
+ };
+ }
+ return {
+ en: `\`${path}\` not changed (read in full first, then edit)`,
+ zh: `\`${path}\` 还没改(先完整读取再编辑)`,
+ };
+}
+
+/** Resolve the stable O98/O99 outcome from a filled tracker + final answer. */
+export function resolveRunOutcome(input: ResolveRunOutcomeInput): RunOutcome {
+ const { tracker } = input;
+ // O108: fold L3 hits from finished exec_background processes (late exits
+ // after the tool returned a job id) before deciding incompleteness.
+ noteBackgroundProtectedPathMods(tracker);
+ const unverifiedClaims = findUnverifiedCheckClaims(input.finalText, tracker);
+ const reasonsEn: string[] = [];
+ const reasonsZh: string[] = [];
+ let status: RunOutcomeStatus | undefined;
+ let stuckAt: string | undefined;
+ let stuckAtZh: string | undefined;
+
+ if (input.blocked) {
+ status = 'blocked';
+ const reason = input.blockedReason?.trim();
+ reasonsEn.push(reason || 'Stop hook blocked the run ending');
+ // O91: the zh outcome line names the Stop hook with its Chinese display
+ // name instead of the bare English event id.
+ reasonsZh.push(reason || `${hookEventZhName('Stop')}钩子拦住了本次运行的结束`);
+ stuckAt = 'Stop hook';
+ stuckAtZh = `${hookEventZhName('Stop')}钩子`;
+ }
+
+ // O101 layer 3: protected-path post-run detection → incomplete (model
+ // success claims must not override).
+ if (tracker.protectedPathChanges && tracker.protectedPathChanges.length > 0) {
+ status = status ?? 'incomplete';
+ const mods = tracker.protectedPathChanges;
+ const reason = protectedPathModReason(
+ mods.map((m) => ({
+ path: m.path,
+ kind: (m.kind as 'added' | 'modified' | 'deleted') || 'modified',
+ ...(m.keys ? { keys: m.keys } : {}),
+ ...(m.reason ? { reason: m.reason as GitConfigChangeReason } : {}),
+ })),
+ mods[0]?.command
+ );
+ reasonsEn.push(reason.en);
+ reasonsZh.push(reason.zh);
+ stuckAt = stuckAt ?? reason.stuckEn;
+ stuckAtZh = stuckAtZh ?? reason.stuckZh;
+ }
+
+ // O103: a protected-path write refused before it ran → incomplete, driven by
+ // the block record (not by claim detection). If the model then wrote the
+ // file another way, the L3 "modified" stuck half above stays authoritative.
+ let protectedBlockStuck = false;
+ // O121: every safety / approval refusal (block record) → incomplete. One
+ // main cause on the last line, the rest as「(另有 N 处)」/`(+N more)`.
+ // O128 (PM/UX): unresolved *resolvable* gate blocks (partial-read,
+ // truncated-write) do NOT use 「被拦」 — they use「还没改(…)」. Safety-type
+ // refusals keep 「被拦」. When both remain, safety wins.
+ let skipBlockedHalf = false;
+ const blocks: SafetyBlock[] =
+ tracker.safetyBlocks && tracker.safetyBlocks.length > 0
+ ? tracker.safetyBlocks
+ : (tracker.protectedPathBlocks ?? []).map((b) => ({
+ kind: 'protected' as const,
+ step: b.step,
+ en: b.what,
+ zh: b.what,
+ }));
+ if (blocks.length > 0) {
+ status = status ?? 'incomplete';
+ const safety = blocks.filter((b) => !b.resolvable);
+ const gates = blocks.filter((b) => b.resolvable);
+ if (safety.length > 0) {
+ const main = safety[0]!;
+ const more = safety.length - 1 + gates.length;
+ const moreEn = more ? ` (+${more} more)` : '';
+ const moreZh = more ? `(另有 ${more} 处)` : '';
+ if (main.kind === 'protected') {
+ reasonsEn.push(`a protected-path write was blocked: ${main.en}${moreEn}`);
+ reasonsZh.push(`受保护路径的写入被拦下:${main.zh}${moreZh}`);
+ if (!stuckAt && !input.runError) {
+ stuckAt = main.step;
+ stuckAtZh = main.step;
+ protectedBlockStuck = true;
+ }
+ } else {
+ reasonsEn.push('a safety check refused an action');
+ reasonsZh.push('有操作被安全检查拒绝');
+ if (!stuckAt && !input.runError) {
+ stuckAt = `${main.en}${moreEn}`;
+ stuckAtZh = `${main.zh}${moreZh}`;
+ protectedBlockStuck = true;
+ }
+ }
+ } else {
+ const main = gates[0]!;
+ const more = gates.length - 1;
+ const moreEn = more ? ` (+${more} more)` : '';
+ const moreZh = more ? `(另有 ${more} 处)` : '';
+ const gate = unresolvedGateReason(main);
+ reasonsEn.push(`${gate.en}${moreEn}`);
+ reasonsZh.push(`${gate.zh}${moreZh}`);
+ skipBlockedHalf = true;
+ }
+ }
+
+ // O121 (PM): loop-guard stop → incomplete, 「卡在」 (not 被拦).
+ const guardStops: Array<{ step: string; en?: string; zh?: string }> =
+ tracker.loopGuardStops ?? [];
+ if (guardStops.length > 0) {
+ status = status ?? 'incomplete';
+ reasonsEn.push('the repeated-failure guard stopped a tool');
+ reasonsZh.push('重复失败保护停下了工具');
+ if (!stuckAt && !input.runError) {
+ stuckAt = guardStops[0]!.en ?? guardStops[0]!.step;
+ stuckAtZh = guardStops[0]!.zh ?? guardStops[0]!.step;
+ }
+ }
+
+ // Authoritative VERIFY_CMD success: the accept loop is the completion signal.
+ // Do not demote to incomplete just because the continuation turn itself
+ // edited no files (the model may have fixed things in an earlier turn).
+ if (!status && tracker.goalVerify?.passed === true && !input.runError) {
+ return {
+ status: 'completed',
+ reason: 'verification passed',
+ reasonZh: '验证已通过',
+ exitCode: ExitCode.SUCCESS,
+ };
+ }
+
+ const verifyFail = goalVerifyFailReason(tracker);
+ if (verifyFail) {
+ status = status ?? 'verify_failed';
+ reasonsEn.push(verifyFail.en);
+ reasonsZh.push(verifyFail.zh);
+ stuckAt = stuckAt ?? verifyFail.stuckEn;
+ stuckAtZh = stuckAtZh ?? verifyFail.stuckZh;
+ }
+
+ if (input.runError) {
+ status = status ?? 'incomplete';
+ reasonsEn.push(input.runError);
+ reasonsZh.push(zhRunErrorReason(input.runError));
+ }
+
+ if (input.request && requestExpectsFileChanges(input.request) && !tracker.wroteFiles) {
+ status = status ?? 'incomplete';
+ reasonsEn.push('no files were modified');
+ reasonsZh.push('没有改动任何文件');
+ }
+
+ if (unverifiedClaims.length > 0 && status !== 'verify_failed') {
+ const labels = unverifiedClaims.map((c) => CHECK_LABEL[c]).join(', ');
+ const failedRun = unverifiedClaims.some((c) => tracker.checks.get(c));
+ // Soft: no VERIFY_CMD hard failure — show「未验证」; exit is non-0 (O109).
+ if (!status) status = 'unverified';
+ reasonsEn.push(
+ failedRun
+ ? `claimed checks passed, but they did not pass in this run: ${labels}`
+ : `claimed checks passed, but no check was run: ${labels}`
+ );
+ reasonsZh.push(
+ failedRun
+ ? `声称检查已通过,但本次运行未通过:${labels}`
+ : `声称检查已通过,但没有跑检查:${labels}`
+ );
+ if (!stuckAt) {
+ stuckAt = `verification (did not run ${labels})`;
+ stuckAtZh = `验证(未运行 ${labels})`;
+ }
+ }
+
+ if (!status) {
+ const passedVerify = tracker.goalVerify?.passed === true;
+ const reasonEn = passedVerify ? 'verification passed' : 'run finished';
+ const reasonZh = passedVerify ? '验证已通过' : '运行结束';
+ return {
+ status: 'completed',
+ reason: reasonEn,
+ reasonZh,
+ exitCode: ExitCode.SUCCESS,
+ };
+ }
+
+ // O111: unfinished class (turn/budget/…) plus the last tool step when both
+ // are known, e.g. `run budget (read_file: src/a.ts)`; either alone otherwise.
+ // O113: a safety/approval block alone uses 「被拦」; budget/turn-limit classes
+ // stay 「卡在」 even when a blocked tool also ran (do not mislabel budget).
+ let stuckWasBlocked = false;
+ if (!stuckAt) {
+ const blockedStep = tracker.lastBlockedStep;
+ const step = tracker.lastFailedStep ?? tracker.lastStep;
+ const cls = input.runError ? stuckClassFromUnfinished(input.runError) : undefined;
+ if (cls && step) {
+ stuckAt = `${cls.en} (${step})`;
+ stuckAtZh = `${cls.zh}(${step})`;
+ } else if (cls) {
+ stuckAt = cls.en;
+ stuckAtZh = cls.zh;
+ } else if (blockedStep && !skipBlockedHalf) {
+ stuckAt = blockedStep;
+ stuckAtZh = blockedStep;
+ stuckWasBlocked = true;
+ } else if (step && !skipBlockedHalf) {
+ stuckAt = step;
+ stuckAtZh = step;
+ }
+ }
+
+ // O109: soft unverified alone is ExitCode.INCOMPLETE (9), same as other
+ // non-completed statuses — scripts can tell Unverified from success via exit.
+ const exitCode =
+ input.errorExitCode && input.errorExitCode !== 0 ? input.errorExitCode : ExitCode.INCOMPLETE;
+
+ return {
+ status,
+ reason: joinReasons(reasonsEn, false),
+ reasonZh: joinReasons(reasonsZh, true),
+ exitCode,
+ ...(stuckAt ? { stuckAt } : {}),
+ ...(stuckAtZh ? { stuckAtZh } : {}),
+ ...(stuckWasBlocked || protectedBlockStuck ? { stuckWasBlocked: true } : {}),
+ };
+}
+
+/**
+ * O111: concrete stuck step when known; never a placeholder stuck step.
+ * When stuckAt is absent, omit the 「卡在:…」 / `stuck at:` half entirely.
+ */
+function head(label: string, reason: string | undefined, zh: boolean): string {
+ if (!reason) return label;
+ return zh ? `${label}:${reason}` : `${label}: ${reason}`;
+}
+
+function appendStuckHalf(
+ base: string,
+ stuck: string | undefined,
+ zh: boolean,
+ blocked = false
+): string {
+ if (!stuck) return base;
+ // O113: safety/approval block → 「被拦:…」 / `Blocked by: …`
+ if (blocked) {
+ return zh ? `${base};被拦:${stuck}` : `${base}; Blocked by: ${stuck}`;
+ }
+ return zh ? `${base};卡在:${stuck}` : `${base}; stuck at: ${stuck}`;
+}
+
+/**
+ * O116: the headless budget error is an English sentence
+ * (`Run budget exceeded (tool_calls). Partial output above …`). In zh the
+ * conclusion line must not carry it verbatim — keep only the counter key as a
+ * parenthesised identifier. Other runErrors pass through unchanged.
+ */
+export function zhRunErrorReason(runError: string): string {
+ const m = /^\s*Run budget exceeded(?:\s*\(([^)]*)\))?/i.exec(runError);
+ if (!m) return runError;
+ const key = m[1]?.trim();
+ return key ? `运行预算用尽(${key})` : '运行预算用尽';
+}
+
+/**
+ * Map unfinished runError / stop subtype to a short stuck class (O111).
+ * Returns undefined when the string is not a known unfinished class.
+ */
+export function stuckClassFromUnfinished(runError: string): { en: string; zh: string } | undefined {
+ const s = runError.replace(/\s+/g, ' ').trim();
+ if (!s) return undefined;
+ const lower = s.toLowerCase();
+ if (
+ /error_max_turns|max_turns_reached|tool_followup_cap|turn limit|回合上限/.test(lower) ||
+ /hit the turn limit/.test(lower)
+ ) {
+ return { en: 'turn limit', zh: '回合上限' };
+ }
+ if (/tool_budget|工具预算|tool budget/.test(lower)) {
+ return { en: 'tool budget', zh: '工具预算' };
+ }
+ if (/error_budget|budget_|运行预算|run budget|budget exceeded/.test(lower)) {
+ return { en: 'run budget', zh: '运行预算' };
+ }
+ if (/error_output_limit|output_limit|max_tokens|输出长度|output limit/.test(lower)) {
+ return { en: 'output limit', zh: '输出长度上限' };
+ }
+ if (/aborted_by_user|用户中断|interrupted by user/.test(lower)) {
+ return { en: 'user interrupt', zh: '用户中断' };
+ }
+ return undefined;
+}
+
+/**
+ * Mandatory last line for `-p` / TUI / O99.
+ * Always non-empty: completed →「完成:…」; others → matching prefix.
+ */
+export function formatOutcomeLine(outcome: RunOutcome, lang: OutcomeLang): string {
+ const zh = lang === 'zh';
+ // O111: no placeholder reason — omit the reason when empty.
+ const reason = (zh ? outcome.reasonZh : outcome.reason)?.replace(/\s+/g, ' ').trim();
+ // O111: no placeholder stuck step — omit the stuck half when unset.
+ const stuckRaw = zh ? outcome.stuckAtZh : outcome.stuckAt;
+ const stuck = stuckRaw?.replace(/\s+/g, ' ').trim() || undefined;
+ // O113: incomplete + safety block uses 「被拦」 half; Stop-hook `blocked`
+ // status already carries 「被拦」/Blocked in the head — keep stuck half as 卡在.
+ const blockedHalf = outcome.stuckWasBlocked === true && outcome.status === 'incomplete';
+ switch (outcome.status) {
+ case 'completed':
+ return head(zh ? '完成' : 'Completed', reason, zh);
+ case 'unverified':
+ return head(zh ? '未验证' : 'Unverified', reason, zh);
+ case 'verify_failed':
+ return appendStuckHalf(head(zh ? '验证失败' : 'Verify failed', reason, zh), stuck, zh);
+ case 'blocked':
+ return appendStuckHalf(head(zh ? '被拦' : 'Blocked', reason, zh), stuck, zh);
+ case 'incomplete':
+ default:
+ return appendStuckHalf(
+ head(zh ? '未完成' : 'Incomplete', reason, zh),
+ stuck,
+ zh,
+ blockedHalf
+ );
+ }
+}
diff --git a/src/cli/run-progress.ts b/src/cli/run-progress.ts
new file mode 100644
index 00000000..816e3a75
--- /dev/null
+++ b/src/cli/run-progress.ts
@@ -0,0 +1,481 @@
+/**
+ * O99 — run progress for long tasks.
+ *
+ * Display-only: does not change tool scheduling, safety, or approvals.
+ * Progress lines go to stderr (or a sink); stdout stays for the final answer.
+ *
+ * Sources (priority):
+ * 1. tool start / end (name + file or command summary)
+ * 2. long-running tool heartbeats (tool_progress)
+ * 3. model thinking with no tool for ≥30s — 「正在推理,已等待 Ns」
+ * Heartbeat: if nothing real for 60s, emit a thinking-wait line (never silent).
+ *
+ * Conclusion footer (done / unfinished) is read-only via O98 run-outcome:
+ * {@link resolveRunOutcome} + {@link formatOutcomeLine}. Do not invent a second
+ * outcome vocabulary here.
+ */
+import type { MossAgentEvent } from '../core/index.js';
+import {
+ createRunCompletionTracker,
+ formatOutcomeLine,
+ noteRunCompletionEvent,
+ resolveRunOutcome,
+ type OutcomeLang,
+ type RunCompletionTracker,
+ type RunOutcome,
+} from './run-outcome.js';
+
+export const THINKING_WAIT_INTERVAL_MS = 30_000;
+export const HEARTBEAT_MAX_GAP_MS = 60_000;
+
+export type ProgressLineKind =
+ | 'tool_start'
+ | 'tool_end'
+ | 'tool_progress'
+ | 'thinking_wait'
+ | 'done'
+ | 'unfinished';
+
+export interface ProgressLine {
+ kind: ProgressLineKind;
+ text: string;
+ at: number;
+}
+
+export interface RunProgressSink {
+ write(line: string): void;
+}
+
+export interface RunProgressClock {
+ now(): number;
+ setInterval(handler: () => void, ms: number): { clear(): void };
+}
+
+export interface CreateRunProgressTrackerOptions {
+ sink: RunProgressSink;
+ zh?: boolean;
+ /** When false, tool_start / tool_end are not written (caller already prints them). */
+ emitToolBoundaries?: boolean;
+ thinkingWaitIntervalMs?: number;
+ heartbeatMaxGapMs?: number;
+ clock?: RunProgressClock;
+}
+
+const EXEC_LIKE = new Set(['exec', 'device_exec', 'docker_exec', 'exec_background']);
+
+function defaultClock(): RunProgressClock {
+ return {
+ now: () => Date.now(),
+ setInterval(handler, ms) {
+ const id = setInterval(handler, ms);
+ // Do not keep the Node process alive solely for progress heartbeats
+ // (oneshot/tests that forget dispose would otherwise hang).
+ if (typeof (id as NodeJS.Timeout).unref === 'function') {
+ (id as NodeJS.Timeout).unref();
+ }
+ return { clear: () => clearInterval(id) };
+ },
+ };
+}
+
+function clip(text: string, max: number): string {
+ const one = text.replace(/\s+/g, ' ').trim();
+ if (one.length <= max) return one;
+ return `${one.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
+}
+
+/** File path / command / pattern summary for a tool call. */
+export function summarizeToolTarget(toolName: string, input: Record = {}): string {
+ const pick = (...keys: string[]): string => {
+ for (const key of keys) {
+ const value = input[key];
+ if (typeof value === 'string' && value.trim()) return value.trim();
+ }
+ return '';
+ };
+
+ if (
+ toolName === 'read_file' ||
+ toolName === 'write_file' ||
+ toolName === 'edit_file' ||
+ toolName === 'move_file' ||
+ toolName === 'apply_patch'
+ ) {
+ return clip(pick('path', 'filePath', 'file_path', 'source', 'destination'), 72);
+ }
+ if (toolName === 'multi_edit' && Array.isArray(input.edits)) {
+ const paths = (input.edits as Array>)
+ .map((e) => (typeof e.path === 'string' ? e.path : ''))
+ .filter(Boolean);
+ if (paths.length === 1) return clip(paths[0]!.replace(/\\/g, '/'), 72);
+ if (paths.length > 1) {
+ return clip(`${paths[0]} +${paths.length - 1} more`.replace(/\\/g, '/'), 72);
+ }
+ }
+ if (EXEC_LIKE.has(toolName)) {
+ const command = pick('command');
+ return command ? clip(command, 72) : '';
+ }
+ if (toolName === 'search_code' || toolName === 'search_files') {
+ return clip(pick('pattern', 'query'), 40);
+ }
+ if (toolName === 'web_fetch') return clip(pick('url'), 60);
+ return clip(pick('path', 'file_path', 'command', 'pattern', 'query', 'goal', 'url'), 72);
+}
+
+export function formatToolProgressLabel(
+ toolName: string,
+ input: Record = {}
+): string {
+ const target = summarizeToolTarget(toolName, input);
+ return target ? `${toolName}(${target})` : toolName;
+}
+
+/** Bucket elapsed ms into 30s steps (30, 60, 90, …). Minimum 30. */
+export function thinkingWaitSeconds(elapsedMs: number, stepMs = THINKING_WAIT_INTERVAL_MS): number {
+ const step = Math.max(1, stepMs);
+ const n = Math.floor(elapsedMs / step) * (step / 1000);
+ return Math.max(step / 1000, n);
+}
+
+export function formatThinkingWaitLine(elapsedMs: number, zh: boolean): string {
+ const n = thinkingWaitSeconds(elapsedMs);
+ return zh ? `正在推理,已等待 ${n}s` : `Still reasoning, waited ${n}s`;
+}
+
+export function formatToolStartLine(
+ toolName: string,
+ input: Record,
+ zh: boolean
+): string {
+ const label = formatToolProgressLabel(toolName, input);
+ return zh ? `正在调用 ${label}` : `Calling ${label}`;
+}
+
+export function formatToolEndLine(
+ toolName: string,
+ input: Record,
+ zh: boolean,
+ isError: boolean
+): string {
+ const label = formatToolProgressLabel(toolName, input);
+ if (zh) return isError ? `调用失败 ${label}` : `已完成 ${label}`;
+ return isError ? `Failed ${label}` : `Finished ${label}`;
+}
+
+export function formatToolRunningLine(
+ toolName: string,
+ input: Record,
+ elapsedSec: number,
+ zh: boolean
+): string {
+ const label = formatToolProgressLabel(toolName, input);
+ const sec = Math.max(0, Math.round(elapsedSec));
+ return zh ? `正在跑 ${label} · 已 ${sec}s` : `Running ${label} · ${sec}s`;
+}
+
+/** Map a stopReason to a runError string for {@link resolveRunOutcome}. */
+export function unfinishedReasonFromStop(
+ stopReason: string | undefined,
+ zh: boolean
+): string | undefined {
+ if (!stopReason) return undefined;
+ if (stopReason === 'max_turns_reached' || stopReason === 'tool_followup_cap_reached') {
+ return zh ? '已到回合上限,任务暂停未完成' : 'hit the turn limit before finishing';
+ }
+ if (stopReason === 'tool_budget_reached') {
+ return zh ? '工具预算用尽,任务未完成' : 'tool budget reached before finishing';
+ }
+ if (stopReason?.startsWith('budget_')) {
+ return zh ? '运行预算用尽,任务未完成' : 'run budget exceeded before finishing';
+ }
+ if (stopReason === 'output_limit' || stopReason === 'max_tokens' || stopReason === 'length') {
+ return zh ? '输出长度上限,回答不完整' : 'output limit reached; answer is incomplete';
+ }
+ if (stopReason === 'aborted_by_user') {
+ return zh ? '用户中断' : 'interrupted by user';
+ }
+ if (stopReason === 'error') {
+ return zh ? '执行出错,任务未完成' : 'error during execution';
+ }
+ return undefined;
+}
+
+/**
+ * O114: footer reason when the run died on an exhausted mid-stream stall
+ * (watchdog message carries 「上游流停滞」 / "Upstream stream stalled").
+ */
+export function upstreamStallReason(
+ errorText: string | undefined,
+ zh: boolean
+): string | undefined {
+ if (!errorText || !/上游流停滞|upstream stream stalled/i.test(errorText)) return undefined;
+ return zh ? '上游流停滞,重试已用尽,任务未完成' : 'upstream stream stalled; retries exhausted';
+}
+
+export interface ProgressOutcomeInput {
+ stopReason?: string;
+ errorMessage?: string;
+ finalText?: string;
+ /** Prefer the same tracker oneshot/TUI feed; otherwise a fresh empty one. */
+ tracker?: RunCompletionTracker;
+ zh: boolean;
+}
+
+/**
+ * O98-aligned conclusion line for the progress path.
+ * Always non-empty via {@link formatOutcomeLine}.
+ */
+export function resolveProgressOutcome(input: ProgressOutcomeInput): RunOutcome {
+ const tracker = input.tracker ?? createRunCompletionTracker();
+ const runError =
+ input.errorMessage?.replace(/\s+/g, ' ').trim() ||
+ unfinishedReasonFromStop(input.stopReason, input.zh);
+ return resolveRunOutcome({
+ tracker,
+ finalText: input.finalText ?? '',
+ ...(runError ? { runError } : {}),
+ });
+}
+
+export function formatProgressOutcomeLine(input: ProgressOutcomeInput): string {
+ const lang: OutcomeLang = input.zh ? 'zh' : 'en';
+ return formatOutcomeLine(resolveProgressOutcome(input), lang);
+}
+
+export interface RunProgressTracker {
+ onEvent(event: MossAgentEvent): void;
+ /** Record activity without emitting (e.g. when another renderer already printed the tool line). */
+ noteActivity(at?: number): void;
+ dispose(): void;
+ /** Test helper: emitted lines in order. */
+ lines(): readonly ProgressLine[];
+ /**
+ * O113: the O98 completion tracker fed by onEvent — pass to
+ * {@link formatProgressOutcomeLine} so stderr conclusions share lastStep.
+ */
+ completionTracker(): RunCompletionTracker;
+}
+
+/**
+ * Tracks silence and emits thinking-wait / tool-progress lines.
+ * Empty slogans (`working…` / rotating verbs) are never emitted.
+ */
+export function createRunProgressTracker(
+ options: CreateRunProgressTrackerOptions
+): RunProgressTracker {
+ const zh = options.zh === true;
+ const emitToolBoundaries = options.emitToolBoundaries !== false;
+ const waitInterval = options.thinkingWaitIntervalMs ?? THINKING_WAIT_INTERVAL_MS;
+ const maxGap = options.heartbeatMaxGapMs ?? HEARTBEAT_MAX_GAP_MS;
+ const clock = options.clock ?? defaultClock();
+ const emitted: ProgressLine[] = [];
+ // Read-only O98 tracker so conclusion lines share resolveRunOutcome.
+ const completionTracker = createRunCompletionTracker();
+
+ let lastEmitAt = clock.now();
+ let thinkingSince: number | undefined;
+ let lastThinkingBucket = 0;
+ let activeTool:
+ | { toolCallId: string; toolName: string; input: Record }
+ | undefined;
+ let disposed = false;
+ let timer: { clear(): void } | undefined;
+
+ function emit(kind: ProgressLineKind, text: string, at = clock.now()): void {
+ // Dedup: never stack the exact same progress text ≥3 times in a row.
+ const recent = emitted.slice(-2);
+ if (recent.length === 2 && recent.every((line) => line.text === text)) {
+ return;
+ }
+ emitted.push({ kind, text, at });
+ lastEmitAt = at;
+ options.sink.write(text);
+ }
+
+ function beginThinking(at = clock.now()): void {
+ if (thinkingSince === undefined) {
+ thinkingSince = at;
+ lastThinkingBucket = 0;
+ }
+ }
+
+ function endThinking(): void {
+ thinkingSince = undefined;
+ lastThinkingBucket = 0;
+ }
+
+ function maybeEmitThinkingWait(at = clock.now()): void {
+ if (activeTool) return;
+ if (thinkingSince === undefined) return;
+ const elapsed = at - thinkingSince;
+ if (elapsed < waitInterval) return;
+ const bucket = thinkingWaitSeconds(elapsed, waitInterval);
+ if (bucket === lastThinkingBucket) {
+ // Still force a heartbeat if we have been silent too long.
+ if (at - lastEmitAt < maxGap) return;
+ }
+ lastThinkingBucket = bucket;
+ emit('thinking_wait', formatThinkingWaitLine(elapsed, zh), at);
+ }
+
+ function tick(): void {
+ if (disposed) return;
+ const at = clock.now();
+ if (activeTool) {
+ // Tool heartbeats come from tool_progress events; only fill a 60s gap.
+ if (at - lastEmitAt >= maxGap) {
+ emit(
+ 'tool_progress',
+ formatToolRunningLine(
+ activeTool.toolName,
+ activeTool.input,
+ Math.round((at - lastEmitAt) / 1000),
+ zh
+ ),
+ at
+ );
+ }
+ return;
+ }
+ if (thinkingSince === undefined) beginThinking(at);
+ maybeEmitThinkingWait(at);
+ if (at - lastEmitAt >= maxGap) {
+ maybeEmitThinkingWait(at);
+ if (at - lastEmitAt >= maxGap) {
+ const elapsed = thinkingSince !== undefined ? at - thinkingSince : maxGap;
+ emit('thinking_wait', formatThinkingWaitLine(elapsed, zh), at);
+ }
+ }
+ }
+
+ let lastErrorText: string | undefined;
+ timer = clock.setInterval(tick, Math.min(waitInterval, maxGap, 5_000));
+
+ return {
+ noteActivity(at = clock.now()) {
+ lastEmitAt = at;
+ },
+ onEvent(event: MossAgentEvent) {
+ if (disposed) return;
+ const at = clock.now();
+ noteRunCompletionEvent(completionTracker, event);
+ switch (event.type) {
+ case 'turn_start':
+ activeTool = undefined;
+ beginThinking(at);
+ // Do not emit empty "working…" — wait for a real tool or the 30s wait.
+ break;
+ case 'thinking_delta':
+ beginThinking(at);
+ lastEmitAt = at; // deltas count as activity for the 60s gap, but no line spam
+ break;
+ case 'text_delta':
+ endThinking();
+ lastEmitAt = at;
+ break;
+ case 'tool_start': {
+ endThinking();
+ activeTool = {
+ toolCallId: event.toolCallId,
+ toolName: event.toolName,
+ input: event.input,
+ };
+ if (emitToolBoundaries) {
+ emit('tool_start', formatToolStartLine(event.toolName, event.input, zh), at);
+ } else {
+ lastEmitAt = at;
+ }
+ break;
+ }
+ case 'tool_progress': {
+ const input = activeTool?.input ?? {};
+ emit(
+ 'tool_progress',
+ formatToolRunningLine(event.toolName, input, event.elapsedSec, zh),
+ at
+ );
+ break;
+ }
+ case 'tool_end': {
+ const input = activeTool?.input ?? {};
+ if (emitToolBoundaries) {
+ emit('tool_end', formatToolEndLine(event.toolName, input, zh, event.isError), at);
+ } else {
+ lastEmitAt = at;
+ }
+ activeTool = undefined;
+ beginThinking(at);
+ break;
+ }
+ case 'retry':
+ endThinking();
+ beginThinking(at);
+ lastEmitAt = at;
+ break;
+ case 'error': {
+ endThinking();
+ activeTool = undefined;
+ if (emitToolBoundaries) {
+ const errText =
+ event.errorSurface?.userMessage?.trim() ||
+ (typeof event.error === 'string' ? event.error : '');
+ lastErrorText = errText;
+ const outcome = resolveProgressOutcome({
+ errorMessage:
+ upstreamStallReason(errText, zh) || errText || (zh ? '执行出错' : 'error'),
+ tracker: completionTracker,
+ zh,
+ });
+ emit(
+ outcome.status === 'completed' ? 'done' : 'unfinished',
+ formatOutcomeLine(outcome, zh ? 'zh' : 'en'),
+ at
+ );
+ } else {
+ lastEmitAt = at;
+ }
+ break;
+ }
+ case 'done': {
+ activeTool = undefined;
+ endThinking();
+ if (!emitToolBoundaries) {
+ lastEmitAt = at;
+ break;
+ }
+ const stallReason =
+ event.result?.stopReason === 'error'
+ ? upstreamStallReason(lastErrorText, zh)
+ : undefined;
+ const outcome = resolveProgressOutcome({
+ stopReason: event.result?.stopReason,
+ ...(stallReason ? { errorMessage: stallReason } : {}),
+ finalText: event.result?.response ?? '',
+ tracker: completionTracker,
+ zh,
+ });
+ emit(
+ outcome.status === 'completed' ? 'done' : 'unfinished',
+ formatOutcomeLine(outcome, zh ? 'zh' : 'en'),
+ at
+ );
+ break;
+ }
+ default:
+ break;
+ }
+ },
+ dispose() {
+ disposed = true;
+ timer?.clear();
+ timer = undefined;
+ },
+ lines() {
+ return emitted;
+ },
+ completionTracker() {
+ return completionTracker;
+ },
+ };
+}
diff --git a/src/cli/setup-wizard.ts b/src/cli/setup-wizard.ts
index 0b647991..5f0cde0c 100644
--- a/src/cli/setup-wizard.ts
+++ b/src/cli/setup-wizard.ts
@@ -327,6 +327,7 @@ export function renderAuthStatus(
'guardrails',
'maxTurns',
'contextTokens',
+ 'reasoningBudget',
'compaction',
'modelTiers',
'warnings',
diff --git a/src/cli/status-line.ts b/src/cli/status-line.ts
index 87a42f43..6ad52431 100644
--- a/src/cli/status-line.ts
+++ b/src/cli/status-line.ts
@@ -205,7 +205,7 @@ export async function runStatusLineCommand(input: {
if (!command) return { ok: false, reason: 'empty' };
const timeoutMs = input.timeoutMs ?? DEFAULT_STATUS_COMMAND_TIMEOUT_MS;
const shell = IS_WIN ? process.env.COMSPEC || 'cmd.exe' : '/bin/sh';
- const args = IS_WIN ? ['/c', command] : ['-c', command];
+ const args = IS_WIN ? ['/d', '/s', '/c', `"${command}"`] : ['-c', command];
try {
const result = await runProcess(shell, {
args,
@@ -214,6 +214,7 @@ export async function runStatusLineCommand(input: {
env: safeChildEnv(),
stdin: JSON.stringify(input.payload ?? {}),
maxBuffer: 16_384,
+ windowsVerbatimArguments: IS_WIN,
});
const text = firstLine(result.stdout);
if (!text) return { ok: false, reason: 'empty' };
diff --git a/src/cli/tui/app.ts b/src/cli/tui/app.ts
index 699537d8..f7118591 100644
--- a/src/cli/tui/app.ts
+++ b/src/cli/tui/app.ts
@@ -66,6 +66,7 @@ import {
} from '../../core/tools/background-process-registry.js';
import { formatBackgroundCompletionFlash } from '../background-completion-ui.js';
import { redactEgress } from '../../safety/tool-output-redact.js';
+import { installNonTtyOutputDimensions } from './output-dimensions.js';
import { formatMcpStatusLine } from '../rdk-docs-mcp.js';
import {
formatBlockedTaskLine,
@@ -225,6 +226,8 @@ import {
import { installTuiLogSink } from './terminal-io.js';
import { noteFrameHeight } from './frame-invariant.js';
import { needsTestHint, type VerifyHintState } from '../verify-hint.js';
+import { createRunCompletionTracker, noteRunCompletionEvent } from '../run-completion.js';
+import { formatOutcomeLine, resolveRunOutcome } from '../run-outcome.js';
import {
createViewport,
moveTranscript,
@@ -690,6 +693,27 @@ export function TuiAppRoot({
const [activeSession, setActiveSession] = useState(options.sessionKey ?? 'tui');
const sessionKey = activeSession;
const { store } = handle;
+ const reportRuntimeError = useCallback(
+ (error: unknown): void => {
+ const message = errorMessage(error);
+ appendRow(store, 'error', message);
+ runtime.applyEvent({ type: 'error', error: message, retriable: false });
+ handle.notify();
+ },
+ [handle, runtime, store]
+ );
+ const settleRuntime = useCallback(
+ async (halted: boolean): Promise => {
+ try {
+ await runtime.endRun(halted);
+ return true;
+ } catch (error) {
+ reportRuntimeError(error);
+ return false;
+ }
+ },
+ [reportRuntimeError, runtime]
+ );
useEffect(() => {
let added = false;
for (const message of customCommandLoad.notices) {
@@ -725,6 +749,11 @@ export function TuiAppRoot({
* immediately with no user input.
*/
const windowSize = useWindowSize();
+ useEffect(() => {
+ const cleanup = installNonTtyOutputDimensions(stdout);
+ if (cleanup) stdout.emit('resize');
+ return cleanup;
+ }, [stdout]);
const columns = windowSize.columns || stdouts(stdout);
const prevColumnsRef = useRef(columns);
useEffect(() => {
@@ -1096,7 +1125,7 @@ export function TuiAppRoot({
})();
}
for (const row of options.replayRows ?? []) appendRow(store, row.kind, row.text);
- void runtime.refresh().then(() => handle.notify());
+ void runtime.refresh().then(() => handle.notify(), reportRuntimeError);
handle.notify();
}, []);
@@ -1225,8 +1254,10 @@ export function TuiAppRoot({
handle.notify();
const controller = new AbortController();
abortRef.current = controller;
+ const completionTracker = createRunCompletionTracker();
try {
for await (const event of streamTurn(message, controller.signal)) {
+ noteRunCompletionEvent(completionTracker, event);
if (event.type === 'error' && isInterruptEvent(controller, event.error)) {
// D-9: the loop reports the user's own abort as an `error` event
// ("This operation was aborted"), which used to land in the
@@ -1271,22 +1302,47 @@ export function TuiAppRoot({
runStartedAtRef.current = undefined;
const halted = controller.signal.aborted;
const unverified = !halted && needsTestHint(store.run as VerifyHintState);
+ // Capture final answer before endRun clears the live buffer (O98 TUI line).
+ const finalText = [
+ ...store.rows
+ .filter((row) => row.id >= firstRowId && row.kind === 'assistant')
+ .map((row) => row.text),
+ store.run.streamingText,
+ ]
+ .join('\n')
+ .trim();
+ const outcome = resolveRunOutcome({
+ tracker: completionTracker,
+ finalText,
+ request: message,
+ });
+ const outcomeLine = formatOutcomeLine(outcome, isZhLocale() ? 'zh' : 'en');
endRun(store, halted);
if (startedAt !== undefined) {
// Claude leaves a finalizing status line in the transcript; keep the
// same shape (`✻ Worked for 5s`) and add the run's token spend.
- const summary = renderRunSummary(Date.now() - startedAt, halted, Number.MAX_SAFE_INTEGER, {
- input: store.usage.runTokensIn,
- output: store.usage.runTokensOut,
- });
- const text = summary.find((l) => l.text.trim())?.text.trim();
- if (text) appendRow(store, 'summary', text);
+ // O98: conclusion (完成 / 未完成 / 未验证) sits on the next dim line.
+ const summary = renderRunSummary(
+ Date.now() - startedAt,
+ halted,
+ Number.MAX_SAFE_INTEGER,
+ {
+ input: store.usage.runTokensIn,
+ output: store.usage.runTokensOut,
+ },
+ new Date(),
+ outcomeLine
+ );
+ for (const entry of summary) {
+ const text = entry.text.trim();
+ if (text) appendRow(store, 'summary', text);
+ }
}
if (unverified) {
appendRow(store, 'summary', tui('edited JS/TS files but did not run tests'));
}
- await runtime.endRun(halted);
- if (startedAt !== undefined) appendTaskVerdictIfAny(startedAt);
+ const runtimeSettled = await settleRuntime(halted);
+ if (runtimeSettled && startedAt !== undefined) appendTaskVerdictIfAny(startedAt);
notifyAttention(halted ? tui('run interrupted') : tui('run finished'));
// Plan-mode exit ritual (the reference's `Ready to code?` gate): a
// finished plan-mode run that actually produced a plan — it explored
@@ -1294,6 +1350,7 @@ export function TuiAppRoot({
// An interrupted run or a quick question does not get the ceremony.
const rowsThisRun = store.rows.filter((row) => row.id >= firstRowId);
const producedPlan =
+ runtimeSettled &&
!halted &&
getCliInteractionMode() === 'plan' &&
(rowsThisRun.some((row) => row.kind === 'tool') ||
@@ -1310,6 +1367,7 @@ export function TuiAppRoot({
options.agent,
runtime,
sessionKey,
+ settleRuntime,
settleDialog,
store,
streamTurn,
@@ -1540,8 +1598,8 @@ export function TuiAppRoot({
if (unverifiedTask) {
appendRow(store, 'summary', tui('edited JS/TS files but did not run tests'));
}
- await runtime.endRun(halted);
- appendTaskVerdictIfAny(runStartedAtRef.current ?? 0);
+ const runtimeSettled = await settleRuntime(halted);
+ if (runtimeSettled) appendTaskVerdictIfAny(runStartedAtRef.current ?? 0);
runStartedAtRef.current = undefined;
absorbDeferredSteers(halted);
setStatusLine(undefined);
@@ -1580,6 +1638,7 @@ export function TuiAppRoot({
printCommandError,
runtime,
sessionKey,
+ settleRuntime,
store,
]
);
@@ -2201,6 +2260,12 @@ export function TuiAppRoot({
// The hooks subsystem is the most powerful config surface (9 events,
// blocking vetoes) and used to be manageable only by hand-editing
// JSON. The view lists what is live and where to edit it.
+ // O91: in the zh UI every event key is paired with its Chinese display
+ // name (the keys themselves stay the English config literals). Dynamic
+ // import keeps the pure name table off the shell's static graph.
+ const { hookEventCatalogLines, hookEventZhName, isMossHookEventId } =
+ await import('../hook-event-names.js');
+ const zh = isZhLocale();
const hooks = (
options.cliRuntime?.config as
| {
@@ -2220,8 +2285,10 @@ export function TuiAppRoot({
if (hooks) {
for (const [event, entries] of Object.entries(hooks)) {
for (const hook of entries ?? []) {
+ const label =
+ zh && isMossHookEventId(event) ? `${event}(${hookEventZhName(event)})` : event;
lines.push(
- `${event.padEnd(14)} ${hook.matcher ? `[${hook.matcher}] ` : ''}${hook.command}` +
+ `${label.padEnd(14)} ${hook.matcher ? `[${hook.matcher}] ` : ''}${hook.command}` +
`${hook.blocking ? ' · blocking' : ''}` +
`${hook.timeoutMs !== undefined ? ` · ${hook.timeoutMs}ms` : ''}`
);
@@ -2231,9 +2298,7 @@ export function TuiAppRoot({
if (lines.length === 0) {
lines.push(
tui('no hooks configured — add a "hooks" object to the config file:'),
- ' PreToolUse · PostToolUse · UserPromptSubmit · PermissionRequest',
- ' SessionStart · Stop · SubagentStop · PreCompact · PostCompact',
- ' SessionEnd · Notification',
+ ...hookEventCatalogLines(zh),
'each entry: { "command": "…", "matcher": "tool-glob", "timeoutMs": 5000, "blocking": true }'
);
}
@@ -2727,7 +2792,31 @@ export function TuiAppRoot({
const osc11FilterRef = useRef(createOsc11ReplyFilter());
useInput((chunk, key) => {
- const fed = osc11FilterRef.current.feed(chunk);
+ const control = Boolean(
+ key.return ||
+ key.backspace ||
+ key.delete ||
+ key.leftArrow ||
+ key.rightArrow ||
+ key.upArrow ||
+ key.downArrow ||
+ key.escape ||
+ key.tab ||
+ key.ctrl ||
+ key.meta ||
+ chunk === '\n'
+ );
+ const held = control ? osc11FilterRef.current.flushText() : '';
+ let input = composer.value;
+ if (held) {
+ input = composerInsert(composer, held).value;
+ setComposer((current) => composerInsert(current, held));
+ }
+ // Ink reports a standalone ESC as an empty chunk plus key.escape. Restore
+ // that byte only for the reply filter, so a fragmented ST terminator is
+ // consumed before it can cancel the run; ordinary Esc retains its key.
+ const filterChunk = key.escape && chunk === '' ? '\u001b' : chunk;
+ const fed = osc11FilterRef.current.feed(filterChunk);
if (fed.body !== undefined && !themeLockedByEnv() && !process.env.MOSS_TUI_THEME) {
const detected = themeFromOsc11(fed.body);
if (detected) {
@@ -2735,7 +2824,7 @@ export function TuiAppRoot({
forceUpdate();
}
}
- if (fed.passthrough !== chunk) {
+ if (fed.passthrough !== filterChunk) {
chunk = fed.passthrough;
if (chunk.length === 0) return;
}
diff --git a/src/cli/tui/osc-reply-filter.ts b/src/cli/tui/osc-reply-filter.ts
index b5d32eaa..a1f76739 100644
--- a/src/cli/tui/osc-reply-filter.ts
+++ b/src/cli/tui/osc-reply-filter.ts
@@ -29,6 +29,8 @@ export interface OscReplyFilterResult {
export interface OscReplyFilter {
feed(chunk: string): OscReplyFilterResult;
+ /** Flush a partial head as literal input before a composer control key. */
+ flushText(): string;
}
export function createOsc11ReplyFilter(): OscReplyFilter {
@@ -36,56 +38,59 @@ export function createOsc11ReplyFilter(): OscReplyFilter {
let collecting = false;
let swallowStTail = false;
- const finishPending = (): OscReplyFilterResult => {
- const term = pending.search(TERMINATOR);
- if (term !== -1) {
- const body = pending.slice(REPLY_PREFIX.length, term);
- let rest = pending.slice(term + 1);
- const termChar = pending[term];
- if (termChar === '\u001b' && rest.startsWith('\\')) rest = rest.slice(1);
- pending = '';
- collecting = false;
- swallowStTail = rest === '' && termChar === '\u001b';
- return { passthrough: rest, body: body === '' ? undefined : body };
- }
- if (pending.length > MAX_PENDING) {
- const held = pending;
- pending = '';
- collecting = false;
- return { passthrough: held };
- }
- return { passthrough: '' };
- };
-
const feed = (chunk: string): OscReplyFilterResult => {
if (swallowStTail && chunk.startsWith('\\')) {
swallowStTail = false;
chunk = chunk.slice(1);
}
swallowStTail = false;
- const acc = pending + chunk;
- if (!collecting) {
- pending = '';
+ let acc = pending + chunk;
+ pending = '';
+ let passthrough = '';
+ let body: string | undefined;
+ for (;;) {
+ if (collecting) {
+ const term = acc.search(TERMINATOR);
+ if (term === -1) {
+ if (acc.length > MAX_PENDING) {
+ collecting = false;
+ return { passthrough: passthrough + acc, body };
+ }
+ pending = acc;
+ return { passthrough, body };
+ }
+ body = acc.slice(REPLY_PREFIX.length, term) || body;
+ const termChar = acc[term];
+ acc = acc.slice(term + 1);
+ if (termChar === '\u001b' && acc.startsWith('\\')) acc = acc.slice(1);
+ swallowStTail = acc === '' && termChar === '\u001b';
+ collecting = false;
+ }
const start = acc.indexOf(REPLY_PREFIX);
if (start === -1) {
for (const part of PARTIAL_SUFFIXES) {
if (acc.endsWith(part)) {
pending = part;
- return { passthrough: acc.slice(0, acc.length - part.length) };
+ return { passthrough: passthrough + acc.slice(0, acc.length - part.length), body };
}
}
- return { passthrough: acc };
+ return { passthrough: passthrough + acc, body };
}
- pending = acc.slice(start);
collecting = true;
let head = acc.slice(0, start);
if (head.endsWith('\u001b')) head = head.slice(0, -1);
- const tail = finishPending();
- return { passthrough: head + tail.passthrough, body: tail.body };
+ passthrough += head;
+ acc = acc.slice(start);
}
- pending = acc;
- return finishPending();
};
- return { feed };
+ return {
+ feed,
+ flushText() {
+ if (collecting) return '';
+ const held = pending;
+ pending = '';
+ return held;
+ },
+ };
}
diff --git a/src/cli/tui/output-dimensions.ts b/src/cli/tui/output-dimensions.ts
new file mode 100644
index 00000000..2d108c5a
--- /dev/null
+++ b/src/cli/tui/output-dimensions.ts
@@ -0,0 +1,64 @@
+/** Avoid terminal-size subprocess probes for custom non-TTY output sinks. */
+export function installNonTtyOutputDimensions(stdout: {
+ columns?: number;
+ rows?: number;
+ isTTY?: boolean;
+}): (() => void) | undefined {
+ if (stdout === process.stdout || stdout.isTTY === true) return undefined;
+ const cleanups: Array<() => void> = [];
+ for (const [key, fallback] of [
+ ['columns', 80],
+ ['rows', 24],
+ ] as const) {
+ try {
+ if (Number.isFinite(stdout[key]) && (stdout[key] ?? 0) > 0) continue;
+ const previous = Object.getOwnPropertyDescriptor(stdout, key);
+ let effective = previous;
+ for (
+ let prototype = Object.getPrototypeOf(stdout);
+ !effective && prototype;
+ prototype = Object.getPrototypeOf(prototype)
+ ) {
+ effective = Object.getOwnPropertyDescriptor(prototype, key);
+ }
+ if (
+ (previous && !previous.configurable) ||
+ (effective && (effective.writable === false || 'get' in effective || 'set' in effective))
+ )
+ continue;
+ const get = (): number => fallback;
+ const set = (value: number): void => {
+ Object.defineProperty(stdout, key, {
+ value,
+ writable: true,
+ enumerable: previous?.enumerable ?? false,
+ configurable: true,
+ });
+ };
+ Object.defineProperty(stdout, key, {
+ get,
+ set,
+ configurable: true,
+ enumerable: previous?.enumerable ?? false,
+ });
+ cleanups.push(() => {
+ const current = Object.getOwnPropertyDescriptor(stdout, key);
+ if (current?.get !== get || current.set !== set) return;
+ if (previous) Object.defineProperty(stdout, key, previous);
+ else Reflect.deleteProperty(stdout, key);
+ });
+ } catch {
+ // Read-only or non-extensible host streams keep their own dimensions.
+ }
+ }
+ if (cleanups.length === 0) return undefined;
+ return () => {
+ for (const cleanup of cleanups) {
+ try {
+ cleanup();
+ } catch {
+ // The host may have sealed the stream after mounting.
+ }
+ }
+ };
+}
diff --git a/src/cli/tui/render-bridge.ts b/src/cli/tui/render-bridge.ts
index 5f570c0b..0c96399e 100644
--- a/src/cli/tui/render-bridge.ts
+++ b/src/cli/tui/render-bridge.ts
@@ -541,6 +541,14 @@ export function applyAgentEvent(store: TuiStore, event: MossAgentEvent): void {
store.version++;
break;
}
+ case 'tool_progress': {
+ // O99: long-running tool heartbeat — keep the live label fresh with elapsed.
+ const input = store.run.toolInputs.get(event.toolCallId) ?? {};
+ const base = toolLabel(event.toolName, input);
+ store.run.toolLine = `${base} · ${Math.max(0, Math.round(event.elapsedSec))}s`;
+ store.version++;
+ break;
+ }
case 'tool_start': {
closeThinking(store);
store.run.retry = undefined;
diff --git a/src/cli/tui/transcript.ts b/src/cli/tui/transcript.ts
index 4417d2d6..e6443a17 100644
--- a/src/cli/tui/transcript.ts
+++ b/src/cli/tui/transcript.ts
@@ -37,6 +37,7 @@ import {
} from './text.js';
import { TONE } from './theme.js';
import { statusLineParts, type StatusLineField, type StatusLinePiece } from '../status-line.js';
+import { formatThinkingWaitLine } from '../run-progress.js';
export const USER_MARK = '❯';
export const ANSWER_MARK = '⏺';
@@ -185,11 +186,12 @@ export const READONLY_PREVIEW_TOOLS = new Set([
'device_robotics_status',
]);
-/** Verbs rotate slowly so a long run still looks alive without being cute. */
-const VERBS = ['Working', 'Thinking', 'Probing', 'Checking', 'Wiring', 'Verifying'];
-
-export function runVerb(seed: number): string {
- return tui(VERBS[Math.abs(Math.trunc(seed / 3)) % VERBS.length] ?? 'Working');
+/**
+ * O99: no rotating slogan list — fake verbs do not count as progress.
+ * Prefer a real tool line; otherwise a stable working label (elapsed Ns is separate).
+ */
+export function runVerb(_seed: number): string {
+ return tui('Working');
}
export function spinnerFrame(elapsedMs: number): string {
@@ -943,15 +945,33 @@ export function renderLive(view: LiveView, width: number, verbose = false): TuiL
const tokens =
view.tokensOut > 0 ? tui(' · {count} out', { count: formatCompactCount(view.tokensOut) }) : '';
const queued = view.queued > 0 ? tui(' · {count} queued', { count: view.queued }) : '';
- const verb = view.waitingForDevice
- ? tui('Waiting for device')
- : view.thinkingActive && !view.streaming.trim()
- ? tui('Thinking')
- : runVerb(seconds);
+ // O99: real progress first — active tool label, then thinking-wait with Ns,
+ // then a stable Working label. Never rotate empty slogans.
+ let verb: string;
+ if (view.waitingForDevice) {
+ verb = tui('Waiting for device');
+ } else if (view.toolLine && view.toolLine.trim()) {
+ verb = view.toolLine.trim();
+ } else if (view.thinkingActive && !view.streaming.trim() && elapsedMs >= 30_000) {
+ verb = formatThinkingWaitLine(elapsedMs, isTuiZh());
+ } else if (view.thinkingActive && !view.streaming.trim()) {
+ verb = tui('Thinking');
+ } else if (elapsedMs >= 60_000 && !view.streaming.trim()) {
+ verb = formatThinkingWaitLine(elapsedMs, isTuiZh());
+ } else {
+ verb = runVerb(seconds);
+ }
+ const showEllipsis = !view.toolLine && !verb.includes('等待') && !verb.includes('waited');
out.push(
- line(clip(`${spinnerFrame(elapsedMs)} ${verb}… ${seconds}s${tokens}${queued}`, width), {
- color: TONE.warn,
- })
+ line(
+ clip(
+ `${spinnerFrame(elapsedMs)} ${verb}${showEllipsis ? '…' : ''} ${seconds}s${tokens}${queued}`,
+ width
+ ),
+ {
+ color: TONE.warn,
+ }
+ )
);
// Silence is information too: past the hint threshold the spinner stops
// pretending the model is thinking and says the stream has gone quiet (a
@@ -989,7 +1009,9 @@ export function renderRunSummary(
halted: boolean,
width: number,
_tokens?: { input: number; output: number },
- now: Date = new Date()
+ now: Date = new Date(),
+ /** O98 conclusion line (完成 / 未完成 / 未验证 / …); shown under the done stamp. */
+ outcomeLine?: string
): TuiLine[] {
const seconds = Math.max(1, Math.round(elapsedMs / 1000));
const verb = runVerb(seconds);
@@ -1005,7 +1027,12 @@ export function renderRunSummary(
const text = halted
? tui('✻ {verb} for {seconds}s · interrupted', { verb, seconds })
: tui('✻ worked for {seconds}s{doneAt}', { seconds, doneAt });
- return [line(''), line(clip(text, width), { dim: true })];
+ const out: TuiLine[] = [line(''), line(clip(text, width), { dim: true })];
+ const conclusion = outcomeLine?.trim();
+ if (conclusion && !halted) {
+ out.push(line(clip(conclusion, width), { dim: true }));
+ }
+ return out;
}
// ─── inline information blocks (slash commands, `?`) ─────────────────────
diff --git a/src/context/tool-output-truncate.ts b/src/context/tool-output-truncate.ts
index 6df85d74..46d29ed4 100644
--- a/src/context/tool-output-truncate.ts
+++ b/src/context/tool-output-truncate.ts
@@ -2,6 +2,15 @@ const BASE_TOOL_OUTPUT_LIMITS: Record = {
device_exec: 6_000,
device_file_read: 10_000,
read: 10_000,
+ // O97: a partial-read rejection from these tools can carry the full file
+ // (read_file's view). Give them the same budget as `read` so a ~20k-char
+ // source file arrives whole instead of with its middle cut out. Normal
+ // edit/write results are a few hundred chars and never get near it.
+ write_file: 10_000,
+ edit_file: 10_000,
+ multi_edit: 10_000,
+ move_file: 10_000,
+ apply_patch: 10_000,
web_search: 2_250,
web_fetch: 8_000,
device_diagnose: 3_000,
diff --git a/src/core/agent/command-queue.ts b/src/core/agent/command-queue.ts
index d83450bd..4d817bed 100644
--- a/src/core/agent/command-queue.ts
+++ b/src/core/agent/command-queue.ts
@@ -21,11 +21,17 @@ export interface EnqueueOpts {
export class CommandQueueRegistry {
private lanes = new Map();
+ private concurrency = new Map();
private getLaneState(lane: string): LaneState {
const existing = this.lanes.get(lane);
if (existing) return existing;
- const created: LaneState = { lane, active: 0, queue: [], maxConcurrent: 1 };
+ const created: LaneState = {
+ lane,
+ active: 0,
+ queue: [],
+ maxConcurrent: this.concurrency.get(lane) ?? 1,
+ };
this.lanes.set(lane, created);
return created;
}
@@ -61,6 +67,7 @@ export class CommandQueueRegistry {
setConcurrency(lane: string, maxConcurrent: number): void {
const state = this.getLaneState(lane);
state.maxConcurrent = Math.max(1, Math.floor(maxConcurrent));
+ this.concurrency.set(lane, state.maxConcurrent);
this.drainLane(lane);
}
@@ -93,6 +100,7 @@ export class CommandQueueRegistry {
const state = this.lanes.get(lane);
if (!state) return false;
if (state.active > 0 || state.queue.length > 0) return false;
+ // Clearing idle runtime state preserves the host's explicit concurrency policy.
return this.lanes.delete(lane);
}
}
diff --git a/src/core/agent/moss-agent-loop-adapter.ts b/src/core/agent/moss-agent-loop-adapter.ts
index efde5083..581ba26d 100644
--- a/src/core/agent/moss-agent-loop-adapter.ts
+++ b/src/core/agent/moss-agent-loop-adapter.ts
@@ -128,6 +128,15 @@ export function createMossAgentLoopEventAdapter(
},
];
}
+ case 'tool_execution_progress':
+ return [
+ {
+ type: 'tool_progress',
+ toolCallId: event.toolCallId,
+ toolName: event.toolName,
+ elapsedSec: event.elapsed_sec,
+ },
+ ];
case 'tool_execution_end': {
const content = event.content ?? event.result;
toolResults.push({
@@ -146,6 +155,7 @@ export function createMossAgentLoopEventAdapter(
toolCallId: event.toolCallId,
toolName: event.toolName,
result: event.result,
+ ...(event.content !== undefined ? { content: redactEgress(event.content) } : {}),
isError: event.isError,
...(event.outcome ? { outcome: event.outcome } : {}),
...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}),
diff --git a/src/core/agent/moss-agent-types.ts b/src/core/agent/moss-agent-types.ts
index bd8a47db..e336f0fa 100644
--- a/src/core/agent/moss-agent-types.ts
+++ b/src/core/agent/moss-agent-types.ts
@@ -230,6 +230,7 @@ export interface MossAgentConfig
steeringRules?: SteeringRule[];
completionGate?: AgentLoopExtensions['completionGate'];
+ onAgentLoopEvent?: AgentLoopExtensions['onAgentLoopEvent'];
/**
* Force buffering of assistant text until turn end (disables live
* message_delta streaming). Prefer leaving this unset: structured-output
@@ -250,6 +251,14 @@ export interface ChatOptions {
/** Limit the tools visible and executable for this run. */
toolFilter?: ToolFilter;
+ /**
+ * Host-owned device availability for this run. Unset retains CLI device
+ * discovery; false hides device tools even when a process target exists.
+ * This only controls offering: tool filters, approval and the host's
+ * authenticated device binding still govern execution.
+ */
+ deviceConfigured?: boolean;
+
extraContext?: string;
/**
@@ -330,11 +339,19 @@ export type MossAgentEvent =
| { type: 'text_delta'; delta: string }
| { type: 'thinking_delta'; delta: string }
| { type: 'tool_start'; toolName: string; toolCallId: string; input: Record }
+ | {
+ type: 'tool_progress';
+ toolName: string;
+ toolCallId: string;
+ elapsedSec: number;
+ }
| {
type: 'tool_end';
toolName: string;
toolCallId: string;
result: string;
+ /** Canonical tool output after the existing per-tool limit and egress redaction. */
+ content?: string;
isError: boolean;
outcome?: ToolResult['outcome'];
durationMs?: number;
diff --git a/src/core/agent/moss-agent.ts b/src/core/agent/moss-agent.ts
index 810bae60..7aebdec2 100644
--- a/src/core/agent/moss-agent.ts
+++ b/src/core/agent/moss-agent.ts
@@ -1022,6 +1022,7 @@ ${result.stderr ?? ''}`.trim();
this.deferredTools.isOffered(tool.name) &&
toolVisibleForRun(tool.name, {
taskFlow: ledgerVisible ? true : options?.taskFlow,
+ deviceConfigured: options?.deviceConfigured,
userQuestions,
requiresUserQuestion: tool.metadata?.requiresUserQuestion === true,
}) &&
@@ -1155,6 +1156,7 @@ ${result.stderr ?? ''}`.trim();
// Child agents inherit host coding gates (verify/todo/false-complete) so
// fan_out_subagents / create_subagent cannot skip completion honesty.
...(this.config.completionGate ? { completionGate: this.config.completionGate } : {}),
+ ...(this.config.onAgentLoopEvent ? { onAgentLoopEvent: this.config.onAgentLoopEvent } : {}),
// F23: child agents inherit the host approval gate. The runner tags each
// call with the child sessionKey/runId before consulting this policy.
...(hooks?.onBeforeToolExec
@@ -1561,6 +1563,7 @@ ${result.stderr ?? ''}`.trim();
// Buffer only when a host completionGate may rewrite or discard the answer.
shouldBufferAssistantOutput: () => this.config.bufferAssistantUntilComplete === true,
completionGate,
+ onAgentLoopEvent: this.config.onAgentLoopEvent,
// Per-instance epochs isolate same-session streams across embedded agents.
runEpochStore: this.runEpochStore,
pendingToolAborts: this.pendingToolAborts,
diff --git a/src/core/index.ts b/src/core/index.ts
index 490f0f62..ebacb211 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -58,6 +58,7 @@ export {
export type {
AgentLoopDeps,
AgentLoopExtensions,
+ AgentLoopEventObserver,
AgentLoopHardCaps,
AgentLoopIdentity,
AgentLoopParams,
diff --git a/src/core/llm/llm-provider-stream-adapter.ts b/src/core/llm/llm-provider-stream-adapter.ts
index 4fca608c..96cde3a7 100644
--- a/src/core/llm/llm-provider-stream-adapter.ts
+++ b/src/core/llm/llm-provider-stream-adapter.ts
@@ -407,6 +407,11 @@ export function createStreamFunctionFromLlmProvider(
...(isMossError(err) ? { errorDetails: mossErrorToOutcome(err) } : {}),
timestamp: Date.now(),
};
+ // O114: carry the original error in-memory (non-enumerable, so it is
+ // never persisted/serialized) so the loop can keep its identity — e.g.
+ // a retryable mid-stream stall must not be flattened into a generic
+ // non-retryable "LLM stream error".
+ Object.defineProperty(assistant, 'originalError', { value: err, enumerable: false });
stream.push({ type: 'error', reason: 'error', error: assistant });
stream.end(assistant);
}
diff --git a/src/core/loop/agent-error-outcome.ts b/src/core/loop/agent-error-outcome.ts
index 1203b545..5ac3e8e8 100644
--- a/src/core/loop/agent-error-outcome.ts
+++ b/src/core/loop/agent-error-outcome.ts
@@ -5,7 +5,11 @@ import {
mossErrorToOutcome,
type MossErrorOutcome,
} from '../../errors.js';
-import { classifyLlmError, type LlmErrorCategory } from '../../provider/llm-error-classifier.js';
+import {
+ classifyLlmError,
+ LlmRetriesExhaustedError,
+ type LlmErrorCategory,
+} from '../../provider/llm-error-classifier.js';
function codeForCategory(category: LlmErrorCategory): ErrorCode {
if (category === 'auth') return ErrorCode.PROVIDER_AUTH_FAILED;
@@ -26,7 +30,19 @@ export function toMossErrorOutcome(error: unknown): MossErrorOutcome {
? (error as { originalError?: unknown }).originalError
: undefined;
if (originalError !== undefined && originalError !== error) {
- return toMossErrorOutcome(originalError);
+ const inner = toMossErrorOutcome(originalError);
+ // O114: a mid-stream stall that already exhausted its retries is no longer
+ // recoverable. Scoped to that case only — other wrapped errors keep their
+ // own recoverable semantics (e.g. a recoverable rate-limit MossError).
+ if (
+ error instanceof LlmRetriesExhaustedError &&
+ originalError instanceof Error &&
+ originalError.name === 'PiAiFirstEventTimeoutError' &&
+ (originalError as { phase?: unknown }).phase === 'inter'
+ ) {
+ return { ...inner, recoverable: false };
+ }
+ return inner;
}
const classified = classifyLlmError(error);
return {
diff --git a/src/core/loop/agent-loop-push-guard.ts b/src/core/loop/agent-loop-push-guard.ts
index 14734afa..10da7fde 100644
--- a/src/core/loop/agent-loop-push-guard.ts
+++ b/src/core/loop/agent-loop-push-guard.ts
@@ -52,12 +52,18 @@ export function guardMiniAgentStreamPush(
stream: EventStream,
sessionKey: string,
runEpoch: number,
- store: Map = runEpochBySessionKey
+ store: Map = runEpochBySessionKey,
+ onEvent?: (event: Readonly) => void
): void {
const protoPush = stream.push.bind(stream) as (e: MiniAgentEvent) => void;
(stream as unknown as { push: (e: MiniAgentEvent) => void }).push = (e: MiniAgentEvent) => {
if ((store.get(sessionKey) ?? 0) !== runEpoch) return;
+ try {
+ onEvent?.(e);
+ } catch {
+ // Host diagnostics must not interrupt persistence or event delivery.
+ }
protoPush(e);
};
}
diff --git a/src/core/loop/agent-loop-response.ts b/src/core/loop/agent-loop-response.ts
index 56b34b00..ee61bc18 100644
--- a/src/core/loop/agent-loop-response.ts
+++ b/src/core/loop/agent-loop-response.ts
@@ -520,5 +520,11 @@ export async function processLlmResponse(
state.lastTurnEndMs = Date.now();
state.pendingMessages = toolExecution.pendingMessages;
+ if (state.taskAcceptanceCommitted) {
+ state.hasMoreToolCalls = false;
+ state.pendingMessages = [];
+ return { control: 'break' };
+ }
+
return { control: 'continue' };
}
diff --git a/src/core/loop/agent-loop-state.ts b/src/core/loop/agent-loop-state.ts
index b909b724..12599ba7 100644
--- a/src/core/loop/agent-loop-state.ts
+++ b/src/core/loop/agent-loop-state.ts
@@ -55,6 +55,7 @@ export interface AgentLoopMutableState {
promptPruneCompactionAttempted: boolean;
promptPruneCompactionSucceeded: boolean;
hasMoreToolCalls: boolean;
+ taskAcceptanceCommitted: boolean;
compactionSummary: Message | undefined;
pendingMessages: Message[];
finalText: string;
@@ -114,6 +115,7 @@ export function createInitialLoopState(): AgentLoopMutableState {
promptPruneCompactionAttempted: false,
promptPruneCompactionSucceeded: false,
hasMoreToolCalls: true,
+ taskAcceptanceCommitted: false,
compactionSummary: undefined,
pendingMessages: [],
finalText: '',
diff --git a/src/core/loop/agent-loop-stream-helpers.ts b/src/core/loop/agent-loop-stream-helpers.ts
index 60a7647e..d576db74 100644
--- a/src/core/loop/agent-loop-stream-helpers.ts
+++ b/src/core/loop/agent-loop-stream-helpers.ts
@@ -29,7 +29,13 @@ interface PiStreamEventExt {
type: string;
delta?: string;
text?: string;
- error?: { errorMessage?: string; message?: string; errorDetails?: MossErrorOutcome };
+ error?: {
+ errorMessage?: string;
+ message?: string;
+ errorDetails?: MossErrorOutcome;
+ /** In-memory only (non-enumerable), set by llm-provider-stream-adapter. */
+ originalError?: unknown;
+ };
reason?: string;
}
@@ -410,6 +416,16 @@ export async function runAgentLoopLlmTurn(
case 'error': {
const ext = event as unknown as PiStreamEventExt;
const errObj = ext.error;
+ // O114: rethrow a mid-stream stall as-is so the retry policy
+ // can classify it (phase 'inter' → retryable timeout).
+ const orig = errObj?.originalError;
+ if (
+ orig instanceof Error &&
+ orig.name === 'PiAiFirstEventTimeoutError' &&
+ (orig as { phase?: unknown }).phase === 'inter'
+ ) {
+ throw orig;
+ }
if (errObj?.errorDetails) throw mossErrorFromOutcome(errObj.errorDetails);
const errMsg =
errObj?.errorMessage ??
diff --git a/src/core/loop/agent-loop-tool-execution.ts b/src/core/loop/agent-loop-tool-execution.ts
index 6829ea4e..4718ea50 100644
--- a/src/core/loop/agent-loop-tool-execution.ts
+++ b/src/core/loop/agent-loop-tool-execution.ts
@@ -27,6 +27,7 @@ import {
type ToolLoopGuardState,
} from '../tools/tool-loop-guard.js';
import { buildBackgroundCompletionSystemText } from './background-completion.js';
+import { inAcceptanceScope } from '../task/acceptance-commit-scope.js';
const log = getRootLogger().child('agent:loop');
@@ -102,6 +103,21 @@ export function parallelToolCallsWithinBudget(
return maxToolCalls === undefined || metrics.totalToolCalls + groupSize <= maxToolCalls;
}
+/**
+ * The loop's identical-call replay lookup. O122/O128: tools marked
+ * `noReplay` (read_file) always execute, so their side state (read scope) is
+ * recorded; every other read-only tool still replays.
+ */
+export function findLoopReplay(
+ history: LLMMessage[],
+ toolName: string,
+ input: Record,
+ toolMeta: Tool['metadata'] | undefined
+): string | null {
+ if (toolMeta?.noReplay) return null;
+ return findReplayableToolResultContent(history, toolName, input, 32, toolMeta?.sideEffectClass);
+}
+
function preflightToolCall(
call: ToolCallRef,
ctx: PreflightContext,
@@ -166,13 +182,7 @@ function preflightToolCall(
}
const toolMeta = resolvedTools.find((t) => t.name === call.name)?.metadata;
- const replayed = findReplayableToolResultContent(
- ctx.historyBeforeAssistant,
- call.name,
- call.input,
- 32,
- toolMeta?.sideEffectClass
- );
+ const replayed = findLoopReplay(ctx.historyBeforeAssistant, call.name, call.input, toolMeta);
if (replayed) {
log.info('tool replay: reusing recent identical-params result', {
tool: call.name,
@@ -366,11 +376,40 @@ export async function executeAgentLoopToolCalls(
effectiveParallelSafeTools,
loadToolsMetaName
);
+ const executeScoped = async (
+ call: ToolCallRef,
+ deps: ExecuteToolCallDeps
+ ): Promise => {
+ const executed = await inAcceptanceScope(toolCtx.workspaceDir, () =>
+ executeOneToolCall(call, deps)
+ );
+ if (executed.committed) {
+ state.taskAcceptanceCommitted = true;
+ state.finalText = executed.committed.detail;
+ }
+ return executed.value;
+ };
+ const skipRemainingToolCalls = (calls: ToolCallRef[]): void => {
+ for (const skipped of calls) {
+ push({ type: 'tool_skipped', toolCallId: skipped.id, toolName: skipped.name });
+ toolResults.push({
+ type: 'tool_result',
+ tool_use_id: skipped.id,
+ name: skipped.name,
+ content: 'Task acceptance committed; skipped remaining tool call.',
+ is_error: true,
+ });
+ }
+ };
// Finish every call this assistant message already made. Steering (the
// extended tool-loop nudge included) is attached after the batch and guides
// the next turn — it does not skip or error the rest of the batch.
for (const group of toolGroups) {
+ if (state.taskAcceptanceCommitted) {
+ skipRemainingToolCalls(group.calls);
+ continue;
+ }
if (
group.parallel &&
group.calls.length > 1 &&
@@ -391,7 +430,7 @@ export async function executeAgentLoopToolCalls(
syncAssistantToolUseInput(assistantContent, execCall);
});
if (preflight) return preflight;
- return executeOneToolCall(execCall, deps).then((outcome) => {
+ return executeScoped(execCall, deps).then((outcome) => {
call.input = execCall.input;
return outcome;
});
@@ -410,20 +449,25 @@ export async function executeAgentLoopToolCalls(
recordToolOutcome(call, outcome, recordCtx, toolResults);
}
} else {
- for (const call of group.calls) {
+ for (let gi = 0; gi < group.calls.length; gi++) {
+ const call = group.calls[gi];
const preflight = preflightToolCall(call, preflightCtx, toolsForRun);
if (preflight) {
recordToolOutcome(call, preflight, recordCtx, toolResults);
continue;
}
- const outcome = await executeOneToolCall(
+ const outcome = await executeScoped(
call,
toolCallDeps(call, (input) => {
syncAssistantToolUseInput(assistantContent, { ...call, input });
})
);
recordToolOutcome(call, outcome, recordCtx, toolResults);
+ if (state.taskAcceptanceCommitted) {
+ skipRemainingToolCalls(group.calls.slice(gi + 1));
+ break;
+ }
}
}
}
@@ -478,7 +522,7 @@ export async function executeAgentLoopToolCalls(
await appendMessage(sessionKey, resultMsg);
toolResultMsgPersisted = true;
- newSteering = evaluateSteering();
+ newSteering = state.taskAcceptanceCommitted ? [] : evaluateSteering();
metrics.prepNextTurnParallelMs += Date.now() - parallelStartMs;
} finally {
if (abortSignal.aborted && !toolResultMsgPersisted) {
diff --git a/src/core/loop/agent-loop-types.ts b/src/core/loop/agent-loop-types.ts
index cfa24c71..4ed0157a 100644
--- a/src/core/loop/agent-loop-types.ts
+++ b/src/core/loop/agent-loop-types.ts
@@ -6,6 +6,7 @@ import type { ToolHookRegistry } from '../tools/tool-hooks.js';
import type { Tool, ToolContext } from '../tools/tool-types.js';
import type { SteeringEngine } from './steering.js';
import type { PendingToolAbortStore } from './pending-tool-aborts.js';
+import type { MiniAgentEvent } from '../subagent/agent-events.js';
export interface AgentLoopPlatformConfig {
parallelSafeTools?: Set;
@@ -32,6 +33,12 @@ export interface AgentLoopIdentity {
agentId: string;
}
+/** Synchronous producer-side observation, before an event reaches consumers. */
+export type AgentLoopEventObserver = (
+ event: Readonly,
+ identity: Readonly
+) => void;
+
export interface AgentLoopPromptInput {
currentMessages: Message[];
compactionSummary: Message | undefined;
@@ -100,6 +107,8 @@ export interface AgentLoopPolicy {
}
export interface AgentLoopExtensions {
+ /** Observation only; exceptions are ignored and cannot change loop execution. */
+ onAgentLoopEvent?: AgentLoopEventObserver;
getSteeringMessages?: () => Promise;
getFollowUpMessages?: () => Promise;
/** Per-agent-instance run-epoch store. When multiple MossAgent instances
diff --git a/src/core/loop/agent-loop.ts b/src/core/loop/agent-loop.ts
index eea6bb40..1fd2b1fa 100644
--- a/src/core/loop/agent-loop.ts
+++ b/src/core/loop/agent-loop.ts
@@ -174,7 +174,9 @@ export function runAgentLoop(
};
const runEpoch = bumpAgentLoopRunEpoch(sessionKey, params.runEpochStore);
- guardMiniAgentStreamPush(stream, sessionKey, runEpoch, params.runEpochStore);
+ guardMiniAgentStreamPush(stream, sessionKey, runEpoch, params.runEpochStore, (event) => {
+ params.onAgentLoopEvent?.(event, { runId, sessionKey, agentId: params.agentId });
+ });
const parallelSafeTools = platform?.parallelSafeTools ?? new Set();
const toolTimeoutMs = platform?.toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS;
@@ -211,7 +213,7 @@ export function runAgentLoop(
const runStartMs = Date.now();
const INTER_TURN_SILENCE_WINDOW = 50;
- const toolLoopGuard = createToolLoopGuardState();
+ const toolLoopGuard = createToolLoopGuardState({ workspaceDir: toolCtx.workspaceDir });
const flushAssistantBuffer = async (buffer: Message[]): Promise => {
while (buffer.length > 0) {
@@ -614,6 +616,10 @@ export function runAgentLoop(
await flushAssistantBuffer(turnAssistantBuffer);
+ if (responseResult.control === 'break' && state.taskAcceptanceCommitted) {
+ break outerLoop;
+ }
+
if (getSteeringMessages) {
const steeringMessages = await getSteeringMessages();
if (steeringMessages.length > 0) state.pendingMessages.push(...steeringMessages);
diff --git a/src/core/loop/index.ts b/src/core/loop/index.ts
index 11ca6a4b..63efc5b2 100644
--- a/src/core/loop/index.ts
+++ b/src/core/loop/index.ts
@@ -16,7 +16,7 @@ export type {
AgentLoopToolInput,
} from './agent-loop.js';
export { resolveRoutedModel } from './agent-loop-types.js';
-export type { ModelTiers } from './agent-loop-types.js';
+export type { ModelTiers, AgentLoopEventObserver } from './agent-loop-types.js';
export { PendingToolAbortStore } from './pending-tool-aborts.js';
export { CompactHookRegistry, buildCompactionCheckpointOutline } from './compact-hooks.js';
export type {
diff --git a/src/core/mcp/stdio-transport.ts b/src/core/mcp/stdio-transport.ts
index 7a8c6c6f..682387f4 100644
--- a/src/core/mcp/stdio-transport.ts
+++ b/src/core/mcp/stdio-transport.ts
@@ -9,6 +9,7 @@
* for crash diagnostics only.
*/
import fs from 'node:fs';
+import path from 'node:path';
import { spawnProcess, type ChildProcess } from '../../utils/run-process.js';
import { pinNpmUserConfig, safeChildEnv, startupChildEnv } from '../../utils/safe-child-env.js';
import { MossError, ErrorCode, errorMessage } from '../../errors.js';
@@ -109,16 +110,37 @@ export class McpStdioTransport implements McpTransport {
? pinNpmUserConfig(startupChildEnv(this.config.env))
: safeChildEnv(this.config.env ?? {});
if (this.config.cwd) fs.mkdirSync(this.config.cwd, { recursive: true });
- child = spawnProcess(command, this.config.args ?? [], {
- stdio: ['pipe', 'pipe', 'pipe'],
- // Credential-bearing env values come only from the expanded config env
- // block; the inherited parent env is sanitized by safeChildEnv.
- // startupEnvOnly children (built-in rdk-docs) also pin npm's userconfig
- // to the user's own ~/.npmrc and run outside the workspace cwd.
- env,
- cwd: this.config.cwd,
- windowsHide: true,
- });
+ // Windows npx is a .cmd shim. Run npm's JS entry through Node instead of
+ // a shell, so package paths and arguments keep their literal boundaries.
+ const npxScript =
+ process.platform === 'win32' && command === 'npx'
+ ? [...(env.PATH ?? env.Path ?? '').split(path.delimiter), path.dirname(process.execPath)]
+ .map((dir) =>
+ path.resolve(
+ this.config.cwd ?? process.cwd(),
+ dir,
+ 'node_modules',
+ 'npm',
+ 'bin',
+ 'npx-cli.js'
+ )
+ )
+ .find((candidate) => fs.existsSync(candidate))
+ : undefined;
+ child = spawnProcess(
+ npxScript ? process.execPath : command,
+ [...(npxScript ? [npxScript] : []), ...(this.config.args ?? [])],
+ {
+ stdio: ['pipe', 'pipe', 'pipe'],
+ // Credential-bearing env values come only from the expanded config env
+ // block; the inherited parent env is sanitized by safeChildEnv.
+ // startupEnvOnly children (built-in rdk-docs) also pin npm's userconfig
+ // to the user's own ~/.npmrc and run outside the workspace cwd.
+ env,
+ cwd: this.config.cwd,
+ windowsHide: true,
+ }
+ );
} catch (err) {
this._state = 'failed';
this._lastError = errorMessage(err);
diff --git a/src/core/session/session-write-lock.ts b/src/core/session/session-write-lock.ts
index 4ecfaaf1..0a1ee9d4 100644
--- a/src/core/session/session-write-lock.ts
+++ b/src/core/session/session-write-lock.ts
@@ -16,6 +16,7 @@ export interface SessionWriteLock {
}
interface SessionWriteLockOperations {
+ readOwner(ownerPath: string): Promise;
syncDirectory(directoryPath: string): Promise;
}
@@ -60,7 +61,7 @@ async function syncDirectory(directoryPath: string): Promise {
}
}
-const defaultOperations: SessionWriteLockOperations = { syncDirectory };
+const defaultOperations: SessionWriteLockOperations = { syncDirectory, readOwner };
async function writeOwnerCandidate(
filePath: string,
@@ -112,7 +113,7 @@ async function releaseOwnedPath(
token: string,
operations: SessionWriteLockOperations
): Promise {
- const current = await readOwner(ownerPath);
+ const current = await operations.readOwner(ownerPath);
if (!current || current.token !== token) {
throw new MossError({
code: ErrorCode.SESSION_PERSIST_FAILED,
@@ -139,9 +140,9 @@ async function compensatePublishedOwner(
/**
* Acquire the single recovery gate. A crashed gate owner is replaced only
- * after exact token revalidation plus an ESRCH probe. Every canonical-lock
- * operation goes through this gate, so the rename gap cannot admit a second
- * writer; contenders race to hard-link exactly one complete successor owner.
+ * after claiming that dead owner's generation, then revalidating its token
+ * and ESRCH. A stranded reclaim claim remains fail-closed: recursively
+ * reclaiming that guard would recreate the same unprotected rename race.
*/
async function tryAcquireRecoveryGate(
recoveryPath: string,
@@ -149,23 +150,36 @@ async function tryAcquireRecoveryGate(
operations: SessionWriteLockOperations
): Promise {
if (await tryCreateOwner(recoveryPath, nextOwner, operations)) return true;
- const observed = await readOwner(recoveryPath);
+ const observed = await operations.readOwner(recoveryPath);
if (!observed || processIsAlive(observed.pid)) return false;
-
- const current = await readOwner(recoveryPath);
- if (!current || current.token !== observed.token || processIsAlive(current.pid)) return false;
- const quarantinePath = `${recoveryPath}.recovered-${nextOwner.token}`;
- try {
- await fs.rename(recoveryPath, quarantinePath);
- await operations.syncDirectory(path.dirname(recoveryPath));
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
- throw error;
- }
+ const generation = crypto.createHash('sha256').update(observed.token).digest('hex');
+ const claimPath = `${recoveryPath}.reclaim-${generation}`;
+ if (!(await tryCreateOwner(claimPath, nextOwner, operations))) return false;
+ let published = false;
try {
- return await tryCreateOwner(recoveryPath, nextOwner, operations);
+ const current = await operations.readOwner(recoveryPath);
+ if (!current || current.token !== observed.token || processIsAlive(current.pid)) return false;
+ const quarantinePath = `${recoveryPath}.recovered-${nextOwner.token}`;
+ try {
+ await fs.rename(recoveryPath, quarantinePath);
+ await operations.syncDirectory(path.dirname(recoveryPath));
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
+ throw error;
+ }
+ try {
+ published = await tryCreateOwner(recoveryPath, nextOwner, operations);
+ return published;
+ } finally {
+ await fs.rm(quarantinePath, { force: true }).catch(() => {});
+ }
} finally {
- await fs.rm(quarantinePath, { force: true }).catch(() => {});
+ try {
+ await releaseOwnedPath(claimPath, nextOwner.token, operations);
+ } catch (error) {
+ if (published) await compensatePublishedOwner(recoveryPath, nextOwner.token, operations);
+ throw error;
+ }
}
}
@@ -176,7 +190,7 @@ async function tryReplaceDeadCanonical(
nextOwner: LockOwner,
operations: SessionWriteLockOperations
): Promise {
- const current = await readOwner(lockPath);
+ const current = await operations.readOwner(lockPath);
if (!current || current.token !== observed.token || processIsAlive(current.pid)) return false;
const quarantinePath = `${lockPath}.recovered-${nextOwner.token}`;
try {
@@ -256,7 +270,7 @@ export async function acquireSessionWriteLockWithOperations(params: {
let acquired = false;
try {
- const current = await readOwner(lockPath);
+ const current = await operations.readOwner(lockPath);
if (!current) {
// A present but malformed canonical owner is unknown, not absent.
try {
@@ -294,3 +308,24 @@ export async function acquireSessionWriteLockWithOperations(params: {
});
}
}
+
+/** Internal task-store reuse, including identifiable legacy pid:nonce owners. */
+export async function acquireTaskArtifactWriteLock(
+ sessionFile: string,
+ timeoutMs: number
+): Promise {
+ return acquireSessionWriteLockWithOperations({
+ sessionFile,
+ timeoutMs,
+ operations: {
+ async readOwner(ownerPath) {
+ const owner = await readOwner(ownerPath);
+ if (owner) return owner;
+ const raw = await fs.readFile(ownerPath, 'utf8').catch(() => '');
+ const match = /^([1-9]\d*):([A-Za-z0-9_-]+)$/.exec(raw.trim());
+ if (!match || !Number.isSafeInteger(Number(match[1]))) return undefined;
+ return { version: 1, pid: Number(match[1]), token: raw.trim(), createdAt: 'legacy' };
+ },
+ },
+ });
+}
diff --git a/src/core/subagent/subagent-runner.ts b/src/core/subagent/subagent-runner.ts
index 971a5737..9ed96fcf 100644
--- a/src/core/subagent/subagent-runner.ts
+++ b/src/core/subagent/subagent-runner.ts
@@ -93,6 +93,7 @@ export interface SubAgentRunnerDeps {
* so fan_out_subagents / create_subagent cannot false-complete coding work.
*/
completionGate?: import('../loop/agent-loop-types.js').AgentLoopExtensions['completionGate'];
+ onAgentLoopEvent?: import('../loop/agent-loop-types.js').AgentLoopExtensions['onAgentLoopEvent'];
/**
* Optional host approval gate. When set, every child tool call is checked
@@ -293,6 +294,7 @@ export function createSubAgentRunner(deps: SubAgentRunnerDeps): SubAgentRunner {
getToolsForRun: () => filteredTools,
toolCtx: {
workspaceDir,
+ runId: childRunId,
sessionKey: childSessionKey,
abortSignal: workPhaseController.signal,
maxSpawnDepth: deps.maxSpawnDepth ?? 1,
@@ -317,6 +319,7 @@ export function createSubAgentRunner(deps: SubAgentRunnerDeps): SubAgentRunner {
toolHooks: deps.toolHooks,
// Inherit parent coding completion gates (verify / todo / false-complete).
...(deps.completionGate ? { completionGate: deps.completionGate } : {}),
+ onAgentLoopEvent: deps.onAgentLoopEvent,
// Inherit the parent's approval gate (F23): without it the child loop
// executes mutating tools the parent was denied. Child identity is
// injected so approval audit/UI sees the sub-agent context.
@@ -447,6 +450,7 @@ export function createSubAgentRunner(deps: SubAgentRunnerDeps): SubAgentRunner {
maxOutputTokens: deps.maxOutputTokens,
platform: deps.platform,
toolHooks: deps.toolHooks,
+ onAgentLoopEvent: deps.onAgentLoopEvent,
});
for await (const event of finalizationStream) {
diff --git a/src/core/task-runtime/artifacts.ts b/src/core/task-runtime/artifacts.ts
index 911e5556..e0f33a28 100644
--- a/src/core/task-runtime/artifacts.ts
+++ b/src/core/task-runtime/artifacts.ts
@@ -14,6 +14,12 @@ import { listDeploymentRecords } from '../../device/deployment.js';
import { getRootLogger } from '../../logger.js';
import { redactEgress } from '../../safety/tool-output-redact.js';
import { ensureMossRuntimeGitignore } from '../../utils/workspace-paths.js';
+import { withTaskEventLock, withTaskArtifactReadLock } from '../task/task-store.js';
+
+import {
+ acceptanceAppendDispatched,
+ assertTaskAppendScopeOpen,
+} from '../task/acceptance-commit-scope.js';
const jsonlLog = getRootLogger().child('task-jsonl');
@@ -48,33 +54,95 @@ export interface TaskArtifacts {
* The first skip for a file warns once; later reads only debug.
*/
export async function readJsonlFile(file: string): Promise {
+ return withTaskArtifactReadLock(path.dirname(path.dirname(file)), async () => {
+ const before = await readPendingAppend(file);
+ let raw: string;
+ try {
+ raw = await fs.readFile(file, 'utf8');
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
+ throw error;
+ }
+ // An interrupted writer retains a durable prepare record. Never replay its
+ // uncertain tail, including when both the original sync and rollback failed.
+ const after = await readPendingAppend(file);
+ const pending =
+ before === undefined ? after : after === undefined ? before : Math.min(before, after);
+ if (pending !== undefined) {
+ const bytes = Buffer.from(raw);
+ if (pending > bytes.length) throw new Error(`unconfirmed task append: ${file}`);
+ raw = bytes.subarray(0, pending).toString('utf8');
+ }
+ const rows: T[] = [];
+ let skipped = 0;
+ for (const line of raw.split('\n')) {
+ if (line.trim() === '') continue;
+ try {
+ rows.push(JSON.parse(line) as T);
+ } catch {
+ skipped += 1;
+ }
+ }
+ if (skipped > 0) {
+ const key = path.resolve(file);
+ const payload = { file: key, skipped };
+ if (jsonlParseWarned.has(key)) {
+ jsonlLog.debug('skipping unparseable jsonl line', payload);
+ } else {
+ jsonlParseWarned.add(key);
+ jsonlLog.warn('skipping unparseable jsonl line', payload);
+ }
+ }
+ return rows;
+ });
+}
+
+async function readPendingAppend(file: string): Promise {
let raw: string;
try {
- raw = await fs.readFile(file, 'utf8');
- } catch {
- return [];
+ raw = await fs.readFile(`${file}.pending`, 'utf8');
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
+ throw error;
}
- const rows: T[] = [];
- let skipped = 0;
- for (const line of raw.split('\n')) {
- if (line.trim() === '') continue;
- try {
- rows.push(JSON.parse(line) as T);
- } catch {
- skipped += 1;
- }
+ const pending = JSON.parse(raw) as { originalSize?: unknown };
+ if (!Number.isSafeInteger(pending.originalSize) || Number(pending.originalSize) < 0) {
+ throw new Error(`unconfirmed task append: ${file}`);
}
- if (skipped > 0) {
- const key = path.resolve(file);
- const payload = { file: key, skipped };
- if (jsonlParseWarned.has(key)) {
- jsonlLog.debug('skipping unparseable jsonl line', payload);
- } else {
- jsonlParseWarned.add(key);
- jsonlLog.warn('skipping unparseable jsonl line', payload);
- }
+ return Number(pending.originalSize);
+}
+
+async function syncParentDirectory(file: string): Promise {
+ // Windows does not expose directory fsync through Node. File barriers still
+ // run on Windows; POSIX additionally persists directory entries.
+ if (process.platform === 'win32') return;
+ const directory = await fs.open(path.dirname(file), 'r');
+ try {
+ await directory.sync();
+ } finally {
+ await directory.close();
+ }
+}
+
+async function prepareAppend(
+ file: string,
+ originalSize: number,
+ created?: () => void
+): Promise {
+ const prepare = await fs.open(file, 'wx');
+ created?.();
+ let failed = false;
+ try {
+ await prepare.writeFile(JSON.stringify({ originalSize }), 'utf8');
+ await prepare.sync();
+ await syncParentDirectory(file);
+ } catch (error) {
+ failed = true;
+ throw error;
+ } finally {
+ if (failed) await prepare.close().catch(() => {});
+ else await prepare.close();
}
- return rows;
}
async function readJsonl(file: string): Promise {
@@ -87,9 +155,93 @@ async function readJsonl(file: string): Promise {
* share a file across processes must hold their own lock around this; the
* task-event lock is not re-entrant.
*/
-export async function appendJsonlFile(file: string, record: unknown): Promise {
+export async function appendJsonlFile(
+ file: string,
+ record: unknown,
+ signal?: AbortSignal
+): Promise {
+ signal?.throwIfAborted();
+ if ((await readPendingAppend(file)) !== undefined) {
+ throw new Error(`unconfirmed task append requires recovery: ${file}`);
+ }
await ensureTrailingNewline(file);
- await fs.appendFile(file, `${JSON.stringify(record)}\n`, 'utf8');
+ signal?.throwIfAborted();
+ const previous = await fs.stat(file).catch((error: NodeJS.ErrnoException) => {
+ if (error.code === 'ENOENT') return undefined;
+ throw error;
+ });
+ const originalSize = previous?.size ?? 0;
+ const pendingFile = `${file}.pending`;
+ const workspaceDir = path.dirname(path.dirname(file));
+ const evidence = path.basename(file) === 'evidence.jsonl';
+ let handle: Awaited> | undefined;
+ let dispatched = false;
+ let ownedPrepare = false;
+ try {
+ assertTaskAppendScopeOpen(workspaceDir, evidence);
+ await prepareAppend(pendingFile, originalSize, () => {
+ ownedPrepare = true;
+ });
+ signal?.throwIfAborted();
+ assertTaskAppendScopeOpen(workspaceDir, evidence);
+ if (path.basename(file) === 'acceptance.jsonl')
+ acceptanceAppendDispatched(path.dirname(path.dirname(file)));
+ dispatched = true;
+ await fs.appendFile(file, `${JSON.stringify(record)}\n`, 'utf8');
+ handle = await fs.open(file, 'r+');
+ await handle.sync();
+ if (!previous) await syncParentDirectory(file);
+ await handle.close();
+ handle = undefined;
+ await fs.unlink(pendingFile);
+ await syncParentDirectory(pendingFile);
+ } catch (error) {
+ if (!dispatched) {
+ // wx proves ownership, and no data append has been issued. The workspace
+ // mutex still protects the original prefix, even if prepare itself is torn.
+ if (ownedPrepare) {
+ try {
+ await fs.unlink(pendingFile);
+ await syncParentDirectory(pendingFile);
+ } catch {
+ // An uncertain directory barrier must retain a bounded recovery marker.
+ await readPendingAppend(file)
+ .then(async (pending) => {
+ if (pending === undefined) await prepareAppend(pendingFile, originalSize);
+ })
+ .catch(() => {});
+ }
+ }
+ throw error;
+ }
+ // Callers hold the workspace write lock. A failed sync must not leave a
+ // process-visible accepted record which replay would mistake for success.
+ // If the final directory barrier failed after unlink, restore the bounded
+ // prepare record before attempting compensation.
+ await readPendingAppend(file)
+ .then(async (pending) => {
+ if (pending === undefined) await prepareAppend(pendingFile, originalSize);
+ })
+ .catch(() => {});
+ const rollback = handle ?? (await fs.open(file, 'r+').catch(() => undefined));
+ try {
+ if (rollback) {
+ await rollback.truncate(originalSize);
+ await rollback.sync();
+ await fs.unlink(pendingFile).catch((unlinkError: NodeJS.ErrnoException) => {
+ if (unlinkError.code !== 'ENOENT') throw unlinkError;
+ });
+ await syncParentDirectory(pendingFile);
+ }
+ } catch {
+ // Preserve the original IO failure; failed compensation never attests PASS.
+ } finally {
+ if (rollback !== handle) await rollback?.close().catch(() => {});
+ }
+ throw error;
+ } finally {
+ await handle?.close().catch(() => {});
+ }
}
async function ensureTrailingNewline(file: string): Promise {
@@ -113,15 +265,26 @@ async function ensureTrailingNewline(file: string): Promise {
}
}
-async function appendJsonl(workspaceDir: string, name: string, record: unknown): Promise {
- ensureMossRuntimeGitignore(workspaceDir);
- const dir = path.join(workspaceDir, '.moss');
- await fs.mkdir(dir, { recursive: true });
- await appendJsonlFile(path.join(dir, name), record);
+async function appendJsonl(
+ workspaceDir: string,
+ name: string,
+ record: unknown,
+ signal?: AbortSignal
+): Promise {
+ signal?.throwIfAborted();
+ await withTaskEventLock(workspaceDir, async () => {
+ ensureMossRuntimeGitignore(workspaceDir);
+ const dir = path.join(workspaceDir, '.moss');
+ await appendJsonlFile(path.join(dir, name), record, signal);
+ });
}
-export async function appendTaskRecord(workspaceDir: string, task: TaskContract): Promise {
- await appendJsonl(workspaceDir, 'tasks.jsonl', task);
+export async function appendTaskRecord(
+ workspaceDir: string,
+ task: TaskContract,
+ signal?: AbortSignal
+): Promise {
+ await appendJsonl(workspaceDir, 'tasks.jsonl', task, signal);
}
export async function listTaskRecords(workspaceDir: string, limit = 50): Promise {
@@ -136,7 +299,16 @@ export async function appendEvidenceRecord(
workspaceDir: string,
record: EvidenceRecord
): Promise {
- await appendJsonl(workspaceDir, 'evidence.jsonl', redactEvidenceRecord(record));
+ await appendEvidenceRecordWithSignal(workspaceDir, record);
+}
+
+/** Internal harness path; the public two-argument artifact API stays unchanged. */
+export async function appendEvidenceRecordWithSignal(
+ workspaceDir: string,
+ record: EvidenceRecord,
+ signal?: AbortSignal
+): Promise {
+ await appendJsonl(workspaceDir, 'evidence.jsonl', redactEvidenceRecord(record), signal);
}
export async function listEvidenceRecords(
@@ -151,9 +323,10 @@ export async function listEvidenceRecords(
export async function appendAcceptanceVerdict(
workspaceDir: string,
- verdict: AcceptanceVerdict
+ verdict: AcceptanceVerdict,
+ signal?: AbortSignal
): Promise {
- await appendJsonl(workspaceDir, 'acceptance.jsonl', verdict);
+ await appendJsonl(workspaceDir, 'acceptance.jsonl', verdict, signal);
}
export async function listAcceptanceVerdicts(
@@ -168,11 +341,20 @@ export async function listAcceptanceVerdicts(
/** Load all task artifacts for a workspace in one pass. */
export async function loadTaskArtifacts(workspaceDir: string): Promise {
- const [tasks, evidence, deployments, acceptance] = await Promise.all([
- listTaskRecords(workspaceDir, 200),
- listEvidenceRecords(workspaceDir, 1000),
- listDeploymentRecords(workspaceDir, 100),
- listAcceptanceVerdicts(workspaceDir, 200),
- ]);
- return { tasks, evidence, deployments, acceptance };
+ return withTaskArtifactReadLock(workspaceDir, async () => {
+ const reads = [
+ listTaskRecords(workspaceDir, 200),
+ listEvidenceRecords(workspaceDir, 1000),
+ listDeploymentRecords(workspaceDir, 100),
+ listAcceptanceVerdicts(workspaceDir, 200),
+ ] as const;
+ const [tasks, evidence, deployments, acceptance] = await Promise.all(reads).catch(
+ async (error) => {
+ // Keep ownership until every sibling read has stopped using this snapshot.
+ await Promise.allSettled(reads);
+ throw error;
+ }
+ );
+ return { tasks, evidence, deployments, acceptance };
+ });
}
diff --git a/src/core/task-runtime/runtime.ts b/src/core/task-runtime/runtime.ts
index 07e0dfe9..15ff9b41 100644
--- a/src/core/task-runtime/runtime.ts
+++ b/src/core/task-runtime/runtime.ts
@@ -297,8 +297,9 @@ export class TaskRuntime {
private runStartedAt = 0;
private observations: DeviceObservation[] = [];
private activeToolCallId: string | undefined;
- private refreshing = false;
+ private refreshPromise: Promise | undefined;
private refreshQueued = false;
+ private backgroundRefreshError: unknown;
private listeners = new Set<() => void>();
/** Bumped after every completed refresh — UIs re-read projections then. */
version = 0;
@@ -320,26 +321,29 @@ export class TaskRuntime {
for (const listener of this.listeners) listener();
}
- async refresh(): Promise {
- if (this.refreshing) {
+ refresh(): Promise {
+ if (this.refreshPromise) {
this.refreshQueued = true;
- return;
+ return this.refreshPromise;
}
- this.refreshing = true;
- try {
+ const refresh = this.drainRefresh().finally(() => {
+ this.refreshPromise = undefined;
+ });
+ this.refreshPromise = refresh;
+ return refresh;
+ }
+
+ private async drainRefresh(): Promise {
+ do {
+ this.refreshQueued = false;
this.artifacts = await loadTaskArtifacts(this.workspaceDir);
const snapshots = await listTaskStateSnapshots(this.workspaceDir);
this.taskSnapshots = new Map(snapshots.map((snapshot) => [snapshot.taskId, snapshot]));
- } finally {
- this.refreshing = false;
- if (this.refreshQueued) {
- this.refreshQueued = false;
- await this.refresh();
- return;
+ if (!this.refreshQueued) {
+ this.updateFocus();
+ this.notifyChange();
}
- }
- this.updateFocus();
- this.notifyChange();
+ } while (this.refreshQueued);
}
private updateFocus(): void {
@@ -361,13 +365,22 @@ export class TaskRuntime {
this.live.currentAction = undefined;
this.live.halted = false;
this.live.lastError = undefined;
+ this.backgroundRefreshError = undefined;
}
async endRun(halted: boolean): Promise {
this.live.running = false;
this.live.currentAction = undefined;
this.live.halted = halted;
- await this.refresh();
+ if (this.backgroundRefreshError !== undefined && !this.refreshPromise) {
+ throw this.backgroundRefreshError;
+ }
+ try {
+ await this.refresh();
+ } catch (error) {
+ throw this.backgroundRefreshError ?? error;
+ }
+ if (this.backgroundRefreshError !== undefined) throw this.backgroundRefreshError;
}
setApprovalPending(pending: boolean): void {
@@ -406,7 +419,15 @@ export class TaskRuntime {
};
}
if (TASK_MUTATING_TOOLS.has(event.toolName)) {
- void this.refresh();
+ void this.refresh().catch((error: unknown) => {
+ this.backgroundRefreshError ??= error;
+ this.live.lastError = {
+ tool: event.toolName,
+ message: clip(String(error), 160),
+ at: this.now(),
+ };
+ this.notifyChange();
+ });
}
if (DEVICE_TOOLS.has(event.toolName) && !event.isError) {
const preview = observationPreview(event.result);
diff --git a/src/core/task/acceptance-authority.ts b/src/core/task/acceptance-authority.ts
new file mode 100644
index 00000000..b5630e12
--- /dev/null
+++ b/src/core/task/acceptance-authority.ts
@@ -0,0 +1,20 @@
+import { listTaskEvents } from './task-store.js';
+
+/** The persisted external oracle survives resume and agent-driven acceptance. */
+export function acceptanceCommandFromEvents(
+ events: readonly { type: string; data?: Record }[]
+): string | undefined {
+ for (let i = events.length - 1; i >= 0; i -= 1) {
+ if (events[i]?.type !== 'task_created') continue;
+ const command = events[i]?.data?.acceptanceCommand;
+ if (typeof command === 'string' && command.trim()) return command.trim();
+ }
+ return undefined;
+}
+
+export async function persistedAcceptanceCommand(
+ workspaceDir: string,
+ taskId: string
+): Promise {
+ return acceptanceCommandFromEvents(await listTaskEvents(workspaceDir, taskId));
+}
diff --git a/src/core/task/acceptance-command.ts b/src/core/task/acceptance-command.ts
index ae876337..52bafba1 100644
--- a/src/core/task/acceptance-command.ts
+++ b/src/core/task/acceptance-command.ts
@@ -108,6 +108,15 @@ async function acceptanceEnv(): Promise> {
export async function runAcceptanceCommand(
spec: AcceptanceSpec,
signal?: AbortSignal
+): Promise {
+ return runAcceptanceCommandInWorkspace(spec, signal);
+}
+
+/** Internal host-cwd-independent execution; the public runner stays compatible. */
+export async function runAcceptanceCommandInWorkspace(
+ spec: AcceptanceSpec,
+ signal?: AbortSignal,
+ workspaceDir: string | undefined = spec.workspaceDir
): Promise {
const endedAt = Date.now();
const shell = acceptanceShell(spec.command);
@@ -118,7 +127,7 @@ export async function runAcceptanceCommand(
args: shell.args,
timeout: spec.timeoutMs ?? DEFAULT_ACCEPTANCE_TIMEOUT_MS,
signal,
- ...(spec.workspaceDir ? { cwd: spec.workspaceDir } : {}),
+ ...(workspaceDir ? { cwd: workspaceDir } : {}),
...(shell.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
});
const combined = `${res.stdout}\n${res.stderr}`.trim();
diff --git a/src/core/task/acceptance-commit-scope.ts b/src/core/task/acceptance-commit-scope.ts
new file mode 100644
index 00000000..ae8a7078
--- /dev/null
+++ b/src/core/task/acceptance-commit-scope.ts
@@ -0,0 +1,88 @@
+import { AsyncLocalStorage } from 'node:async_hooks';
+import path from 'node:path';
+import type { TaskVerdict } from './verdict.js';
+
+interface AcceptanceScope {
+ workspaceDir: string;
+ committed?: TaskVerdict;
+ settlements: Promise[];
+ dispatchOpen: boolean;
+}
+const nativeSettlements = new AsyncLocalStorage<{
+ workspaceDir: string;
+ settled: Promise;
+ registered: boolean;
+}>();
+const scopes = new AsyncLocalStorage();
+const workspaceKey = (workspaceDir: string): string => {
+ const resolved = path.resolve(workspaceDir);
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
+};
+
+/** Only the native provider calls this after its real persistence barriers. */
+export function noteCommittedAcceptance(workspaceDir: string, verdict: TaskVerdict): void {
+ const scope = scopes.getStore();
+ if (scope?.workspaceDir === workspaceKey(workspaceDir)) scope.committed = verdict;
+}
+
+/** Each tool/child owns its scope; a child's commit cannot stop its parent. */
+export async function inAcceptanceScope(
+ workspaceDir: string,
+ action: () => Promise
+): Promise<{ value: T; committed?: TaskVerdict }> {
+ const scope: AcceptanceScope = {
+ workspaceDir: workspaceKey(workspaceDir),
+ settlements: [],
+ dispatchOpen: true,
+ };
+ let value: T;
+ try {
+ value = await scopes.run(scope, action);
+ } finally {
+ scope.dispatchOpen = false;
+ }
+ await Promise.all(scope.settlements);
+ return { value, ...(scope.committed ? { committed: scope.committed } : {}) };
+}
+
+/** Closed tools cannot start writes; only an already dispatched commit may settle. */
+export function assertTaskAppendScopeOpen(workspaceDir: string, evidence = false): void {
+ const key = workspaceKey(workspaceDir);
+ const scope = scopes.getStore();
+ const native = nativeSettlements.getStore();
+ if (
+ scope?.workspaceDir === key &&
+ !scope.dispatchOpen &&
+ (evidence || native?.workspaceDir !== key || !native.registered)
+ ) {
+ throw new Error('tool execution ended before native acceptance commit dispatch');
+ }
+}
+
+/** Register only native persistence already dispatched inside the workspace lock. */
+export function acceptanceAppendDispatched(workspaceDir: string): void {
+ const native = nativeSettlements.getStore();
+ const scope = scopes.getStore();
+ if (!native || native.registered || native.workspaceDir !== workspaceKey(workspaceDir)) return;
+ assertTaskAppendScopeOpen(workspaceDir);
+ native.registered = true;
+ if (scope?.workspaceDir === native.workspaceDir) scope.settlements.push(native.settled);
+}
+
+export async function inNativeAcceptanceSettlement(
+ workspaceDir: string,
+ action: () => Promise
+): Promise {
+ let finish!: () => void;
+ const settled = new Promise((resolve) => {
+ finish = resolve;
+ });
+ try {
+ return await nativeSettlements.run(
+ { workspaceDir: workspaceKey(workspaceDir), settled, registered: false },
+ action
+ );
+ } finally {
+ finish();
+ }
+}
diff --git a/src/core/task/suite-evidence.ts b/src/core/task/suite-evidence.ts
index 0622eaa9..56fa8be7 100644
--- a/src/core/task/suite-evidence.ts
+++ b/src/core/task/suite-evidence.ts
@@ -7,7 +7,7 @@
* is required so run_tests cannot close an unfinished goal.
*/
import type { EvidenceRecord } from '../../contracts/evidence.js';
-import { appendEvidenceRecord } from '../task-runtime/artifacts.js';
+import { appendEvidenceRecordWithSignal } from '../task-runtime/artifacts.js';
import { findLatestLiveTaskSnapshot } from './task-store.js';
function evidenceId(metric: string): string {
@@ -24,8 +24,10 @@ export async function recordHarnessSuiteEvidence(input: {
buildPassed?: boolean;
typecheckPassed?: boolean;
output?: string;
+ signal?: AbortSignal;
}): Promise {
try {
+ input.signal?.throwIfAborted();
const taskId =
input.taskId ??
(input.taskTurn === true
@@ -57,10 +59,11 @@ export async function recordHarnessSuiteEvidence(input: {
timestamp: base + index,
...(output ? { details: output } : {}),
};
- await appendEvidenceRecord(input.workspaceDir, record);
+ await appendEvidenceRecordWithSignal(input.workspaceDir, record, input.signal);
}
return rows.length;
} catch {
+ input.signal?.throwIfAborted();
return 0;
}
}
diff --git a/src/core/task/task-engine.ts b/src/core/task/task-engine.ts
index 901654b0..9630ba66 100644
--- a/src/core/task/task-engine.ts
+++ b/src/core/task/task-engine.ts
@@ -25,9 +25,14 @@ import {
tryAppendTaskEvent,
} from './task-store.js';
import { injectExperienceIntoPrompt } from '../experience/experience-library.js';
+import { persistedAcceptanceCommand } from './acceptance-authority.js';
import type { TaskVerdict, VerdictProvider } from './verdict.js';
import type { AgentTurnResult } from './agent-turn.js';
-import { acceptanceAlreadySatisfied, createTaskVerdictProvider } from './verdict.js';
+import {
+ acceptanceAlreadySatisfied,
+ createTaskVerdictProvider,
+ isCommittedTaskVerdict,
+} from './verdict.js';
const log = getRootLogger().child('task-engine');
@@ -220,9 +225,11 @@ async function runAgentTurn(
phase: string
): Promise<'ok' | 'budget'> {
const { stopReason } = readAgentTurnResult(await deps.runTurn(prompt, phase), deps.runTurn);
+ // Normal turns are reconciled by their caller; only a budget stop needs
+ // this extra snapshot before deciding whether to append a failure.
+ if (!stopReason?.startsWith('budget_')) return 'ok';
const snapshot = await getTaskStateSnapshot(deps.workspaceDir, state.taskId);
if (phaseIsSettled(snapshot?.phase) || deps.signal?.aborted) return 'ok';
- if (!stopReason?.startsWith('budget_')) return 'ok';
await appendTaskEvent(deps.workspaceDir, state.taskId, 'task_failed', {
detail: `run budget exceeded (${stopReason})`,
});
@@ -292,16 +299,24 @@ async function verifyRepairLoop(
detail: 'evaluating acceptance',
});
const verdict = await provider.evaluate(state.taskId, deps.signal);
+ // Cancellation while a provider awaits evidence outranks its eventual PASS.
+ if (deps.signal?.aborted && !isCommittedTaskVerdict(verdict)) continue;
state.lastVerdict = verdict;
if (verdict.passed) {
- await tryAppendTaskEvent(workspaceDir, state.taskId, 'acceptance_pass', {
- detail:
- verdict.source === 'command'
- ? 'acceptance command exited 0'
- : 'criteria met with evidence',
- acceptanceSource: verdict.source,
- });
+ await tryAppendTaskEvent(
+ workspaceDir,
+ state.taskId,
+ 'acceptance_pass',
+ {
+ detail:
+ verdict.source === 'command'
+ ? 'acceptance command exited 0'
+ : 'criteria met with evidence',
+ acceptanceSource: verdict.source,
+ },
+ isCommittedTaskVerdict(verdict) ? undefined : deps.signal
+ );
deps.onProgress?.({
taskId: state.taskId,
phase: 'accepted',
@@ -467,9 +482,20 @@ async function acceptPlanningIfSatisfied(
provider: VerdictProvider
): Promise {
const current = await getTaskStateSnapshot(deps.workspaceDir, state.taskId);
- if (!current || current.phase === 'accepted') return current?.phase === 'accepted';
+ if (!current) return false;
+ if (current.phase === 'accepted') {
+ deps.onProgress?.({
+ taskId: state.taskId,
+ phase: 'accepted',
+ turn: state.turns,
+ detail: 'acceptance passed',
+ });
+ return true;
+ }
if (!(await acceptanceAlreadySatisfied(deps.workspaceDir, state.taskId))) return false;
+ deps.signal?.throwIfAborted();
const verdict = await provider.evaluate(state.taskId, deps.signal);
+ if (!isCommittedTaskVerdict(verdict)) deps.signal?.throwIfAborted();
if (!verdict.passed) return false;
state.lastVerdict = verdict;
deps.onProgress?.({
@@ -483,7 +509,8 @@ async function acceptPlanningIfSatisfied(
state.taskId,
true,
verdict.detail,
- verdict.source
+ verdict.source,
+ isCommittedTaskVerdict(verdict) ? undefined : deps.signal
);
deps.onProgress?.({
taskId: state.taskId,
@@ -577,7 +604,7 @@ export async function runTask(
// `resumeTask` refuses to re-enter.
await tryAppendTaskEvent(deps.workspaceDir, taskId, 'task_failed', {
detail: `run crashed: ${err instanceof Error ? err.message : String(err)}`.slice(0, 200),
- });
+ }).catch(() => {}); // A failed store must not mask the original IO error.
}
throw err;
}
@@ -604,8 +631,13 @@ export async function resumeTask(deps: TaskEngineDeps, taskId: string): Promise<
) {
await appendTaskEvent(deps.workspaceDir, taskId, 'task_resumed', { detail: 'resumed by user' });
}
+ const command = await persistedAcceptanceCommand(deps.workspaceDir, taskId);
const provider =
- deps.verdictProvider ?? createTaskVerdictProvider({ workspaceDir: deps.workspaceDir });
+ deps.verdictProvider ??
+ createTaskVerdictProvider({
+ workspaceDir: deps.workspaceDir,
+ ...(command ? { command } : {}),
+ });
const state: RunLoopState = { taskId, turns: 0, repairsUsed: 0 };
try {
await verifyRepairLoop(
@@ -622,7 +654,7 @@ export async function resumeTask(deps: TaskEngineDeps, taskId: string): Promise<
if (!isTaskEventLockTimeout(err)) {
await tryAppendTaskEvent(deps.workspaceDir, taskId, 'task_failed', {
detail: `run crashed: ${err instanceof Error ? err.message : String(err)}`.slice(0, 200),
- });
+ }).catch(() => {}); // A failed store must not mask the original IO error.
}
throw err;
}
diff --git a/src/core/task/task-store.ts b/src/core/task/task-store.ts
index 8fb38465..b1460edf 100644
--- a/src/core/task/task-store.ts
+++ b/src/core/task/task-store.ts
@@ -7,6 +7,8 @@
* model asserted it. Failures and repairs are first-class JSONL records.
*/
import fs from 'node:fs/promises';
+import { AsyncLocalStorage } from 'node:async_hooks';
+import { acquireTaskArtifactWriteLock } from '../session/session-write-lock.js';
import path from 'node:path';
import { MossError, ErrorCode } from '../../errors.js';
@@ -39,6 +41,7 @@ import {
readJsonlFile,
} from '../task-runtime/artifacts.js';
import { ensureMossRuntimeGitignore } from '../../utils/workspace-paths.js';
+import { ensureDirectoryDurably } from '../session/jsonl-session-store.js';
const log = getRootLogger().child('task-store');
@@ -46,7 +49,18 @@ const EVENTS_FILE = 'task-events.jsonl';
const FAILURES_FILE = 'task-failures.jsonl';
const REPAIRS_FILE = 'task-repairs.jsonl';
const EVENT_LOCK_WAIT_MS = 5_000;
-const EVENT_LOCK_STALE_MS = 30_000;
+interface TaskLockScope {
+ key: string;
+ active: boolean;
+ parent?: TaskLockScope;
+}
+const taskLockScope = new AsyncLocalStorage();
+function ownsTaskLock(key: string): boolean {
+ for (let scope = taskLockScope.getStore(); scope; scope = scope.parent) {
+ if (scope.active && scope.key === key) return true;
+ }
+ return false;
+}
/** In-process queue so two writers in one process take the file lock in order. */
const taskEventWriteChains = new Map>();
@@ -57,7 +71,7 @@ function newId(prefix: string): string {
async function mossDir(workspaceDir: string): Promise {
const dir = path.join(workspaceDir, '.moss');
- await fs.mkdir(dir, { recursive: true });
+ await ensureDirectoryDurably(dir);
return dir;
}
@@ -65,12 +79,22 @@ async function readJsonl(file: string): Promise {
return readJsonlFile(file);
}
-async function appendJsonl(workspaceDir: string, name: string, record: unknown): Promise {
- ensureMossRuntimeGitignore(workspaceDir);
- const dir = await mossDir(workspaceDir);
- // Newline repair runs here. Task-event callers already hold the event lock
- // (withTaskEventLock). That lock is not re-entrant.
- await appendJsonlFile(path.join(dir, name), record);
+async function appendJsonl(
+ workspaceDir: string,
+ name: string,
+ record: unknown,
+ signal?: AbortSignal
+): Promise {
+ signal?.throwIfAborted();
+ const write = async (): Promise => {
+ ensureMossRuntimeGitignore(workspaceDir);
+ const dir = await mossDir(workspaceDir);
+ await appendJsonlFile(path.join(dir, name), record, signal);
+ };
+ // Event callers already hold the non-reentrant workspace lock. Other task
+ // records join it so a failed durability barrier can safely undo its append.
+ if (name === EVENTS_FILE) await write();
+ else await withTaskEventLock(workspaceDir, write);
}
// --- events -----------------------------------------------------------------
@@ -146,10 +170,12 @@ export async function appendTaskEvent(
workspaceDir: string,
taskId: string,
type: TaskEventType,
- data?: Record
+ data?: Record,
+ signal?: AbortSignal
): Promise {
+ signal?.throwIfAborted();
return withTaskEventLock(workspaceDir, () =>
- appendTaskEventUnlocked(workspaceDir, taskId, type, data)
+ appendTaskEventUnlocked(workspaceDir, taskId, type, data, signal)
);
}
@@ -157,9 +183,12 @@ async function appendTaskEventUnlocked(
workspaceDir: string,
taskId: string,
type: TaskEventType,
- data?: Record
+ data?: Record,
+ signal?: AbortSignal
): Promise {
+ signal?.throwIfAborted();
const events = await listTaskEvents(workspaceDir, taskId);
+ signal?.throwIfAborted();
const current = events.length > 0 ? replayTaskPhase(events) : 'draft';
const next = nextTaskPhase(
current,
@@ -183,7 +212,7 @@ async function appendTaskEventUnlocked(
phase: next,
...(data ? { data } : {}),
};
- await appendJsonl(workspaceDir, EVENTS_FILE, event);
+ await appendJsonl(workspaceDir, EVENTS_FILE, event, signal);
if (experienceEnabled()) {
await recordAcceptedExperience(workspaceDir, event).catch(() => {
// Optional bookkeeping must not fail the task state machine.
@@ -203,10 +232,11 @@ export async function tryAppendTaskEvent(
workspaceDir: string,
taskId: string,
type: TaskEventType,
- data?: Record
+ data?: Record,
+ signal?: AbortSignal
): Promise {
try {
- return await appendTaskEvent(workspaceDir, taskId, type, data);
+ return await appendTaskEvent(workspaceDir, taskId, type, data, signal);
} catch (err) {
if (err instanceof MossError && err.code === ErrorCode.EXECUTION_STATE_INVALID) {
return null;
@@ -238,19 +268,27 @@ export async function emitAcceptanceLifecycle(
taskId: string,
passed: boolean,
detail: string,
- source?: 'command' | 'contract'
+ source?: 'command' | 'contract',
+ signal?: AbortSignal
): Promise {
const events = await listTaskEvents(workspaceDir, taskId);
if (events.length === 0) return;
const phase = replayTaskPhase(events);
if (isTerminalTaskPhase(phase)) return;
+ signal?.throwIfAborted();
if (['ready', 'executing', 'diagnosing', 'repairing'].includes(phase)) {
- await tryAppendTaskEvent(workspaceDir, taskId, 'verification_started');
+ await tryAppendTaskEvent(workspaceDir, taskId, 'verification_started', undefined, signal);
}
- await tryAppendTaskEvent(workspaceDir, taskId, passed ? 'acceptance_pass' : 'acceptance_fail', {
- detail: detail.slice(0, 400),
- ...(source ? { acceptanceSource: source } : {}),
- });
+ await tryAppendTaskEvent(
+ workspaceDir,
+ taskId,
+ passed ? 'acceptance_pass' : 'acceptance_fail',
+ {
+ detail: detail.slice(0, 400),
+ ...(source ? { acceptanceSource: source } : {}),
+ },
+ signal
+ );
}
// --- failures & repairs -------------------------------------------------------
@@ -366,47 +404,56 @@ export async function getTaskStateSnapshot(
workspaceDir: string,
taskId: string
): Promise {
- const tasks = await listTaskRecords(workspaceDir);
- const contract = tasks.find((task) => task.taskId === taskId);
- if (!contract) return null;
-
- const [events, failures, repairs, evidence, verdicts] = await Promise.all([
- listTaskEvents(workspaceDir, taskId),
- listFailures(workspaceDir, taskId),
- listRepairs(workspaceDir, taskId),
- listEvidenceRecords(workspaceDir, 1000),
- listAcceptanceVerdicts(workspaceDir, taskId),
- ]);
-
- const folded = foldTaskEvents(events);
- const phase = folded.phase;
- const applied = folded.applied;
- const attempt = applied.filter((event) => event.type === 'verification_started').length;
- const taskEvidence = evidence.filter((record) => record.taskId === taskId);
- const aborted = phase === 'failed' && latestTaskFailureDetail(applied) === 'aborted';
- const outcome = aborted ? 'aborted' : deriveTaskOutcome(phase);
- const lastVerdict = latestAcceptanceVerdict(verdicts);
-
- return {
- taskId,
- goal: contract.goal,
- phase,
- statusView: taskStatusView(phase),
- ...(outcome ? { outcome } : {}),
- ...(contract.targetDeviceId ? { targetDeviceId: contract.targetDeviceId } : {}),
- contractStatus: contract.status,
- plan: planFromEvents(applied),
- acceptanceCriteria: contract.acceptanceCriteria,
- ...(contract.verificationPlan ? { verificationPlan: contract.verificationPlan } : {}),
- attempt,
- failures,
- repairs,
- evidenceCount: taskEvidence.length,
- ...(lastVerdict ? { lastVerdict } : {}),
- ...(phase === 'blocked' ? { blockedReason: blockedReasonFromEvents(applied) } : {}),
- createdAt: contract.createdAt,
- updatedAt: Math.max(contract.updatedAt, ...applied.map((event) => event.timestamp), 0),
- };
+ return withTaskArtifactReadLock(workspaceDir, async () => {
+ const tasks = await listTaskRecords(workspaceDir);
+ const contract = tasks.find((task) => task.taskId === taskId);
+ if (!contract) return null;
+
+ const reads = [
+ listTaskEvents(workspaceDir, taskId),
+ listFailures(workspaceDir, taskId),
+ listRepairs(workspaceDir, taskId),
+ listEvidenceRecords(workspaceDir, 1000),
+ listAcceptanceVerdicts(workspaceDir, taskId),
+ ] as const;
+ const [events, failures, repairs, evidence, verdicts] = await Promise.all(reads).catch(
+ async (error) => {
+ // Keep ownership until every sibling read has stopped using this snapshot.
+ await Promise.allSettled(reads);
+ throw error;
+ }
+ );
+
+ const folded = foldTaskEvents(events);
+ const phase = folded.phase;
+ const applied = folded.applied;
+ const attempt = applied.filter((event) => event.type === 'verification_started').length;
+ const taskEvidence = evidence.filter((record) => record.taskId === taskId);
+ const aborted = phase === 'failed' && latestTaskFailureDetail(applied) === 'aborted';
+ const outcome = aborted ? 'aborted' : deriveTaskOutcome(phase);
+ const lastVerdict = latestAcceptanceVerdict(verdicts);
+
+ return {
+ taskId,
+ goal: contract.goal,
+ phase,
+ statusView: taskStatusView(phase),
+ ...(outcome ? { outcome } : {}),
+ ...(contract.targetDeviceId ? { targetDeviceId: contract.targetDeviceId } : {}),
+ contractStatus: contract.status,
+ plan: planFromEvents(applied),
+ acceptanceCriteria: contract.acceptanceCriteria,
+ ...(contract.verificationPlan ? { verificationPlan: contract.verificationPlan } : {}),
+ attempt,
+ failures,
+ repairs,
+ evidenceCount: taskEvidence.length,
+ ...(lastVerdict ? { lastVerdict } : {}),
+ ...(phase === 'blocked' ? { blockedReason: blockedReasonFromEvents(applied) } : {}),
+ createdAt: contract.createdAt,
+ updatedAt: Math.max(contract.updatedAt, ...applied.map((event) => event.timestamp), 0),
+ };
+ });
}
export async function listTaskStateSnapshots(workspaceDir: string): Promise {
@@ -599,15 +646,6 @@ export function isTaskSettled(snapshot: TaskStateSnapshot): boolean {
return isTerminalTaskPhase(snapshot.phase);
}
-const RETRYABLE_LOCK_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
-
-/** Tokens this process currently holds. A matching lock file is not stale. */
-const heldLockTokens = new Set();
-
-function newLockToken(): string {
- return `${process.pid}:${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
-}
-
/**
* One queue per real directory. A symlink or (on Windows) a different case
* must not take a second queue and then delete the holder's lock as stale.
@@ -622,8 +660,7 @@ async function taskEventQueueKey(workspaceDir: string): Promise {
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}
-async function enqueueTaskEventWrite(workspaceDir: string, fn: () => Promise): Promise {
- const key = await taskEventQueueKey(workspaceDir);
+async function enqueueTaskEventWrite(key: string, fn: () => Promise): Promise {
const previous = taskEventWriteChains.get(key) ?? Promise.resolve();
const run = previous.then(fn, fn);
taskEventWriteChains.set(
@@ -636,134 +673,53 @@ async function enqueueTaskEventWrite(workspaceDir: string, fn: () => Promise<
return run;
}
-/**
- * Cross-process lock around task-events.jsonl reads and appends.
- *
- * Not re-entrant. Do not call appendTaskEvent (or anything else that takes
- * this lock) from inside the locked callback: the in-process queue waits on
- * the outer write. The lock body is `pid:nonce`. A recycled container pid is
- * stale only when this process does not currently hold that nonce.
- */
-async function withTaskEventLock(workspaceDir: string, fn: () => Promise): Promise {
- return enqueueTaskEventWrite(workspaceDir, async () => {
+/** One reliable workspace mutex; nested reads may reuse their caller's lock. */
+export async function withTaskEventLock(workspaceDir: string, fn: () => Promise): Promise {
+ const key = await taskEventQueueKey(workspaceDir);
+ return withTaskEventLockForKey(workspaceDir, key, fn);
+}
+
+async function withTaskEventLockForKey(
+ workspaceDir: string,
+ key: string,
+ fn: () => Promise
+): Promise {
+ if (ownsTaskLock(key)) throw new Error('task artifact write lock is not re-entrant');
+ return enqueueTaskEventWrite(key, async () => {
const dir = await mossDir(workspaceDir);
- const lockPath = path.join(dir, `${EVENTS_FILE}.lock`);
- const token = await acquireTaskEventLock(lockPath);
+ const lockPath = path.join(dir, EVENTS_FILE);
+ let lock;
try {
- return await fn();
- } finally {
- try {
- await releaseTaskEventLock(lockPath, token);
- } finally {
- heldLockTokens.delete(token);
+ lock = await acquireTaskArtifactWriteLock(lockPath, EVENT_LOCK_WAIT_MS);
+ } catch (error) {
+ const cause = (error as Error).cause;
+ if (cause instanceof Error && cause.message === 'lock acquisition timed out') {
+ throw new MossError({
+ code: ErrorCode.EXECUTION_LEASE_HELD,
+ message: 'timed out waiting for the task event lock (task-events.jsonl.lock)',
+ context: { lockPath },
+ });
}
+ if (cause && ['EPERM', 'EACCES'].includes((cause as NodeJS.ErrnoException).code ?? ''))
+ throw cause;
+ throw error;
}
- });
-}
-
-function taskEventLockTimeout(lockPath: string): MossError {
- return new MossError({
- code: ErrorCode.EXECUTION_LEASE_HELD,
- message: `timed out waiting for the task event lock (${path.basename(lockPath)})`,
- hint: 'Another moss process is writing task events. Retry when it finishes.',
- context: { lockPath },
- });
-}
-
-async function acquireTaskEventLock(lockPath: string): Promise {
- const deadline = Date.now() + EVENT_LOCK_WAIT_MS;
- const token = newLockToken();
- for (;;) {
- if (Date.now() > deadline) throw taskEventLockTimeout(lockPath);
+ const scope: TaskLockScope = { key, active: true, parent: taskLockScope.getStore() };
try {
- await fs.writeFile(lockPath, token, { encoding: 'utf8', flag: 'wx' });
- heldLockTokens.add(token);
- return token;
- } catch (err) {
- const code = (err as NodeJS.ErrnoException).code;
- // Those three codes are Windows delete/create races. Elsewhere they
- // mean the directory is not writable: retrying spins, because reclaim
- // sees ENOENT and would continue without sleeping.
- const winRetry =
- process.platform === 'win32' && code !== undefined && RETRYABLE_LOCK_CODES.has(code);
- if (code !== 'EEXIST' && !winRetry) throw err;
- const reclaim = await tryReclaimStaleTaskEventLock(lockPath);
- if (Date.now() > deadline) throw taskEventLockTimeout(lockPath);
- if (reclaim !== 'reclaimed') {
- await new Promise((resolve) => setTimeout(resolve, 20));
- }
+ return await taskLockScope.run(scope, fn);
+ } finally {
+ scope.active = false;
+ await lock.release();
}
- }
-}
-
-type LockReclaim = 'missing' | 'reclaimed' | 'busy';
-
-/**
- * Unlink a stale lock only when the pid is still the one we observed, so two
- * waiters cannot both delete a lock a third writer just created.
- * `missing` means the file is gone (ENOENT): the caller must sleep before
- * retrying, or a non-writable directory tight-loops.
- */
-async function tryReclaimStaleTaskEventLock(lockPath: string): Promise {
- let observed: string;
- try {
- observed = await fs.readFile(lockPath, 'utf8');
- } catch (err) {
- return (err as NodeJS.ErrnoException).code === 'ENOENT' ? 'missing' : 'busy';
- }
- if (!(await taskEventLockIsStale(lockPath, observed))) return 'busy';
- let again: string;
- try {
- again = await fs.readFile(lockPath, 'utf8');
- } catch (err) {
- return (err as NodeJS.ErrnoException).code === 'ENOENT' ? 'missing' : 'busy';
- }
- if (again !== observed) return 'busy';
- await fs.unlink(lockPath).catch(() => undefined);
- try {
- await fs.stat(lockPath);
- return 'busy';
- } catch (err) {
- return (err as NodeJS.ErrnoException).code === 'ENOENT' ? 'reclaimed' : 'busy';
- }
-}
-
-function lockPid(raw: string): number {
- const head = raw.trim().split(':')[0] ?? '';
- const pid = Number(head);
- return Number.isInteger(pid) ? pid : Number.NaN;
-}
-
-async function taskEventLockIsStale(lockPath: string, raw: string): Promise {
- const token = raw.trim();
- if (heldLockTokens.has(token)) return false;
- try {
- const stat = await fs.stat(lockPath);
- if (Date.now() - stat.mtimeMs > EVENT_LOCK_STALE_MS) return true;
- const pid = lockPid(raw);
- if (!Number.isInteger(pid) || pid <= 0) return true;
- // Own pid with a nonce this process is not holding: leftover from pid
- // reuse, not the lock we just took through another path.
- if (pid === process.pid) return true;
- return !processIsAlive(pid);
- } catch (err) {
- return (err as NodeJS.ErrnoException).code === 'ENOENT';
- }
-}
-
-function processIsAlive(pid: number): boolean {
- try {
- process.kill(pid, 0);
- return true;
- } catch (err) {
- return (err as NodeJS.ErrnoException).code !== 'ESRCH';
- }
+ });
}
-async function releaseTaskEventLock(lockPath: string, token: string): Promise {
- const current = await fs.readFile(lockPath, 'utf8').catch(() => '');
- if (current.trim() !== token) return;
- const again = await fs.readFile(lockPath, 'utf8').catch(() => '');
- if (again.trim() !== token) return;
- await fs.unlink(lockPath).catch(() => undefined);
+/** Read under the writer's mutex, preventing replay of a rolled-back cached tail. */
+export async function withTaskArtifactReadLock(
+ workspaceDir: string,
+ fn: () => Promise
+): Promise {
+ const key = await taskEventQueueKey(workspaceDir);
+ if (ownsTaskLock(key)) return fn();
+ return withTaskEventLockForKey(workspaceDir, key, fn);
}
diff --git a/src/core/task/verdict.ts b/src/core/task/verdict.ts
index 376527df..c34f0b8b 100644
--- a/src/core/task/verdict.ts
+++ b/src/core/task/verdict.ts
@@ -8,7 +8,7 @@
*/
import type { AcceptanceVerdict, TaskContract } from '../../contracts/task.js';
import { evaluateAcceptance, formatAcceptanceVerdict } from '../../contracts/task.js';
-import { runAcceptanceCommand } from './acceptance-command.js';
+import { runAcceptanceCommandInWorkspace } from './acceptance-command.js';
import { recordHarnessSuiteEvidence } from './suite-evidence.js';
import {
appendAcceptanceVerdict,
@@ -16,6 +16,12 @@ import {
listEvidenceRecords,
listTaskRecords,
} from '../task-runtime/artifacts.js';
+import {
+ noteCommittedAcceptance,
+ inNativeAcceptanceSettlement,
+ assertTaskAppendScopeOpen,
+} from './acceptance-commit-scope.js';
+import { appendTaskEvent, emitAcceptanceLifecycle, getTaskStateSnapshot } from './task-store.js';
export type VerdictSource = 'command' | 'contract';
@@ -34,6 +40,13 @@ export interface VerdictProvider {
evaluate(taskId: string, signal?: AbortSignal): Promise;
}
+// Only a successful durable append can attest acceptance, never a caller's
+// source label. Identity is preserved through host wrappers returning verdicts.
+const committedVerdicts = new WeakSet();
+export function isCommittedTaskVerdict(verdict: TaskVerdict): boolean {
+ return committedVerdicts.has(verdict);
+}
+
/**
* Exit-code acceptance: pass = exit 0, fail detail = combined output tail.
* Used as-is by the engine's VERIFYING phase.
@@ -41,27 +54,37 @@ export interface VerdictProvider {
export function createCommandVerdictProvider(
command: string,
options: { timeoutMs?: number; workspaceDir?: string } = {}
+): VerdictProvider {
+ return commandVerdictProvider(command, options, options.workspaceDir);
+}
+
+function commandVerdictProvider(
+ command: string,
+ options: { timeoutMs?: number },
+ workspaceDir?: string
): VerdictProvider {
return {
source: 'command',
async evaluate(taskId, signal) {
- const result = await runAcceptanceCommand(
- {
- command,
- ...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}),
- ...(options.workspaceDir ? { workspaceDir: options.workspaceDir } : {}),
- },
- signal
+ const result = await runAcceptanceCommandInWorkspace(
+ { command, ...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}) },
+ signal,
+ workspaceDir
);
- if (options.workspaceDir) {
+ signal?.throwIfAborted();
+ if (workspaceDir) assertTaskAppendScopeOpen(workspaceDir, true);
+ if (workspaceDir) {
await recordHarnessSuiteEvidence({
- workspaceDir: options.workspaceDir,
+ workspaceDir,
taskId,
source: 'acceptance_command',
testsPassed: result.passed,
output: result.tail,
+ signal,
});
}
+ signal?.throwIfAborted();
+ if (workspaceDir) assertTaskAppendScopeOpen(workspaceDir, true);
return {
taskId,
passed: result.passed,
@@ -97,9 +120,22 @@ export async function acceptanceAlreadySatisfied(
export async function evaluateContractAcceptance(
workspaceDir: string,
- taskId?: string
+ taskId?: string,
+ signal?: AbortSignal
): Promise<{ verdict: AcceptanceVerdict; task: TaskContract } | null> {
+ return evaluateContractWithAuthority(workspaceDir, taskId, signal, false);
+}
+
+async function evaluateContractWithAuthority(
+ workspaceDir: string,
+ taskId: string | undefined,
+ signal: AbortSignal | undefined,
+ commandPassed: boolean,
+ recordAuthority?: (commitStarted: boolean) => Promise
+): Promise<{ verdict: AcceptanceVerdict; task: TaskContract } | null> {
+ signal?.throwIfAborted();
const tasks = await listTaskRecords(workspaceDir);
+ signal?.throwIfAborted();
if (tasks.length === 0) return null;
const task = taskId
? tasks.find((candidate) => candidate.taskId === taskId)
@@ -109,50 +145,108 @@ export async function evaluateContractAcceptance(
if (!task) return null;
const evidence = await listEvidenceRecords(workspaceDir, 1000);
+ signal?.throwIfAborted();
const verdict = evaluateAcceptance(task, evidence);
- await appendAcceptanceVerdict(workspaceDir, verdict);
+ // The last abort check is at append dispatch. Once the PASS commit starts,
+ // finish its ledger despite later cancellation. Failed IO never attests PASS.
+ const contractAudit = !commandPassed || task.acceptanceCriteria.length > 0;
+ if (contractAudit) await appendAcceptanceVerdict(workspaceDir, verdict, signal);
+ await recordAuthority?.(contractAudit);
- if (verdict.verdict === 'pass' && task.status !== 'accepted') {
+ if ((verdict.verdict === 'pass' || commandPassed) && task.status !== 'accepted') {
await appendTaskRecord(workspaceDir, { ...task, status: 'accepted', updatedAt: Date.now() });
} else if (verdict.verdict === 'fail' && task.status === 'active') {
- await appendTaskRecord(workspaceDir, { ...task, status: 'failed', updatedAt: Date.now() });
+ await appendTaskRecord(
+ workspaceDir,
+ { ...task, status: 'failed', updatedAt: Date.now() },
+ signal
+ );
}
return { verdict, task };
}
+async function settleAcceptedContract(
+ workspaceDir: string,
+ result: { verdict: AcceptanceVerdict; task: TaskContract },
+ source: VerdictSource,
+ detail: string
+): Promise {
+ try {
+ const before = await getTaskStateSnapshot(workspaceDir, result.task.taskId);
+ // A planning turn can already implement and measure the goal. Complete the
+ // existing legal phase path before publishing its acceptance barrier.
+ if (before && ['draft', 'understanding', 'planning'].includes(before.phase)) {
+ await appendTaskEvent(workspaceDir, result.task.taskId, 'plan_ready');
+ await appendTaskEvent(workspaceDir, result.task.taskId, 'execution_started');
+ } else if (before && ['failed', 'abandoned', 'blocked'].includes(before.phase)) {
+ await appendTaskEvent(workspaceDir, result.task.taskId, 'task_resumed', {
+ reason: 'acceptance re-evaluation',
+ });
+ }
+ await emitAcceptanceLifecycle(workspaceDir, result.task.taskId, true, detail, source);
+ const after = await getTaskStateSnapshot(workspaceDir, result.task.taskId);
+ if (after && after.phase !== 'accepted') {
+ throw new Error(`task ${result.task.taskId} cannot accept from ${after.phase}`);
+ }
+ } catch (error) {
+ // These are separate durable appends, not a transaction. Retain the audit
+ // trail, but compensate only this incomplete settlement; a prior completed
+ // lifecycle acceptance must never be undone.
+ const current = await getTaskStateSnapshot(workspaceDir, result.task.taskId).catch(() => null);
+ if (current?.phase !== 'accepted') {
+ await appendTaskRecord(workspaceDir, {
+ ...result.task,
+ status: result.task.status === 'accepted' ? 'active' : result.task.status,
+ updatedAt: Date.now(),
+ }).catch(() => {});
+ }
+ throw error;
+ }
+}
+
export function createContractVerdictProvider(workspaceDir: string): VerdictProvider {
return {
source: 'contract',
- async evaluate(taskId) {
- // Guard: a draft contract with no criteria would trivially "pass"
- // (nothing to fail). No criteria = no defined done = not accepted.
- const tasks = await listTaskRecords(workspaceDir);
- const task = tasks.find((candidate) => candidate.taskId === taskId);
- if (task && task.acceptanceCriteria.length === 0) {
- return {
- taskId,
- passed: false,
- source: 'contract',
- detail:
- 'task has no acceptance criteria — define them with task_define (metric + expectation) before verification; a goal without a checkable definition of done cannot be accepted',
- };
- }
- const result = await evaluateContractAcceptance(workspaceDir, taskId);
- if (!result) {
- return {
+ async evaluate(taskId, signal) {
+ return inNativeAcceptanceSettlement(workspaceDir, async () => {
+ signal?.throwIfAborted();
+ // Guard: a draft contract with no criteria would trivially "pass"
+ // (nothing to fail). No criteria = no defined done = not accepted.
+ const tasks = await listTaskRecords(workspaceDir);
+ signal?.throwIfAborted();
+ const task = tasks.find((candidate) => candidate.taskId === taskId);
+ if (task && task.acceptanceCriteria.length === 0) {
+ return {
+ taskId,
+ passed: false,
+ source: 'contract',
+ detail:
+ 'task has no acceptance criteria — define them with task_define (metric + expectation) before verification; a goal without a checkable definition of done cannot be accepted',
+ };
+ }
+ const result = await evaluateContractAcceptance(workspaceDir, taskId, signal);
+ if (!result) {
+ return {
+ taskId,
+ passed: false,
+ source: 'contract',
+ detail: 'no task contract found — define one with task_define first',
+ };
+ }
+ const evaluated: TaskVerdict = {
taskId,
- passed: false,
+ passed: result.verdict.verdict === 'pass',
source: 'contract',
- detail: 'no task contract found — define one with task_define first',
+ detail: formatAcceptanceVerdict(result.verdict, result.task),
+ verdict: result.verdict,
};
- }
- return {
- taskId,
- passed: result.verdict.verdict === 'pass',
- source: 'contract',
- detail: formatAcceptanceVerdict(result.verdict, result.task),
- verdict: result.verdict,
- };
+ if (evaluated.passed) {
+ await settleAcceptedContract(workspaceDir, result, 'contract', evaluated.detail);
+ committedVerdicts.add(evaluated);
+ noteCommittedAcceptance(workspaceDir, evaluated);
+ } else signal?.throwIfAborted();
+ return evaluated;
+ });
},
};
}
@@ -161,24 +255,29 @@ async function recordCommandVerdictRow(
workspaceDir: string,
taskId: string,
passed: boolean,
- detail: string
+ detail: string,
+ signal?: AbortSignal
): Promise {
- await appendAcceptanceVerdict(workspaceDir, {
- taskId,
- verdict: passed ? 'pass' : 'fail',
- acceptedAt: Date.now(),
- criteriaResults: [
- {
- metric: 'acceptance_command',
- expected: 'exit 0',
- required: true,
- result: passed ? 'pass' : 'fail',
- explanation: detail.slice(0, 300),
- },
- ],
- unmetRequired: passed ? 0 : 1,
- evidenceConsidered: 0,
- });
+ await appendAcceptanceVerdict(
+ workspaceDir,
+ {
+ taskId,
+ verdict: passed ? 'pass' : 'fail',
+ acceptedAt: Date.now(),
+ criteriaResults: [
+ {
+ metric: 'acceptance_command',
+ expected: 'exit 0',
+ required: true,
+ result: passed ? 'pass' : 'fail',
+ explanation: detail.slice(0, 300),
+ },
+ ],
+ unmetRequired: passed ? 0 : 1,
+ evidenceConsidered: 0,
+ },
+ signal
+ );
}
/**
@@ -192,28 +291,61 @@ export function createTaskVerdictProvider(options: {
}): VerdictProvider {
const contract = createContractVerdictProvider(options.workspaceDir);
if (!options.command) return contract;
- const command = createCommandVerdictProvider(options.command, {
- workspaceDir: options.workspaceDir,
- });
+ const command = commandVerdictProvider(options.command, {}, options.workspaceDir);
return {
source: 'command',
async evaluate(taskId, signal) {
- const commandVerdict = await command.evaluate(taskId, signal);
- if (commandVerdict.passed) {
- // Command passed — still record the contract evaluation for the trail.
- // The command row is written after that so it stays the latest verdict.
- await contract.evaluate(taskId).catch(() => undefined);
- }
- // A later command result replaces the previous row in acceptance.jsonl.
- // Scoring the contract on failure can rewrite a FAIL into PASS when older
- // evidence still matches criteria the command does not cover.
- await recordCommandVerdictRow(
- options.workspaceDir,
- taskId,
- commandVerdict.passed,
- commandVerdict.detail
- ).catch(() => undefined);
- return commandVerdict;
+ return inNativeAcceptanceSettlement(options.workspaceDir, async () => {
+ const commandVerdict = await command.evaluate(taskId, signal);
+ signal?.throwIfAborted();
+ if (commandVerdict.passed) {
+ // Command passed — still record the contract evaluation for the trail.
+ const recorded = await evaluateContractWithAuthority(
+ options.workspaceDir,
+ taskId,
+ signal,
+ true,
+ (commitStarted) =>
+ recordCommandVerdictRow(
+ options.workspaceDir,
+ taskId,
+ true,
+ commandVerdict.detail,
+ commitStarted ? undefined : signal
+ )
+ );
+ if (recorded) {
+ await settleAcceptedContract(
+ options.workspaceDir,
+ recorded,
+ 'command',
+ commandVerdict.detail
+ );
+ committedVerdicts.add(commandVerdict);
+ noteCommittedAcceptance(options.workspaceDir, commandVerdict);
+ } else {
+ // Keep the command audit even when no contract exists. This row
+ // alone cannot attest a native contract/lifecycle settlement.
+ signal?.throwIfAborted();
+ await recordCommandVerdictRow(
+ options.workspaceDir,
+ taskId,
+ true,
+ commandVerdict.detail,
+ signal
+ );
+ }
+ return commandVerdict;
+ }
+ await recordCommandVerdictRow(
+ options.workspaceDir,
+ taskId,
+ false,
+ commandVerdict.detail,
+ signal
+ );
+ return commandVerdict;
+ });
},
};
}
diff --git a/src/core/tools/background-process-registry.ts b/src/core/tools/background-process-registry.ts
index 96315ecd..7f505d62 100644
--- a/src/core/tools/background-process-registry.ts
+++ b/src/core/tools/background-process-registry.ts
@@ -43,6 +43,16 @@ export interface BackgroundProcSnapshot {
endedAt?: number;
errorMessage?: string;
droppedBytes?: number;
+ /**
+ * O108: protected-path changes detected by layer-3 snapshot when this
+ * process fully exited (metadata only — never file contents).
+ */
+ protectedPathChanges?: ReadonlyArray<{
+ path: string;
+ kind: string;
+ keys?: string[];
+ reason?: string;
+ }>;
}
export interface BackgroundOutputChunk {
@@ -62,6 +72,8 @@ export interface BackgroundProc {
/** Session that started the process, when the host provided one. */
sessionKey?: string;
child: ChildProcess;
+ /** SDK-owned Windows Job bootstrap; user code cannot dispatch before Assign succeeds. */
+ windowsJobOwner?: boolean;
pid?: number;
status: BackgroundStatus;
exitCode: number | null;
@@ -71,6 +83,11 @@ export interface BackgroundProc {
buffer: string;
errorMessage?: string;
droppedBytes: number;
+ /**
+ * O108: set on full exit when layer-3 snapshot detects protected-path
+ * modifications (paths + kind only).
+ */
+ protectedPathChanges?: Array<{ path: string; kind: string; keys?: string[]; reason?: string }>;
killTimer?: ReturnType;
killRequested?: boolean;
@@ -117,6 +134,9 @@ export function toSnapshot(proc: BackgroundProc): BackgroundProcSnapshot {
endedAt: proc.endedAt,
errorMessage: proc.errorMessage,
droppedBytes: proc.droppedBytes > 0 ? proc.droppedBytes : undefined,
+ ...(proc.protectedPathChanges && proc.protectedPathChanges.length > 0
+ ? { protectedPathChanges: proc.protectedPathChanges }
+ : {}),
};
}
@@ -322,6 +342,9 @@ export function killProc(proc: BackgroundProc): void {
const pid = proc.child.pid;
try {
if (IS_WIN && pid) {
+ // Stop the Job owner before the slower tree enumeration. For a live
+ // owner this stack does not yield between its kill and taskkill.
+ if (proc.windowsJobOwner) proc.child.kill();
runProcessSync('taskkill', ['/pid', String(pid), '/T', '/F'], {
stdio: 'ignore',
windowsHide: true,
diff --git a/src/core/tools/execute-tool-call.ts b/src/core/tools/execute-tool-call.ts
index 0189cdcd..30f056c0 100644
--- a/src/core/tools/execute-tool-call.ts
+++ b/src/core/tools/execute-tool-call.ts
@@ -108,6 +108,8 @@ export function isStringToolFailureResult(text: string | undefined): boolean {
// Prefix-only failure encodings (start of the tool result).
if (/^\s*Error:/i.test(head)) return true;
if (/^\s*Command failed\b/i.test(head)) return true;
+ // exec timeout / signal kill are marked structurally via ctx.markResultError
+ // (builtin exec catch branch) — never by matching stdout text (O96).
if (/^\s*Command blocked:/i.test(head)) return true;
if (/^\s*Patch rejected\b/i.test(head)) return true;
// exec non-zero exit is emitted as the first line: "exit_code: N\n..."
@@ -465,7 +467,14 @@ async function executeOneToolCallInner(
const attemptSignal =
combineAbortSignals(effectiveAbortSignal, timeoutAbortCtrl.signal) ??
effectiveAbortSignal;
- const attemptCtx: ToolContext = { ...callToolCtx, abortSignal: attemptSignal };
+ let structuralError = false;
+ const attemptCtx: ToolContext = {
+ ...callToolCtx,
+ abortSignal: attemptSignal,
+ markResultError: () => {
+ structuralError = true;
+ },
+ };
if (!skipAgentHeartbeat) {
const heartbeatIntervalMs = Math.max(1, deps.heartbeatIntervalMs);
const maxMissed = resolveMaxMissedHeartbeats(
@@ -519,7 +528,7 @@ async function executeOneToolCallInner(
// Many tools (exec, edit_file, …) return error as a string instead of
// throwing. Detect those so is_error / failure gates / loop-guard
// see a real failure (Claude/Codex: non-zero shell is not success).
- if (!attemptErrFlag && isStringToolFailureResult(attemptText)) {
+ if (!attemptErrFlag && (structuralError || isStringToolFailureResult(attemptText))) {
attemptErrFlag = true;
attemptError = new MossError({
code: ErrorCode.TOOL_EXECUTION_FAILED,
diff --git a/src/core/tools/tool-loop-guard.ts b/src/core/tools/tool-loop-guard.ts
index 22f82bb6..ca3e8878 100644
--- a/src/core/tools/tool-loop-guard.ts
+++ b/src/core/tools/tool-loop-guard.ts
@@ -1,5 +1,7 @@
import { stableSerializeToolInput } from './tool-idempotent-replay.js';
import { readEnv } from '../../utils/env-compat.js';
+import path from 'node:path';
+import { canonicalPath, findProtectedWrite } from '../../safety/shell-write-detect.js';
const SINGLE_TOOL_LIMIT_EXEMPT_TOOLS = new Set([
'read_file',
@@ -91,6 +93,23 @@ const SURGICAL_EDIT_TOOLS = new Set(['edit_file', 'multi_edit', 'apply_patch']);
/** Max failed surgical edits per path before forcing re-read (identical input limit still applies). */
const DEFAULT_EDIT_PATH_FAILURE_LIMIT = 3;
+/**
+ * O96: exec / run_tests that finish normally with a non-zero exit (lint/test fail)
+ * must not burn the global byToolFailure budget. Same-command identical-output
+ * thrash still hints to change approach via a separate counter.
+ */
+const VERIFY_SHELL_TOOLS = new Set(['exec', 'run_tests']);
+/** Consecutive same-command failures with identical output before hinting to change approach. */
+const DEFAULT_SAME_COMMAND_FAILURE_LIMIT = 3;
+
+export type ShellFaultKind = 'timeout' | 'start_failure' | 'killed' | 'unusable';
+
+export type SameCommandFailureStreak = {
+ count: number;
+ outputKey: string;
+ displayCommand: string;
+};
+
export type ToolLoopGuardState = {
bySignature: Map;
byTool: Map;
@@ -107,6 +126,30 @@ export type ToolLoopGuardState = {
* go stale — Claude Code "read then edit" discipline at the loop layer.
*/
byEditPathFailure: Map;
+ /**
+ * O96: per normalized command signature — consecutive normal non-zero exits
+ * with substantially identical output. Does not feed byToolFailure.
+ */
+ bySameCommandFailure: Map;
+ /** O96: last real shell fault kind for exec/run_tests (timeout / start / …). */
+ lastShellFaultKind: Map;
+ /**
+ * O96: missing command name parsed from a 126/127 start failure's output.
+ * Copy-only — never used for classification.
+ */
+ lastMissingCommand?: Map;
+ /**
+ * O97: paths whose edit/write was just refused for read scope (partial
+ * window, file content not attached). While locked, an exec that would write
+ * the same file is blocked too, so the model cannot route around the edit
+ * tool with `node -e` / `sed -i` / redirects. Cleared by a full read_file of
+ * the path or a successful edit/write to it.
+ */
+ readScopeLockedPaths: Set;
+ /** O97: thrash counter key → path as the model wrote it (for realpath compare). */
+ editPathRaw?: Map;
+ /** Workspace the run's tools resolve relative paths against. */
+ workspaceDir?: string;
webSearchQueries: Set;
freshNewsSearchRequested: boolean;
@@ -184,13 +227,85 @@ export function collectSurgicalEditPathKeys(input?: Record): st
return keys;
}
-export function createToolLoopGuardState(): ToolLoopGuardState {
+/** Edit/write tools whose read-scope refusal locks the path for shell writes too. */
+const FILE_MUTATION_TOOLS = new Set([
+ 'edit_file',
+ 'multi_edit',
+ 'apply_patch',
+ 'write_file',
+ 'move_file',
+]);
+
+const SHELL_TOOLS = new Set(['exec', 'exec_background', 'bash']);
+
+export function rawMutationPaths(toolName: string, input?: Record): string[] {
+ if (!input) return [];
+ const out: string[] = [];
+ if (typeof input.path === 'string' && input.path.trim()) out.push(input.path);
+ if (Array.isArray(input.edits)) {
+ for (const e of input.edits) {
+ const p = (e as { path?: unknown } | null)?.path;
+ if (typeof p === 'string' && p.trim()) out.push(p);
+ }
+ }
+ if (typeof input.patch === 'string') {
+ for (const m of input.patch.matchAll(/\*\*\*\s+(?:Update|Delete|Add)\s+File:\s*(\S+)/gi)) {
+ if (m[1]) out.push(m[1]);
+ }
+ }
+ if (toolName === 'move_file' && typeof input.destination === 'string')
+ out.push(input.destination);
+ return out;
+}
+
+function lockKey(state: ToolLoopGuardState, raw: string): string {
+ return canonicalPath(path.resolve(state.workspaceDir ?? process.cwd(), raw));
+}
+
+function trackReadScopeLocks(
+ state: ToolLoopGuardState,
+ toolName: string,
+ isError: boolean,
+ resultText: string | undefined,
+ input: Record | undefined
+): void {
+ const locks = state.readScopeLockedPaths;
+ if (!locks) return;
+ if (toolName === 'read_file') {
+ const full = input && input.offset === undefined && input.limit === undefined;
+ if (full && !isError && typeof input?.path === 'string') {
+ locks.delete(lockKey(state, input.path));
+ }
+ return;
+ }
+ if (!FILE_MUTATION_TOOLS.has(toolName)) return;
+ const text = resultText ?? '';
+ const refused = /You only read a partial window of /.test(text);
+ for (const raw of rawMutationPaths(toolName, input)) {
+ const key = lockKey(state, raw);
+ // Remember the real path behind the (lower-cased) thrash counter key.
+ state.editPathRaw?.set(normalizePathKey(raw), raw);
+ // O128: an attached body no longer unlocks editing, so every refusal locks.
+ if (refused) locks.add(key);
+ else if (!isError && !refused) locks.delete(key);
+ }
+}
+
+export function createToolLoopGuardState(
+ options: { workspaceDir?: string } = {}
+): ToolLoopGuardState {
return {
+ ...(options.workspaceDir ? { workspaceDir: options.workspaceDir } : {}),
+ editPathRaw: new Map(),
bySignature: new Map(),
byTool: new Map(),
byToolFailure: new Map(),
byWebFetchUrlFailure: new Map(),
byEditPathFailure: new Map(),
+ bySameCommandFailure: new Map(),
+ lastShellFaultKind: new Map(),
+ lastMissingCommand: new Map(),
+ readScopeLockedPaths: new Set(),
webSearchQueries: new Set(),
freshNewsSearchRequested: false,
hasSufficientRssNewsEvidence: false,
@@ -223,6 +338,154 @@ function normalizeWebFetchUrlKey(input: unknown): string | null {
}
}
+/**
+ * O96: normalize exec / run_tests command for same-command streak keys.
+ * @internal exported for tests
+ */
+export function normalizeVerifyCommandSignature(
+ toolName: string,
+ input?: Record
+): { key: string; display: string } | null {
+ if (toolName === 'exec') {
+ const cmd = typeof input?.command === 'string' ? input.command.trim().replace(/\s+/g, ' ') : '';
+ if (!cmd) return null;
+ return { key: `exec:${cmd.toLowerCase()}`, display: cmd };
+ }
+ if (toolName === 'run_tests') {
+ const file = typeof input?.file === 'string' && input.file.trim() ? input.file.trim() : '';
+ const cmd =
+ typeof input?.command === 'string' && input.command.trim() ? input.command.trim() : '';
+ const display = file ? `node --test ${file}` : cmd || 'npm test';
+ return { key: `run_tests:${display.toLowerCase().replace(/\s+/g, ' ')}`, display };
+ }
+ return null;
+}
+
+/** @internal exported for tests */
+export function normalizeShellOutputFingerprint(resultText: string): string {
+ return resultText
+ .trim()
+ .replace(/\r\n/g, '\n')
+ .replace(/[ \t]+/g, ' ')
+ .replace(/\n{3,}/g, '\n\n');
+}
+
+/**
+ * O96: classify an exec / run_tests error result.
+ * - `normal_nonzero`: process started and finished; lint/test simply failed.
+ * - fault kinds: timeout / start failure / killed / unusable empty output.
+ * - `other`: policy deny, blocked, or unrecognized — keep existing byToolFailure path.
+ * @internal exported for tests
+ */
+export function classifyShellToolFailure(
+ resultText: string | undefined,
+ platform: NodeJS.Platform | string = process.platform
+): 'normal_nonzero' | ShellFaultKind | 'other' {
+ const text = resultText ?? '';
+ const head = text.slice(0, 600);
+ // Fault markers are only trusted on the tool-generated first line. Child
+ // stdout (e.g. a test named "... timed out after 5000ms") must never be
+ // scanned for fault text, or a normal run is misclassified as a tool fault.
+ const firstLine = (head.replace(/^\s+/, '').split(/\r?\n/, 1)[0] ?? '').trim();
+
+ // Normal completion header: process ran to completion; stdout is untrusted.
+ const exitHeader = /^exit_code:\s*(\d+)\b/i.exec(firstLine);
+ // 126 (not executable) / 127 (command not found) from the exec first line
+ // mean the process never started — a tool fault, not a lint/test failure.
+ // O106: on Windows, cmd.exe "command not found" is exit 9009 — same as 127.
+ if (exitHeader) {
+ const code = Number(exitHeader[1]);
+ if (code === 126 || code === 127) return 'start_failure';
+ if (code === 9009 && platform === 'win32') return 'start_failure';
+ return code > 0 ? 'normal_nonzero' : 'other';
+ }
+ if (/^Command failed \(exit\s+12[67]\)/i.test(firstLine)) return 'start_failure';
+ if (/^Command failed \(exit\s+9009\)/i.test(firstLine) && platform === 'win32') {
+ return 'start_failure';
+ }
+ if (/^Command failed \(exit\s+[1-9]\d*\)/i.test(firstLine)) return 'normal_nonzero';
+
+ if (
+ /^timed out after\b/i.test(firstLine) ||
+ /^❌\s*timed out\b/i.test(firstLine) ||
+ /^Execution error:.*\btimed out\b/i.test(firstLine) ||
+ /^Tool \S+ timed out\b/i.test(firstLine)
+ ) {
+ return 'timeout';
+ }
+ if (/^Command killed by\b/i.test(firstLine)) {
+ return 'killed';
+ }
+ // "Command failed:" without "(exit N)" — spawn/start failure style from builtin.ts
+ if (/^Command failed:\s*$/i.test(firstLine)) {
+ return 'start_failure';
+ }
+ if (
+ /^(?:Execution error:|Error:)?.*\bspawn(?:Sync)?\b.*\bENOENT\b/i.test(firstLine) ||
+ /^(?:Execution error:|Error:)\s.*\bENOENT\b/i.test(firstLine) ||
+ /^(?:\S+:\s*)?(?:\S+:\s*)?command not found\b/i.test(firstLine) ||
+ /^.*\bnot found in PATH\b/i.test(firstLine) ||
+ /^(?:Error:\s*)?Failed to (?:start|launch|spawn)\b/i.test(firstLine) ||
+ /^未能启动/.test(firstLine)
+ ) {
+ return 'start_failure';
+ }
+ if (/^Execution error:/i.test(firstLine)) {
+ return 'start_failure';
+ }
+
+ // Policy / user deny — not a normal lint non-zero (QA guardrail).
+ if (
+ /^\s*Command blocked:/i.test(head) ||
+ /^\s*已拦截/u.test(head) ||
+ // O121: loop-guard's own stop copy (renamed from 已拦截 / Blocked).
+ /^\s*已停止重试:/u.test(head) ||
+ /^\s*Stopped retrying:/i.test(head) ||
+ /^\s*Blocked\b/i.test(head) ||
+ /^\s*Tool execution denied\b/i.test(head) ||
+ /^\s*Operation blocked by workspace policy\b/i.test(head)
+ ) {
+ return 'other';
+ }
+
+ // Normal completed non-zero (lint / test / check failed).
+ if (/Test Results:\s*❌\s+\d+\s+FAILED\b/i.test(head)) return 'normal_nonzero';
+ if (/Test Results:\s*❌\s+exit\s+[1-9]/i.test(head)) return 'normal_nonzero';
+ if (/Test Results:\s*⚠️\s*NO TESTS EXECUTED/i.test(head)) return 'normal_nonzero';
+ if (/Verify Fix:\s*❌/i.test(head)) return 'normal_nonzero';
+ if (/❌\s+\d+\s+FAILED\b/i.test(head)) return 'normal_nonzero';
+
+ const trimmed = text.trim();
+ if (!trimmed || trimmed === '(no output)') {
+ return 'unusable';
+ }
+ return 'other';
+}
+
+/**
+ * O96: best-effort missing command name from shell "not found" output
+ * (`sh: eslint: not found`, `sh: 1: eslint: not found`,
+ * `bash: foo: command not found`, `zsh: command not found: foo`,
+ * `bash: /no/such/bin: No such file or directory`). Copy-only.
+ * @internal exported for tests
+ */
+export function parseMissingCommandName(resultText: string | undefined): string | undefined {
+ const text = (resultText ?? '').slice(0, 4000);
+ const patterns = [
+ /^(?:\S*\/)?(?:sh|bash|dash|ash|ksh)(?::\s*(?:line\s+)?\d+)?:\s*([^\s:]+):\s*(?:command\s+)?not found\b/im,
+ /^(?:\S*\/)?zsh(?::\d+)?:\s*command not found:\s*(\S+)/im,
+ /^(?:\S*\/)?(?:sh|bash|dash|ash|ksh)(?::\s*(?:line\s+)?\d+)?:\s*(\/[^\s:]+):\s*No such file or directory\b/im,
+ ];
+ for (const re of patterns) {
+ const m = re.exec(text);
+ const name = m?.[1];
+ // Display-only: strict whitelist, else fall back to generic copy.
+ if (name && /^[A-Za-z0-9._/-]{1,64}$/.test(name)) return name;
+ if (name) return undefined;
+ }
+ return undefined;
+}
+
export function isSoftToolFailureResult(resultText: string | undefined): boolean {
if (!resultText) return false;
if (/^\s*(web_fetch_error|web_search_error)\b/i.test(resultText)) return true;
@@ -241,7 +504,8 @@ export function recordToolLoopOutcome(
toolName: string,
isError: boolean,
resultText?: string,
- input?: Record
+ input?: Record,
+ platform: NodeJS.Platform | string = process.platform
): void {
if (
toolName === 'web_search' &&
@@ -252,7 +516,57 @@ export function recordToolLoopOutcome(
) {
state.hasSufficientRssNewsEvidence = true;
}
- if (!isError && !isSoftToolFailureResult(resultText)) return;
+ trackReadScopeLocks(state, toolName, isError, resultText, input);
+ if (!isError && !isSoftToolFailureResult(resultText)) {
+ // O96: trust is_error only (set by execute-tool-call anchored
+ // isStringToolFailureResult). Never text-scan successful output.
+ if (VERIFY_SHELL_TOOLS.has(toolName)) {
+ const sig = normalizeVerifyCommandSignature(toolName, input);
+ if (sig) state.bySameCommandFailure.delete(sig.key);
+ }
+ return;
+ }
+ // O96: normal completed non-zero for exec/run_tests — independent streak only.
+ if (VERIFY_SHELL_TOOLS.has(toolName)) {
+ const kind = classifyShellToolFailure(resultText, platform);
+ if (kind === 'normal_nonzero') {
+ const sig = normalizeVerifyCommandSignature(toolName, input);
+ if (sig) {
+ const outputKey = normalizeShellOutputFingerprint(resultText ?? '');
+ const prev = state.bySameCommandFailure.get(sig.key);
+ if (prev && prev.outputKey === outputKey) {
+ state.bySameCommandFailure.set(sig.key, {
+ count: prev.count + 1,
+ outputKey,
+ displayCommand: sig.display,
+ });
+ } else {
+ state.bySameCommandFailure.set(sig.key, {
+ count: 1,
+ outputKey,
+ displayCommand: sig.display,
+ });
+ }
+ }
+ // Do not bump byToolFailure — lint/test non-zero is not a broken tool.
+ return;
+ }
+ if (
+ kind === 'timeout' ||
+ kind === 'start_failure' ||
+ kind === 'killed' ||
+ kind === 'unusable'
+ ) {
+ state.lastShellFaultKind.set(toolName, kind);
+ const missing = kind === 'start_failure' ? parseMissingCommandName(resultText) : undefined;
+ state.lastMissingCommand ??= new Map();
+ if (missing) state.lastMissingCommand.set(toolName, missing);
+ else state.lastMissingCommand.delete(toolName);
+ const sig = normalizeVerifyCommandSignature(toolName, input);
+ if (sig) state.bySameCommandFailure.delete(sig.key);
+ }
+ // Policy deny / other: fall through to byToolFailure (existing path).
+ }
// web_fetch failures are tracked per-URL (see ToolLoopGuardState) so that a
// failed host doesn't poison the tool's reputation for unrelated hosts.
if (toolName === 'web_fetch') {
@@ -281,6 +595,61 @@ export function recordToolLoopOutcome(
}
export function formatToolLoopGuardMessage(reason: string, toolName: string): string {
+ // O96: same-command identical-output streak — never say STOP / tool broken.
+ if (/^same check failed repeatedly/i.test(reason)) {
+ const cmd =
+ reason.replace(/^same check failed repeatedly\s*\(\d+×\):\s*/i, '').trim() || toolName;
+ return [
+ `同一检查连续失败:\`${cmd}\` 输出实质相同。请改代码或改命令/断言,不要原样重跑。`,
+ `Same check failed repeatedly for \`${cmd}\` with substantially identical output. Change the code or the command/assertions — do not re-run the same check unchanged.`,
+ '优先修代码;不要换工具绕开同一检查。Prefer fixing the code; do not switch tools merely to bypass the same check.',
+ ].join(' ');
+ }
+ // O96: real exec/run_tests faults — say 超时 / 未能启动, not "test failed".
+ if (VERIFY_SHELL_TOOLS.has(toolName)) {
+ if (/timed out \d+ time/i.test(reason)) {
+ return [
+ `已停止重试:${toolName} 连续超时(${reason})。`,
+ `Stopped retrying: ${toolName} timed out repeatedly (${reason}).`,
+ '请提高 timeout_ms、简化命令,或改用 exec_background;不要原样重试。',
+ 'Raise timeout_ms, simplify the command, or use exec_background — do not retry unchanged.',
+ ].join(' ');
+ }
+ const missingMatch = /\(missing command: ([^\s)]+)\)/.exec(reason);
+ if (/failed to start \d+ time/i.test(reason) && missingMatch) {
+ const name = missingMatch[1];
+ return [
+ `已停止重试:${toolName} 未能启动:找不到命令 \`${name}\`(${reason})。`,
+ `Stopped retrying: ${toolName} failed to start: command \`${name}\` not found (${reason}).`,
+ `下一步:装依赖或改命令(例如安装提供 \`${name}\` 的包,或换成已安装的命令);不要原样重试。`,
+ `Next step: install the dependency or change the command (e.g. install the package that provides \`${name}\`, or use a command that is installed) — do not retry unchanged.`,
+ ].join(' ');
+ }
+ if (/failed to start \d+ time/i.test(reason)) {
+ return [
+ `已停止重试:${toolName} 未能启动(命令不存在或起不来;${reason})。`,
+ `Stopped retrying: ${toolName} failed to start (command missing or could not launch; ${reason}).`,
+ '请检查命令是否在 PATH 中、拼写是否正确,再换一条能启动的命令。',
+ 'Check PATH and spelling, then try a command that can actually launch.',
+ ].join(' ');
+ }
+ if (/was killed \d+ time/i.test(reason)) {
+ return [
+ `已停止重试:${toolName} 连续被信号杀掉(${reason})。`,
+ `Stopped retrying: ${toolName} was killed repeatedly (${reason}).`,
+ '请检查是否超时杀进程或外部信号;不要原样重试。',
+ 'Check for timeout kills or external signals — do not retry unchanged.',
+ ].join(' ');
+ }
+ if (/returned unusable results \d+ time/i.test(reason)) {
+ return [
+ `已停止重试:${toolName} 连续返回不可用输出(${reason})。`,
+ `Stopped retrying: ${toolName} returned unusable output repeatedly (${reason}).`,
+ '请换一条能产生可读输出的命令;不要原样重试。',
+ 'Try a different command that produces readable output — do not retry unchanged.',
+ ].join(' ');
+ }
+ }
if (/fresh-news search is already in progress/i.test(reason)) {
return [
`[moss-agent] Tool loop guard stopped another ${toolName} call: ${reason}.`,
@@ -307,6 +676,14 @@ export function formatToolLoopGuardMessage(reason: string, toolName: string): st
'Never invent, assume, or describe the content you could not actually fetch.',
].join(' ');
}
+ if (/^shell write to edit-locked file /.test(reason)) {
+ const file = reason.slice('shell write to edit-locked file '.length);
+ return [
+ `已拦截 / Blocked: ${toolName} would write ${file}, which the edit tools just refused (read scope or repeated failed edits).`,
+ 'Writing it through the shell (node -e, python -c, sed -i, redirects, tee, cp/mv) is not allowed while it is locked.',
+ `Next: call read_file on ${file} without offset/limit, then use edit_file/multi_edit with exact current text.`,
+ ].join(' ');
+ }
if (/^edit thrash on /.test(reason)) {
return [
`[moss-agent] Tool loop guard stopped another ${toolName} call: ${reason}.`,
@@ -597,6 +974,34 @@ export function shouldShortCircuitToolCall(
}
}
+ // O97: once the edit tools refused a path (read scope, or edit thrash), an
+ // exec must not write that same file behind their back.
+ if (SHELL_TOOLS.has(toolName) && typeof input.command === 'string') {
+ const locked = new Set(state.readScopeLockedPaths ?? []);
+ const display = new Map();
+ if (editPathFailureLimit !== undefined) {
+ for (const [key, fails] of state.byEditPathFailure) {
+ if (fails < editPathFailureLimit) continue;
+ const raw = state.editPathRaw?.get(key) ?? key;
+ const k = lockKey(state, raw);
+ locked.add(k);
+ // Display keys keep real casing (findProtectedWrite returns the
+ // case-preserving canonical path); folded keys only match the set.
+ display.set(
+ canonicalPath(path.resolve(state.workspaceDir ?? process.cwd(), raw), false),
+ raw
+ );
+ }
+ }
+ const base = state.workspaceDir ? canonicalPath(state.workspaceDir, false) : process.cwd();
+ const cwd = typeof input.cwd === 'string' && input.cwd ? path.resolve(base, input.cwd) : base;
+ const hit = findProtectedWrite(input.command, cwd, locked);
+ if (hit) {
+ const shown = display.get(hit) ?? path.relative(base, hit) ?? hit;
+ return `shell write to edit-locked file ${shown || hit}`;
+ }
+ }
+
if (toolName === 'web_search' && state.hasSufficientRssNewsEvidence) {
return 'this user turn already has a dated RSS news snapshot with sufficient publisher provenance';
}
@@ -609,6 +1014,21 @@ export function shouldShortCircuitToolCall(
return 'a fresh-news search is already in progress in this assistant step';
}
+ // O96: same-command identical-output streak (independent of byToolFailure).
+ if (VERIFY_SHELL_TOOLS.has(toolName)) {
+ const sameCmdLimit = resolveOptionalPositiveIntEnv(
+ 'MOSS_TOOL_LOOP_SAME_COMMAND_LIMIT',
+ DEFAULT_SAME_COMMAND_FAILURE_LIMIT
+ );
+ const sig = normalizeVerifyCommandSignature(toolName, input);
+ if (sig && sameCmdLimit !== undefined) {
+ const streak = state.bySameCommandFailure.get(sig.key);
+ if (streak && streak.count >= sameCmdLimit) {
+ return `same check failed repeatedly (${streak.count}×): ${streak.displayCommand}`;
+ }
+ }
+ }
+
// web_fetch failures are tracked per-URL (see ToolLoopGuardState), so the
// block decision for a given web_fetch call keys off its own URL, not the
// aggregate tool-level counter. A different URL that has never failed stays
@@ -626,6 +1046,23 @@ export function shouldShortCircuitToolCall(
// against, and the identical-input / single-tool / total guards below
// still bound it.
} else if (effectiveFailureLimit !== undefined && failureCount >= effectiveFailureLimit) {
+ // O96: encode fault kind so the message says 超时 / 未能启动, not "test failed".
+ if (VERIFY_SHELL_TOOLS.has(toolName)) {
+ const kind = state.lastShellFaultKind.get(toolName) ?? 'unusable';
+ if (kind === 'timeout') {
+ return `${toolName} timed out ${failureCount} time(s) in this user turn`;
+ }
+ if (kind === 'start_failure') {
+ const missing = state.lastMissingCommand?.get(toolName);
+ return `${toolName} failed to start ${failureCount} time(s) in this user turn${
+ missing ? ` (missing command: ${missing})` : ''
+ }`;
+ }
+ if (kind === 'killed') {
+ return `${toolName} was killed ${failureCount} time(s) in this user turn`;
+ }
+ return `${toolName} returned unusable results ${failureCount} time(s) in this user turn`;
+ }
return `${toolName} has failed ${failureCount} time(s) in this user turn`;
}
if (identicalLimit !== undefined && sameSignatureCount >= identicalLimit) {
diff --git a/src/core/tools/tool-types.ts b/src/core/tools/tool-types.ts
index 050aadc0..9f2ffed9 100644
--- a/src/core/tools/tool-types.ts
+++ b/src/core/tools/tool-types.ts
@@ -20,6 +20,11 @@ export interface SubagentRunProgress {
export interface ToolContext {
workspaceDir: string;
+ /**
+ * O96: lets a string-returning tool mark its result as an error structurally
+ * (e.g. exec timeout / signal kill) instead of relying on output text.
+ */
+ markResultError?: () => void;
bootstrapDir?: string;
runId?: string;
sessionKey: string;
@@ -162,6 +167,12 @@ export type ToolPlanMode = 'allow' | 'audit' | 'requires_user_confirmation';
export interface ToolMetadata {
permissionBoundary?: string;
sideEffectClass?: ToolSideEffectClass;
+ /**
+ * O122/O128: never answer this tool from the loop's identical-call replay
+ * cache. For read_file, execute() records the read scope the edit gate
+ * checks; a replayed result skips that, so a full re-read never counted.
+ */
+ noReplay?: boolean;
planMode?: ToolPlanMode;
requiresApproval?: boolean;
diff --git a/src/index.ts b/src/index.ts
index 6bf59898..eebd30f3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -41,6 +41,36 @@ export type {
ContextWindowGuardResult,
} from './context/index.js';
+// Embedded hosts use the same context policy and summarizer as the CLI.
+export {
+ buildCompactionSummary,
+ estimateMessagesTokens,
+ DEFAULT_CONTEXT_WINDOW_TOKENS,
+ getEffectiveContextWindowTokens,
+ getProactiveCompactThreshold,
+ getContextWarningThreshold,
+ shouldProactiveCompactByWindowEconomics,
+ AUTOCOMPACT_BUFFER_TOKENS,
+ SUMMARY_OUTPUT_CAP_TOKENS,
+} from './context/index.js';
+export type { SummarizeFn, ContextPruningSettings } from './context/index.js';
+
+// Hosts may wrap their own file and shell tools without copying these policies.
+export {
+ assertSandboxPath,
+ resolveSandboxPath,
+ redactSecretsInText,
+ appendShellContinueHint,
+} from './safety/index.js';
+export {
+ atomicWriteFile,
+ parsePatch,
+ applyUpdateHunk,
+ extractAddContent,
+ mossRunTrace,
+} from './utils/index.js';
+export type { ParsedPatch, PatchHunk } from './utils/index.js';
+
export * from './provider/index.js';
export {
diff --git a/src/provider/llm-error-classifier.ts b/src/provider/llm-error-classifier.ts
index d9bccb31..902af409 100644
--- a/src/provider/llm-error-classifier.ts
+++ b/src/provider/llm-error-classifier.ts
@@ -85,6 +85,16 @@ export function classifyLlmError(error: unknown): LlmErrorClassification {
);
return { category: 'timeout', retryable: !proxySet, message };
}
+ // O114: a mid-stream stall (watchdog phase 'inter') is a transient upstream
+ // hiccup — retry it within the normal llm-call budget (maxLLMRetries, default
+ // 2). Phase 'first' is intentionally left on its pre-existing path.
+ if (
+ error instanceof Error &&
+ error.name === 'PiAiFirstEventTimeoutError' &&
+ (error as { phase?: unknown }).phase === 'inter'
+ ) {
+ return { category: 'timeout', retryable: true, message };
+ }
if (isAbortFailure(message)) {
return { category: 'user_abort', retryable: false, message };
}
diff --git a/src/provider/pi-ai-watchdog.ts b/src/provider/pi-ai-watchdog.ts
index bd4eb363..a3d6c1c6 100644
--- a/src/provider/pi-ai-watchdog.ts
+++ b/src/provider/pi-ai-watchdog.ts
@@ -18,7 +18,7 @@ const INTER_EVENT_TIMEOUT_MS_DEFAULT = 60_000;
const INTER_EVENT_TIMEOUT_MS_MIN = 0; // 0 = disabled
const INTER_EVENT_TIMEOUT_MS_MAX = 600_000;
-function resolveInterEventTimeoutMs(): number {
+export function resolveInterEventTimeoutMs(): number {
const raw = envPreferMoss('MOSS_PI_AI_INTER_EVENT_TIMEOUT_MS', 'PI_AI_INTER_EVENT_TIMEOUT_MS');
if (raw == null) return INTER_EVENT_TIMEOUT_MS_DEFAULT;
const s = String(raw).trim();
@@ -45,8 +45,9 @@ export class PiAiFirstEventTimeoutError extends Error {
const secs = Math.round(params.timeoutMs / 1000);
const message =
phase === 'inter'
- ? `pi-ai (${params.provider} / ${params.model}) 流在中途停滞——已吐出部分事件后 ${secs}s 内无新事件,` +
- `大半是上游网关在生成中途卡住。已主动中止本次调用,建议稍后再试或换一个模型/供应商。`
+ ? `上游流停滞:pi-ai (${params.provider} / ${params.model}) 流在中途停滞——已吐出部分事件后 ${secs}s 内无新事件,` +
+ `大半是上游网关在生成中途卡住。已主动中止本次调用,建议稍后再试或换一个模型/供应商。` +
+ ` (Upstream stream stalled: no new events for ${secs}s mid-response; call aborted.)`
: `pi-ai (${params.provider} / ${params.model}) 在 ${secs}s 内未吐出任何流事件,` +
`大半是上游网关在 429/超载/超时后内部反复重试。已主动中止本次调用,建议稍后再试或换一个模型/供应商。`;
super(message);
@@ -91,6 +92,7 @@ export function startFirstEventWatchdog(
const ctrl = new AbortController();
let firedByTimeout = false;
let firedPhase: 'first' | 'inter' = 'first';
+ let activeTimeoutMs = timeoutMs;
let timer: ReturnType | null = setTimeout(() => {
firedByTimeout = true;
try {
@@ -113,11 +115,12 @@ export function startFirstEventWatchdog(
clear();
if (!firedByTimeout) {
- // Use the configurable inter-event timeout, but still cap at timeoutMs
- // (the first-event deadline is always an upper bound on the whole call).
- const configuredMs = resolveInterEventTimeoutMs();
- if (configuredMs <= 0) return; // disabled — only first-event timeout applies
- const interEventMs = Math.min(timeoutMs, configuredMs);
+ // O114: the inter-event idle limit is independent of the first-event
+ // deadline (default 60s vs 45s). It used to be Math.min()'d with the
+ // first-event timeout, which silently capped mid-stream idle at 45s.
+ const interEventMs = resolveInterEventTimeoutMs();
+ if (interEventMs <= 0) return; // disabled — only first-event timeout applies
+ activeTimeoutMs = interEventMs;
timer = setTimeout(() => {
firedByTimeout = true;
firedPhase = 'inter';
@@ -134,7 +137,7 @@ export function startFirstEventWatchdog(
if (!firedByTimeout) return err;
if (callerSignal?.aborted) return err;
return new PiAiFirstEventTimeoutError({
- timeoutMs,
+ timeoutMs: activeTimeoutMs,
provider: modelInfo?.provider ?? 'unknown',
model: modelInfo?.id ?? 'unknown',
phase: firedPhase,
diff --git a/src/safety/credential-path.ts b/src/safety/credential-path.ts
index 6ddd6c46..3aa3530e 100644
--- a/src/safety/credential-path.ts
+++ b/src/safety/credential-path.ts
@@ -4,7 +4,7 @@
* extensions never do (`env-credentials.ts`, `docs/credentials.md`).
*/
import path from 'node:path';
-import { resolveReadPath } from './read-scope.js';
+import { credentialPathFollowCandidates, resolveReadPath } from './read-scope.js';
export const CREDENTIAL_WITHHELD = 'Moss credential values withheld.\n';
@@ -138,7 +138,12 @@ export function targetsCredentialFile(toolName: string, input: Record = {
+ node: ['-e', '--eval', '-p', '--print'],
+ nodejs: ['-e', '--eval', '-p', '--print'],
+ bun: ['-e', '--eval'],
+ deno: ['eval'],
+ python: ['-c'],
+ python3: ['-c'],
+ python2: ['-c'],
+ ruby: ['-e'],
+ php: ['-r'],
+ perl: ['-e', '-E'],
+ lua: ['-e'],
+};
+
+const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'ash']);
+const SCRIPT_EXTS = new Set([
+ '.py',
+ '.js',
+ '.mjs',
+ '.cjs',
+ '.ts',
+ '.sh',
+ '.bash',
+ '.zsh',
+ '.rb',
+ '.pl',
+ '.php',
+ '.lua',
+]);
+
+const WRITE_API =
+ /\b(?:writeFile|writeFileSync|appendFile|appendFileSync|createWriteStream|copyFile|copyFileSync|rename|renameSync|truncate|truncateSync|write_text|write_bytes|shutil\.(?:copy|move|copyfile|copy2)|os\.(?:replace|remove|unlink|rename)|Path\([^)]*\)\.(?:write_text|write_bytes|open)|File\.write|file_put_contents|io\.open)\b|\bopen\s*\([^)]*['"][wax+]|\.write\s*\(|\bfs\.\w*[wW]rite|>>?\s*[^\s&|;]|tee\s+/;
+
+/** Path-like tokens that look protected (literal form in source). */
+const PROTECTED_LIT =
+ /(?:~\/\.ssh|\$HOME\/\.ssh|\$\{HOME\}\/\.ssh|\.ssh\/|\/\.ssh(?:\/|$)|authorized_keys|id_rsa|id_ed25519|\.git\/(?:config|HEAD|hooks|refs)|(?:^|[\s'"`=(,[{])\.env(?:\.[\w.-]+)?(?:$|[\s'"`),;\]}])|\.envrc|\.bashrc|\.zshrc|\.profile(?:$|[\s'"`])|os\.path\.expanduser\s*\(\s*['"]~\/\.ssh)/i;
+
+export interface IndirectWriteSource {
+ kind: 'inline' | 'script';
+ /** Script path relative to cwd, or `python -c` / `node -e` for inline. */
+ script: string;
+ line?: number;
+ /** Offending line text (never contains file contents we wrote — only source). */
+ detail?: string;
+ /** O129: script realpath is outside the workspace (after resolving symlinks). */
+ outsideWorkspace?: boolean;
+}
+
+export interface IndirectWriteFinding {
+ /** Path text as it appears in the source (e.g. `~/.ssh/authorized_keys`). */
+ raw: string;
+ source: IndirectWriteSource;
+ /** Scan could not fully decide (symlink to protected, unreadable + named). */
+ uncertain?: boolean;
+}
+
+export type ScriptScanStatus =
+ | 'ok'
+ | 'skipped-runner'
+ | 'outside-workspace'
+ | 'unreadable'
+ | 'too-large'
+ | 'binary';
+
+export interface ScriptScanResult {
+ status: ScriptScanStatus;
+ findings: IndirectWriteFinding[];
+ scriptPath?: string;
+}
+
+function basenameCmd(word: string): string {
+ return (
+ word
+ .split(/[\\/]/)
+ .pop()
+ ?.toLowerCase()
+ .replace(/\.exe$/, '') ?? ''
+ );
+}
+
+function tokenizeSimple(segment: string): string[] {
+ const tokens: string[] = [];
+ const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
+ let m: RegExpExecArray | null;
+ while ((m = pattern.exec(segment))) tokens.push(m[1] ?? m[2] ?? m[3] ?? '');
+ return tokens;
+}
+
+function skipPrefixes(tokens: string[]): number {
+ let i = 0;
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]!)) i += 1;
+ const prefixes = new Set(['sudo', 'doas', 'env', 'command', 'nohup', 'time', 'nice', 'stdbuf']);
+ while (i < tokens.length && prefixes.has(basenameCmd(tokens[i]!))) {
+ i += 1;
+ while (i < tokens.length && (tokens[i]!.startsWith('-') || /^[A-Za-z_]\w*=/.test(tokens[i]!))) {
+ const flag = tokens[i]!;
+ i += 1;
+ if (/^-[^-]*[uCg]$/.test(flag) || flag === '--user' || flag === '--group') i += 1;
+ }
+ }
+ return i;
+}
+
+function lineOf(text: string, index: number): number {
+ let line = 1;
+ for (let i = 0; i < index && i < text.length; i += 1) if (text[i] === '\n') line += 1;
+ return line;
+}
+
+function extractProtectedRaw(match: string): string {
+ const m = match.match(
+ /(~\/\.ssh(?:\/[\w.-]+)?|\.ssh\/[\w.-]+|\.git\/(?:config|HEAD|hooks\/[\w.-]+|refs(?:\/[\w./-]+)?)|\.env(?:\.[\w.-]+)?|\.envrc|\.bashrc|\.zshrc|\.profile)/i
+ );
+ return m?.[1] ?? match.trim().slice(0, 80);
+}
+
+function looksBinary(buf: Buffer): boolean {
+ const n = Math.min(buf.length, 8000);
+ let weird = 0;
+ for (let i = 0; i < n; i += 1) {
+ const b = buf[i]!;
+ if (b === 0) return true;
+ if (b < 7 || (b > 14 && b < 32 && b !== 9 && b !== 10 && b !== 13)) weird += 1;
+ }
+ return weird / Math.max(n, 1) > 0.3;
+}
+
+function realpathSafe(p: string): string | undefined {
+ try {
+ return fs.realpathSync.native(p);
+ } catch {
+ return undefined;
+ }
+}
+
+function isUnder(child: string, parent: string): boolean {
+ const c = path.resolve(child).replace(/\\/g, '/').toLowerCase();
+ const p = path.resolve(parent).replace(/\\/g, '/').toLowerCase();
+ return c === p || c.startsWith(p.endsWith('/') ? p : `${p}/`);
+}
+
+/**
+ * Scan source text for a write API near a protected-path literal.
+ * Returns findings with line numbers; never executes the source.
+ */
+export function scanSourceForProtectedWrites(
+ source: string,
+ displayName: string,
+ kind: 'inline' | 'script'
+): IndirectWriteFinding[] {
+ if (!source || !WRITE_API.test(source)) return [];
+ const findings: IndirectWriteFinding[] = [];
+ const seen = new Set();
+ let m: RegExpExecArray | null;
+ const re = new RegExp(PROTECTED_LIT.source, 'gi');
+ while ((m = re.exec(source)) !== null) {
+ const raw = extractProtectedRaw(m[0]!);
+ const key = raw.toLowerCase();
+ if (seen.has(key)) continue;
+ seen.add(key);
+ const line = lineOf(source, m.index);
+ const lineText = source.split('\n')[line - 1]?.trim().slice(0, 120) ?? '';
+ findings.push({
+ raw,
+ source: {
+ kind,
+ script: displayName,
+ line,
+ detail: lineText.replace(/[\u0000-\u001f\u007f]/g, ''),
+ },
+ });
+ }
+ return findings;
+}
+
+/**
+ * Read one workspace script (no execute) and look for protected-path writes.
+ * `workspaceRoot` / `cwd` must be absolute.
+ */
+export function scanWorkspaceScript(
+ scriptPath: string,
+ cwd: string,
+ workspaceRoot?: string
+): ScriptScanResult {
+ // O129: compare realpath to realpath. On macOS /var and /tmp are symlinks
+ // (/private/var), so a lexical workspace never contained the realpath'd
+ // script and the scan silently returned nothing.
+ const lexCwd = path.resolve(cwd);
+ const realCwd = realpathSafe(lexCwd) ?? lexCwd;
+ cwd = realCwd;
+ const abs = path.resolve(realCwd, scriptPath);
+ const lexWorkspace = path.resolve(workspaceRoot ?? lexCwd);
+ const workspace = realpathSafe(lexWorkspace) ?? lexWorkspace;
+ const real = realpathSafe(abs) ?? abs;
+ if (!isUnder(real, workspace) && !isUnder(real, lexWorkspace)) {
+ // O129: never silent. An existing script outside the workspace is still
+ // scanned read-only; any finding is uncertain (asks instead of passing).
+ let findings: IndirectWriteFinding[] = [];
+ try {
+ const st = fs.statSync(real);
+ if (st.isFile() && st.size <= MAX_SCRIPT_BYTES) {
+ const buf = fs.readFileSync(real);
+ if (!looksBinary(buf)) {
+ findings = scanSourceForProtectedWrites(buf.toString('utf8'), real, 'script').map(
+ (f) => ({ ...f, source: { ...f.source, outsideWorkspace: true }, uncertain: true })
+ );
+ }
+ }
+ } catch {
+ /* unreadable: caller treats outside-workspace as scan failure */
+ }
+ return { status: 'outside-workspace', findings, scriptPath: real };
+ }
+ // Symlink whose target looks like a protected path → uncertain finding.
+ try {
+ const st = fs.lstatSync(abs);
+ if (st.isSymbolicLink()) {
+ const target = fs.readlinkSync(abs);
+ if (PROTECTED_LIT.test(target) || /(?:^|\/)(?:\.ssh|\.git)(?:\/|$)/i.test(target)) {
+ return {
+ status: 'ok',
+ scriptPath: real,
+ findings: [
+ {
+ raw: target,
+ source: {
+ kind: 'script',
+ script: scriptPath,
+ line: 1,
+ detail: `symlink → ${target}`,
+ },
+ uncertain: true,
+ },
+ ],
+ };
+ }
+ }
+ } catch {
+ /* fall through to read */
+ }
+ let buf: Buffer;
+ try {
+ const st = fs.statSync(real);
+ if (st.size > MAX_SCRIPT_BYTES) return { status: 'too-large', findings: [], scriptPath: real };
+ buf = fs.readFileSync(real);
+ } catch {
+ return { status: 'unreadable', findings: [], scriptPath: real };
+ }
+ if (looksBinary(buf)) return { status: 'binary', findings: [], scriptPath: real };
+ const text = buf.toString('utf8');
+ const display = path.relative(cwd, real) || scriptPath;
+ return {
+ status: 'ok',
+ scriptPath: real,
+ findings: scanSourceForProtectedWrites(text, display.replace(/\\/g, '/'), 'script'),
+ };
+}
+
+/** Collect script-file operands from a shell command (best effort). */
+export function listInvokedScripts(command: string): string[] {
+ const out: string[] = [];
+ for (const segment of command.split(/(?:&&|\|\||[;|\n])/g)) {
+ const tokens = tokenizeSimple(segment.trim());
+ const i = skipPrefixes(tokens);
+ if (i >= tokens.length) continue;
+ const cmd = basenameCmd(tokens[i]!);
+ const args = tokens.slice(i + 1);
+
+ if (SHELLS.has(cmd)) {
+ const cIdx = args.findIndex((a) => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a));
+ if (cIdx >= 0) continue;
+ const file = args.find((a) => !a.startsWith('-'));
+ if (file) out.push(file);
+ continue;
+ }
+
+ const inlineFlags =
+ INTERPRETER_INLINE[cmd] ?? (/^python3\.\d+$/.test(cmd) ? ['-c'] : undefined);
+ if (inlineFlags) {
+ if (args.some((a) => inlineFlags.includes(a))) continue;
+ if (args[0] === '-m') continue;
+ const file = args.find(
+ (a) => !a.startsWith('-') && SCRIPT_EXTS.has(path.extname(a).toLowerCase())
+ );
+ if (file) out.push(file);
+ else {
+ const any = args.find((a) => !a.startsWith('-') && !a.includes('='));
+ if (any && /\.(py|js|mjs|cjs|ts|sh|bash|zsh|rb|pl)$/i.test(any)) out.push(any);
+ }
+ continue;
+ }
+
+ const head = tokens[i]!;
+ if (
+ SCRIPT_EXTS.has(path.extname(head).toLowerCase()) ||
+ /^\.\/.*\.(sh|bash|zsh|py|js)$/i.test(head)
+ ) {
+ out.push(head);
+ }
+ }
+ return out;
+}
+
+/** Collect inline interpreter scripts from the command. */
+export function listInlineScripts(command: string): Array<{ lang: string; code: string }> {
+ const out: Array<{ lang: string; code: string }> = [];
+ for (const segment of command.split(/(?:&&|\|\||[;|\n])/g)) {
+ const tokens = tokenizeSimple(segment.trim());
+ const i = skipPrefixes(tokens);
+ if (i >= tokens.length) continue;
+ const cmd = basenameCmd(tokens[i]!);
+ const args = tokens.slice(i + 1);
+
+ if (SHELLS.has(cmd)) {
+ const cIdx = args.findIndex((a) => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a));
+ if (cIdx >= 0 && args[cIdx + 1] !== undefined) {
+ out.push({ lang: cmd, code: args[cIdx + 1]! });
+ }
+ continue;
+ }
+ const inlineFlags =
+ INTERPRETER_INLINE[cmd] ?? (/^python3\.\d+$/.test(cmd) ? ['-c'] : undefined);
+ if (!inlineFlags) continue;
+ const fIdx = args.findIndex((a) => inlineFlags.includes(a));
+ if (fIdx >= 0 && args[fIdx + 1] !== undefined) {
+ out.push({ lang: cmd, code: args[fIdx + 1]! });
+ }
+ }
+ return out;
+}
+
+/**
+ * Layer 1 + 2 entry: find protected-path writes in inline code and invoked
+ * workspace scripts. Does not replace O97's command-line detector.
+ */
+export function findIndirectProtectedWrites(
+ command: string,
+ cwd: string,
+ workspaceRoot?: string
+): IndirectWriteFinding[] {
+ if (!command.trim()) return [];
+ const findings: IndirectWriteFinding[] = [];
+ const seen = new Set();
+ const push = (f: IndirectWriteFinding) => {
+ const key = `${f.raw.toLowerCase()}|${f.source.script}|${f.source.line ?? 0}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ findings.push(f);
+ };
+
+ for (const { lang, code } of listInlineScripts(command)) {
+ const label =
+ lang === 'node' || lang === 'nodejs'
+ ? 'node -e'
+ : SHELLS.has(lang)
+ ? `${lang} -c`
+ : `${lang} -c`;
+ for (const f of scanSourceForProtectedWrites(code, label, 'inline')) push(f);
+ }
+
+ if (SKIP_DEEP_SCAN.test(command)) return findings;
+
+ let sawUnreadable = false;
+ for (const script of listInvokedScripts(command)) {
+ const result = scanWorkspaceScript(script, cwd, workspaceRoot);
+ if (result.status === 'ok') {
+ for (const f of result.findings) push(f);
+ } else if (result.status === 'outside-workspace' && result.findings.length) {
+ for (const f of result.findings) push(f);
+ } else if (
+ result.status === 'unreadable' ||
+ result.status === 'too-large' ||
+ result.status === 'binary' ||
+ result.status === 'outside-workspace'
+ ) {
+ sawUnreadable = true;
+ }
+ }
+
+ if (sawUnreadable && findings.length === 0) {
+ const named = command.match(PROTECTED_LIT);
+ if (named) {
+ push({
+ raw: extractProtectedRaw(named[0]!),
+ source: {
+ kind: 'script',
+ script: '(unreadable)',
+ detail: 'scan failed; command names protected path',
+ },
+ uncertain: true,
+ });
+ }
+ }
+
+ return findings;
+}
+
+export function shouldSkipDeepScan(command: string): boolean {
+ return SKIP_DEEP_SCAN.test(command);
+}
+
+/** True when text looks like it names a protected path (for uncertain scan). */
+export function textMentionsProtectedLiteral(text: string): boolean {
+ return PROTECTED_LIT.test(text);
+}
diff --git a/src/safety/protected-path-snapshot.ts b/src/safety/protected-path-snapshot.ts
new file mode 100644
index 00000000..c0b7e4db
--- /dev/null
+++ b/src/safety/protected-path-snapshot.ts
@@ -0,0 +1,1029 @@
+/**
+ * O101 layer 3: post-run detection of protected-path changes.
+ *
+ * Takes a metadata snapshot (mtime, size, content hash) BEFORE a shell
+ * command runs and diffs it AFTER. On change we report "已被修改" (modified),
+ * never "已拦截" (intercepted) — the command already ran. No auto-restore.
+ *
+ * Never puts file contents, secret values, or full hashes into notices,
+ * run-outcome lines, tool results, or model context. Hash is compare-only.
+ *
+ * Watched: ~/.ssh/*, workspace .env / .env.* / .envrc, and under .git only
+ * config, HEAD, refs/**, hooks/** (including newly added hooks). For
+ * `.git/config` content edits, changes confined to allow-listed benign keys
+ * are not reported (O117, GIT_CONFIG_BENIGN_KEYS); any other key counts; add/delete/symlink swap or unparsable
+ * content still reports the whole file (fail-closed). Normal
+ * git commit writes (index, objects, logs, ORIG_HEAD, …) are ignored, and
+ * HEAD / refs/** changes are reported only when anomalous (O123: points
+ * outside refs/, not a commit id, symlink/dir swap).
+ *
+ * Snapshot unavailable (permission) → skip that path; never false-report
+ * "modified". `exec_background` runs the same check on full process exit
+ * (O108). Delayed writes via `&` / `nohup` inside a foreground command, and
+ * writes after the Moss process itself exits, remain known limits.
+ */
+import { spawnSync } from 'node:child_process';
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+export type PathChangeKind = 'added' | 'modified' | 'deleted';
+
+export interface ProtectedPathChange {
+ /** Display path (~/.ssh/… or relative to cwd). */
+ path: string;
+ kind: PathChangeKind;
+ /**
+ * O117: for `.git/config` content changes, the non-allow-listed keys whose
+ * values changed (names only, never values). Absent = whole-file report.
+ */
+ keys?: string[];
+ /**
+ * O117: why a git config file is reported without keys (fail-closed):
+ * replaced by a symlink / directory, or content git cannot parse.
+ */
+ reason?: GitConfigChangeReason;
+}
+
+export type GitConfigChangeReason =
+ | 'symlink'
+ | 'dir'
+ | 'unparsable'
+ /** O123: `.git/HEAD` / a ref points outside `refs/` (e.g. `ref: ../x`). */
+ | 'ref-outside'
+ /** O123: ref / HEAD content is not a commit id or `ref: refs/…`. */
+ | 'ref-invalid'
+ /** O127: the worktree's `.git` file was rewritten (gitdir pointer changed). */
+ | 'gitlink-rewritten'
+ /** O127: `.git` file's gitdir / commondir does not exist or cannot be read. */
+ | 'gitlink-missing';
+
+const ALL_REASONS: readonly GitConfigChangeReason[] = [
+ 'symlink',
+ 'dir',
+ 'unparsable',
+ 'ref-outside',
+ 'ref-invalid',
+ 'gitlink-rewritten',
+ 'gitlink-missing',
+];
+
+/** Internal meta — never log `hash` in full. */
+interface PathMeta {
+ mtimeMs: number;
+ size: number;
+ /** sha256 hex; compare only. */
+ hash: string;
+ /** O117: raw bytes of git config files only (in memory, never logged). */
+ raw?: Buffer;
+ /** O123: classification of `.git/HEAD` / `.git/refs/**` content (no content kept). */
+ refState?: RefState;
+ /** O117: entry type, so symlink/dir swaps of a config file can be named. */
+ type?: 'file' | 'symlink' | 'dir';
+ /** O127: for a worktree `.git` *file*, whether its gitdir/commondir resolve. */
+ gitLink?: 'ok' | 'missing';
+}
+
+/** Sentinel: path exists but meta could not be read (permission). */
+const UNAVAILABLE = Symbol('unavailable');
+
+type Entry = PathMeta | typeof UNAVAILABLE;
+
+export interface ProtectedPathSnapshot {
+ /** Absolute path → meta or UNAVAILABLE. Absent key = did not exist. */
+ entries: Map;
+ /** Absolute → display form. */
+ display: Map;
+ takenAt: number;
+ cwd: string;
+ home: string;
+}
+
+/** Machine-readable marker for run-completion tracker (no secrets). */
+export const PROTECTED_PATH_MOD_MARKER = '[moss-o101-protected-path-modified]';
+/** O117: per-path detail (path, kind, reason, key names) for git config files. */
+export const PROTECTED_PATH_DETAIL_MARKER = '[moss-o117-protected-path-detail]';
+
+function homeDir(home?: string): string {
+ return home || process.env.HOME || process.env.USERPROFILE || os.homedir();
+}
+
+function displayFor(abs: string, cwd: string, home: string): string {
+ const rel = path.relative(cwd, abs);
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) return rel.replace(/\\/g, '/');
+ const fromHome = path.relative(home, abs);
+ if (!fromHome.startsWith('..') && !path.isAbsolute(fromHome)) {
+ return fromHome ? `~/${fromHome.replace(/\\/g, '/')}` : '~';
+ }
+ return abs.replace(/\\/g, '/');
+}
+
+/**
+ * O117: git config files whose content is diffed per key: `.git/config`,
+ * `.git/config.worktree`, `.git/worktrees//config.worktree`.
+ */
+export function isGitConfigPath(abs: string): boolean {
+ return /(^|[\\/])\.git[\\/](?:config|config\.worktree|worktrees[\\/][^\\/]+[\\/]config\.worktree)$/.test(
+ abs
+ );
+}
+
+/**
+ * O117 (PM, allow-list): the only `.git/config` keys whose changes are NOT
+ * reported. Every other key change — including ones never seen before — is
+ * reported as "已被修改" (fail-closed). Kept deliberately small: nothing that
+ * can run a program, pull in other config, or change where code is fetched
+ * from. Remote/branch leaves are handled separately (PM 口径, see
+ * GIT_CONFIG_BENIGN_SUBSECTION_LEAVES / isSafeGitRemoteUrl).
+ *
+ * Matching is on the normalised key: section and variable name are
+ * case-insensitive, the subsection keeps git's case-sensitive spelling.
+ */
+export const GIT_CONFIG_BENIGN_KEYS: readonly string[] = [
+ 'user.name',
+ 'user.email',
+ 'color.ui',
+ 'init.defaultBranch',
+ 'pull.rebase',
+ 'core.autocrlf',
+];
+
+/** Lower-case section + variable name; keep subsection as git does. */
+export function normalizeGitConfigKey(key: string): string {
+ const first = key.indexOf('.');
+ const last = key.lastIndexOf('.');
+ if (first === -1) return key.toLowerCase();
+ if (first === last) return key.toLowerCase();
+ return `${key.slice(0, first).toLowerCase()}${key.slice(first, last)}${key.slice(last).toLowerCase()}`;
+}
+
+export function isBenignGitConfigKey(key: string): boolean {
+ // Exact two-part keys only (no wildcards, no subsections): QA gate.
+ const k = normalizeGitConfigKey(key);
+ if (k.indexOf('.') !== k.lastIndexOf('.')) return false;
+ return GIT_CONFIG_BENIGN_KEYS.some((e) => e.toLowerCase() === k);
+}
+
+/**
+ * O117 (PM, remote 口径): three-part leaf keys allowed regardless of value,
+ * for any subsection name. Every other `remote..*` / `branch..*` leaf
+ * (uploadpack, receivepack, proxy, pushurl, vcs, ...) stays reported.
+ */
+export const GIT_CONFIG_BENIGN_SUBSECTION_LEAVES: readonly string[] = [
+ 'branch.*.merge',
+ 'remote.*.fetch',
+];
+
+/** O117: allowed by value only (see isSafeGitRemoteUrl). */
+export const GIT_CONFIG_VALUE_CHECKED_LEAVES: readonly string[] = ['remote.*.url'];
+
+/**
+ * O117: `branch..remote` may hold a URL instead of a remote name (git then
+ * fetches/pushes that URL directly), so it is allowed by value: `.`, a plain
+ * remote name (no `/`, `:`, leading `-`), or a URL isSafeGitRemoteUrl accepts.
+ */
+export function isSafeGitBranchRemote(v: string): boolean {
+ if (v === '.') return true;
+ if (/^[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(v)) return true;
+ return isSafeGitRemoteUrl(v);
+}
+
+function splitSubsectionKey(k: string): { section: string; sub: string; name: string } | null {
+ const first = k.indexOf('.');
+ const last = k.lastIndexOf('.');
+ if (first === -1 || first === last) return null;
+ return { section: k.slice(0, first), sub: k.slice(first + 1, last), name: k.slice(last + 1) };
+}
+
+function leafPattern(k: string): string | null {
+ const p = splitSubsectionKey(k);
+ if (!p || p.sub.length === 0) return null;
+ return `${p.section}.*.${p.name}`;
+}
+
+function safeDecode(x: string): string | null {
+ try {
+ let cur = x;
+ // Decode repeatedly so %252D style double-encoding is also caught.
+ for (let i = 0; i < 3; i++) {
+ const next = decodeURIComponent(cur);
+ if (next === cur) break;
+ cur = next;
+ }
+ return cur;
+ } catch {
+ return null;
+ }
+}
+
+function hostUserOk(raw: string): boolean {
+ const v = safeDecode(raw);
+ if (v === null) return false;
+ if (v.length === 0) return false;
+ if (v.startsWith('-')) return false;
+ if (/[\s\u0000-\u001f\u007f]/.test(v)) return false;
+ return true;
+}
+
+/**
+ * O117 (PM): `remote..url` is allowed only for `https://`, `ssh://` and
+ * scp-style `user@host:path`. Everything else (ext::, fd::, file://, local
+ * paths, leading `-`, newline/control chars, host or user beginning with `-`
+ * after URL-decoding — CVE-2017-1000117 —, non-numeric port) is reported.
+ */
+export function isSafeGitRemoteUrl(url: string): boolean {
+ if (url.length === 0 || url.startsWith('-')) return false;
+ if (/[\u0000-\u001f\u007f\s]/.test(url)) return false;
+ const dec = safeDecode(url);
+ if (dec === null) return false;
+ if (dec.startsWith('-') || /[\u0000-\u001f\u007f]/.test(dec)) return false;
+ if (/^[A-Za-z0-9+.-]*::/.test(url)) return false; // ext::, fd::, ::
+ const m = /^(https|ssh):\/\/([^/?#]*)(.*)$/i.exec(url);
+ if (m) {
+ let auth = m[2];
+ if (!auth) return false;
+ let user: string | undefined;
+ const at = auth.lastIndexOf('@');
+ if (at !== -1) {
+ user = auth.slice(0, at);
+ auth = auth.slice(at + 1);
+ if (m[1].toLowerCase() === 'ssh' && user.includes(':')) return false;
+ const uname = user.split(':')[0];
+ if (!hostUserOk(uname)) return false;
+ }
+ let host = auth;
+ let port: string | undefined;
+ if (host.startsWith('[')) {
+ const close = host.indexOf(']');
+ if (close === -1) return false;
+ const rest = host.slice(close + 1);
+ host = host.slice(1, close);
+ if (rest) {
+ if (!rest.startsWith(':')) return false;
+ port = rest.slice(1);
+ }
+ } else {
+ const c = host.indexOf(':');
+ if (c !== -1) {
+ port = host.slice(c + 1);
+ host = host.slice(0, c);
+ }
+ }
+ if (!hostUserOk(host)) return false;
+ if (port !== undefined && port !== '' && !/^[0-9]+$/.test(port)) return false;
+ return true;
+ }
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return false; // file://, git://, http:// ...
+ // scp style: [user@]host:path — host must contain no '/', and a ':' must
+ // come before any '/', otherwise git treats it as a local path.
+ const scp = /^(?:([^@/:]+)@)?([^@/:[\]]+|\[[^\]]+\]):(.*)$/.exec(url);
+ if (!scp) return false;
+ if (scp[1] !== undefined && !hostUserOk(scp[1])) return false;
+ const host = scp[2].replace(/^\[|\]$/g, '');
+ if (!hostUserOk(host)) return false;
+ // A single-letter "host" is a Windows drive path (C:\x), i.e. local.
+ if (/^[a-z]$/i.test(host)) return false;
+ if (scp[3].startsWith('-')) return false;
+ return true;
+}
+
+/**
+ * O117: whether a key change is allowed given its new values. `afterValues`
+ * is the full list after the command (empty = key removed).
+ */
+export function isBenignGitConfigEntry(key: string, afterValues: readonly string[]): boolean {
+ if (isBenignGitConfigKey(key)) return true;
+ const k = normalizeGitConfigKey(key);
+ const pat = leafPattern(k);
+ if (!pat) return false;
+ if (GIT_CONFIG_BENIGN_SUBSECTION_LEAVES.includes(pat)) return true;
+ if (pat === 'branch.*.remote') {
+ return afterValues.every((v) => v !== '\u0000(implicit-true)' && isSafeGitBranchRemote(v));
+ }
+ if (GIT_CONFIG_VALUE_CHECKED_LEAVES.includes(pat)) {
+ return afterValues.every((v) => v !== '\u0000(implicit-true)' && isSafeGitRemoteUrl(v));
+ }
+ return false;
+}
+
+/**
+ * Parse git config bytes with git itself (`git config --file - --list -z`,
+ * no includes followed, no system/global config). Returns key→values, or
+ * null on any failure (fail-closed: caller reports the whole file).
+ */
+export function parseGitConfigEntries(raw: Buffer): Map | null {
+ let tmpDir: string | undefined;
+ try {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-o117-'));
+ const file = path.join(tmpDir, 'config');
+ fs.writeFileSync(file, raw, { mode: 0o600 });
+ const res = spawnSync('git', ['config', '--file', file, '--no-includes', '--list', '-z'], {
+ cwd: tmpDir,
+ encoding: 'buffer',
+ timeout: 5000,
+ windowsHide: true,
+ env: {
+ PATH: process.env.PATH ?? '',
+ SystemRoot: process.env.SystemRoot ?? '',
+ HOME: tmpDir,
+ GIT_CONFIG_NOSYSTEM: '1',
+ GIT_CONFIG_GLOBAL: os.devNull,
+ LC_ALL: 'C',
+ },
+ });
+ if (res.error || res.status !== 0 || !res.stdout) return null;
+ const out = new Map();
+ for (const rec of res.stdout.toString('utf8').split('\0')) {
+ if (!rec) continue;
+ const nl = rec.indexOf('\n');
+ const key = normalizeGitConfigKey(nl === -1 ? rec : rec.slice(0, nl));
+ const value = nl === -1 ? '\u0000(implicit-true)' : rec.slice(nl + 1);
+ const list = out.get(key) ?? [];
+ list.push(value);
+ out.set(key, list);
+ }
+ return out;
+ } catch {
+ return null;
+ } finally {
+ if (tmpDir) {
+ try {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ } catch {
+ /* ignore */
+ }
+ }
+ }
+}
+
+/**
+ * O117: which non-allow-listed keys differ between two `.git/config`
+ * contents. Returns [] when only allow-listed keys changed, or null when either side cannot
+ * be parsed (fail-closed → report whole file).
+ */
+export function changedUnlistedGitConfigKeys(before: Buffer, after: Buffer): string[] | null {
+ const b = parseGitConfigEntries(before);
+ const a = parseGitConfigEntries(after);
+ if (!b || !a) return null;
+ const changed: string[] = [];
+ for (const key of new Set([...b.keys(), ...a.keys()])) {
+ if (JSON.stringify(b.get(key) ?? []) === JSON.stringify(a.get(key) ?? [])) continue;
+ if (isBenignGitConfigEntry(key, a.get(key) ?? [])) continue;
+ changed.push(key);
+ }
+ changed.sort();
+ const text = `${after.toString('utf8')}\n${before.toString('utf8')}`;
+ return changed.map((k) => displayGitConfigKey(k, text));
+}
+
+function escapeRe(x: string): string {
+ return x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/**
+ * O117 (UX): show a key with the spelling the file used (e.g.
+ * `core.hooksPath`), since git itself lower-cases section + name. Falls back
+ * to the normalised form. Names only, never values.
+ */
+export function displayGitConfigKey(key: string, text: string): string {
+ const first = key.indexOf('.');
+ const last = key.lastIndexOf('.');
+ if (first === -1) return key;
+ const section = key.slice(0, first);
+ const middle = key.slice(first, last + 1);
+ const name = key.slice(last + 1);
+ const secM = text.match(new RegExp(`^\\s*\\[\\s*(${escapeRe(section)})(?=[\\s\\]".])`, 'im'));
+ const nameM = text.match(new RegExp(`^\\s*(${escapeRe(name)})\\s*(?:=|$|[#;])`, 'im'));
+ return `${secM ? secM[1] : section}${middle}${nameM ? nameM[1] : name}`;
+}
+
+/**
+ * O117: classify a git config file change. Only keys outside
+ * GIT_CONFIG_BENIGN_KEYS count; symlink/dir swap or unparsable content is
+ * reported with a reason (fail-closed). Returns undefined = not reported.
+ */
+function gitConfigChange(
+ display: string,
+ kind: PathChangeKind,
+ b: PathMeta | undefined,
+ a: PathMeta
+): ProtectedPathChange | undefined {
+ if (a.type === 'symlink') return { path: display, kind, reason: 'symlink' };
+ if (a.type === 'dir') return { path: display, kind, reason: 'dir' };
+ const before = b ? b.raw : Buffer.alloc(0);
+ if (!before || !a.raw) return { path: display, kind, reason: 'unparsable' };
+ const keys = changedUnlistedGitConfigKeys(before, a.raw);
+ if (keys === null) return { path: display, kind, reason: 'unparsable' };
+ if (keys.length === 0) return undefined;
+ return { path: display, kind, keys };
+}
+
+/**
+ * O123 (QA path allow-list): `.git/HEAD` and loose refs are what ordinary
+ * `git commit` / `checkout -b` / `merge` rewrite. Their changes are NOT
+ * reported when the new content is well-formed — a commit id, or
+ * `ref: refs/` with no `..`, absolute or backslash parts. Anything
+ * else (`ref: ../x`, garbage, a symlink, a directory swap) is reported.
+ * This is command-independent: `git -c k=v commit`, `git -C dir commit` or
+ * a script that commits are judged by what they wrote, not by their text.
+ */
+type RefState = 'ok' | 'outside' | 'invalid';
+
+export function isGitHeadOrLooseRefPath(abs: string): boolean {
+ // O127: a linked worktree's own HEAD lives at .git/worktrees//HEAD.
+ return /(^|[\\/])\.git[\\/](?:HEAD|refs[\\/].+|worktrees[\\/][^\\/]+[\\/]HEAD)$/.test(abs);
+}
+
+/**
+ * O127: where git really keeps metadata for `ws`. A linked worktree (or
+ * `--separate-git-dir` checkout) has a `.git` *file* (`gitdir: `);
+ * per-worktree state (HEAD, config.worktree) lives in that gitdir and shared
+ * state (config, hooks, refs, info…) in its `commondir`. Read with plain fs —
+ * never runs git in the repo under test. `null` dirs = cannot resolve.
+ */
+export interface GitDirs {
+ /** `.git` is a file (linked worktree / separate git dir). */
+ linked: boolean;
+ gitDir: string | null;
+ commonDir: string | null;
+}
+
+export function resolveGitDirs(ws: string): GitDirs {
+ const dotGit = path.join(ws, '.git');
+ let st: fs.Stats;
+ try {
+ st = fs.lstatSync(dotGit);
+ } catch {
+ return { linked: false, gitDir: dotGit, commonDir: dotGit };
+ }
+ if (!st.isFile()) return { linked: false, gitDir: dotGit, commonDir: dotGit };
+ let text: string;
+ try {
+ text = fs.readFileSync(dotGit, 'utf8');
+ } catch {
+ return { linked: true, gitDir: null, commonDir: null };
+ }
+ const m = text.match(/^gitdir:[ \t]*(.+?)[ \t]*\r?$/m);
+ if (!m) return { linked: true, gitDir: null, commonDir: null };
+ const gitDir = path.resolve(ws, m[1]!);
+ if (!isRealDir(gitDir)) return { linked: true, gitDir: null, commonDir: null };
+ let commonDir = gitDir;
+ try {
+ const c = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim();
+ if (c) commonDir = path.resolve(gitDir, c);
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
+ return { linked: true, gitDir, commonDir: null };
+ }
+ }
+ if (!isRealDir(commonDir)) return { linked: true, gitDir, commonDir: null };
+ return { linked: true, gitDir, commonDir };
+}
+
+function isRealDir(p: string): boolean {
+ try {
+ return fs.statSync(p).isDirectory();
+ } catch {
+ return false;
+ }
+}
+
+function isDotGitFile(abs: string): boolean {
+ return path.basename(abs) === '.git';
+}
+
+export function classifyGitRefContent(text: string): RefState {
+ const t = text.replace(/\r?\n$/, '');
+ if (/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(t)) return 'ok';
+ const m = t.match(/^ref:\s*(.*)$/);
+ if (!m) return 'invalid';
+ const target = m[1]!;
+ if (
+ !target.startsWith('refs/') ||
+ target.includes('\\') ||
+ target.split('/').some((seg) => seg === '..' || seg === '.' || seg === '') ||
+ /[\x00-\x1f]/.test(target)
+ ) {
+ return 'outside';
+ }
+ return 'ok';
+}
+
+function isGitRefsDir(abs: string): boolean {
+ return /(^|[\\/])\.git[\\/]refs$/.test(abs);
+}
+
+/** O123: HEAD / loose ref change → reported only when anomalous. */
+function gitRefChange(
+ display: string,
+ abs: string,
+ b: PathMeta | undefined,
+ a: PathMeta | undefined
+): ProtectedPathChange | undefined {
+ const isHead = /(^|[\\/])HEAD$/.test(abs) && !/[\\/]refs[\\/]/.test(abs);
+ if (!a) {
+ if (!b) return undefined;
+ // Deleting a branch / tag ref is ordinary (`git branch -d`); HEAD is not.
+ return isHead ? { path: display, kind: 'deleted' } : undefined;
+ }
+ const kind: PathChangeKind = b ? 'modified' : 'added';
+ if (b && b.hash === a.hash && b.size === a.size && b.type === a.type) return undefined;
+ if (a.type === 'symlink') return { path: display, kind, reason: 'symlink' };
+ if (a.type === 'dir') return { path: display, kind, reason: 'dir' };
+ if (a.refState === 'ok') return undefined;
+ if (a.refState === 'outside') return { path: display, kind, reason: 'ref-outside' };
+ return { path: display, kind, reason: 'ref-invalid' };
+}
+
+/** PathMeta | UNAVAILABLE | null(ENOENT). */
+function metaOf(abs: string): PathMeta | typeof UNAVAILABLE | null {
+ try {
+ const st = fs.lstatSync(abs);
+ if (st.isDirectory()) {
+ return {
+ mtimeMs: st.mtimeMs,
+ size: -1,
+ hash: crypto.createHash('sha256').update(`dir:${st.mtimeMs}`).digest('hex'),
+ type: 'dir',
+ };
+ }
+ if (st.isSymbolicLink()) {
+ const target = fs.readlinkSync(abs);
+ return {
+ mtimeMs: st.mtimeMs,
+ size: target.length,
+ hash: crypto.createHash('sha256').update(`link:${target}`).digest('hex'),
+ type: 'symlink',
+ };
+ }
+ const buf = fs.readFileSync(abs);
+ return {
+ mtimeMs: st.mtimeMs,
+ size: st.size,
+ hash: crypto.createHash('sha256').update(buf).digest('hex'),
+ type: 'file',
+ ...(isGitConfigPath(abs) ? { raw: buf } : {}),
+ ...(isDotGitFile(abs) && st.isFile()
+ ? {
+ gitLink: (() => {
+ const d = resolveGitDirs(path.dirname(abs));
+ return d.gitDir && d.commonDir ? ('ok' as const) : ('missing' as const);
+ })(),
+ }
+ : {}),
+ ...(isGitHeadOrLooseRefPath(abs)
+ ? { refState: classifyGitRefContent(buf.toString('utf8')) }
+ : {}),
+ };
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException)?.code;
+ if (code === 'ENOENT') return null;
+ return UNAVAILABLE;
+ }
+}
+
+function listDirSafe(dir: string): string[] {
+ try {
+ return fs.readdirSync(dir);
+ } catch {
+ return [];
+ }
+}
+
+function walkFiles(dir: string, out: string[], depth = 0): void {
+ if (depth > 8) return;
+ for (const name of listDirSafe(dir)) {
+ if (name === '.' || name === '..') continue;
+ const abs = path.join(dir, name);
+ let st: fs.Stats;
+ try {
+ st = fs.lstatSync(abs);
+ } catch {
+ continue;
+ }
+ if (st.isDirectory()) walkFiles(abs, out, depth + 1);
+ else out.push(abs);
+ }
+}
+
+function watchGitDir(git: string, paths: string[]): void {
+ for (const name of ['config', 'config.worktree', 'HEAD']) {
+ paths.push(path.join(git, name));
+ }
+ // O117: per-worktree config (extensions.worktreeConfig) can set
+ // core.hooksPath etc. just like .git/config.
+ for (const name of listDirSafe(path.join(git, 'worktrees'))) {
+ paths.push(path.join(git, 'worktrees', name, 'config.worktree'));
+ }
+ const hooks = path.join(git, 'hooks');
+ paths.push(hooks);
+ for (const name of listDirSafe(hooks)) {
+ paths.push(path.join(hooks, name));
+ }
+ const refs = path.join(git, 'refs');
+ paths.push(refs);
+ walkFiles(refs, paths);
+ // O123: keep reporting the rest of .git that can change behaviour:
+ // info/** (exclude, attributes, grafts…), object alternates, submodule
+ // and linked-worktree configs.
+ walkFiles(path.join(git, 'info'), paths);
+ for (const name of ['alternates', 'http-alternates']) {
+ paths.push(path.join(git, 'objects', 'info', name));
+ }
+ for (const name of listDirSafe(path.join(git, 'modules'))) {
+ paths.push(path.join(git, 'modules', name, 'config'));
+ }
+ for (const name of listDirSafe(path.join(git, 'worktrees'))) {
+ paths.push(path.join(git, 'worktrees', name, 'config'));
+ }
+}
+
+/**
+ * Collect absolute paths we watch under cwd + home.
+ * `.git` only: config, config.worktree, worktrees//config(.worktree),
+ * HEAD, refs/**, hooks/**, info/**, objects/info/(http-)alternates,
+ * modules//config.
+ */
+export function listWatchedProtectedPaths(cwd: string, home?: string): string[] {
+ const h = homeDir(home);
+ const paths: string[] = [];
+ const ssh = path.join(h, '.ssh');
+ paths.push(ssh);
+ for (const name of listDirSafe(ssh)) {
+ paths.push(path.join(ssh, name));
+ }
+
+ const ws = path.resolve(cwd);
+ for (const name of listDirSafe(ws)) {
+ if (name === '.env' || name === '.envrc' || name.startsWith('.env.')) {
+ paths.push(path.join(ws, name));
+ }
+ }
+
+ const dirs = resolveGitDirs(ws);
+ if (dirs.linked) {
+ // O127: the `.git` file itself (pointer rewrite / gitdir gone is reported)
+ // plus the real per-worktree gitdir and the shared commondir.
+ paths.push(path.join(ws, '.git'));
+ if (dirs.gitDir) {
+ for (const name of ['config.worktree', 'HEAD']) paths.push(path.join(dirs.gitDir, name));
+ }
+ }
+ if (dirs.commonDir) watchGitDir(dirs.commonDir, paths);
+
+ return [...new Set(paths)];
+}
+
+function fillSnapshot(
+ cwd: string,
+ home: string,
+ absPaths: Iterable,
+ into: ProtectedPathSnapshot
+): void {
+ for (const abs of absPaths) {
+ if (!into.display.has(abs)) into.display.set(abs, displayFor(abs, cwd, home));
+ const m = metaOf(abs);
+ if (m === null) {
+ into.entries.delete(abs);
+ continue;
+ }
+ into.entries.set(abs, m);
+ }
+}
+
+export function takeProtectedPathSnapshot(
+ cwd: string,
+ opts?: { home?: string }
+): ProtectedPathSnapshot {
+ const home = homeDir(opts?.home);
+ const resolvedCwd = path.resolve(cwd);
+ const snap: ProtectedPathSnapshot = {
+ entries: new Map(),
+ display: new Map(),
+ takenAt: Date.now(),
+ cwd: resolvedCwd,
+ home,
+ };
+ fillSnapshot(resolvedCwd, home, listWatchedProtectedPaths(resolvedCwd, home), snap);
+ return snap;
+}
+
+/**
+ * After snapshot: re-list watched paths (picks up new hooks) and also re-check
+ * every path that appeared in `before` (so deletions are visible).
+ */
+export function takeProtectedPathSnapshotAfter(
+ before: ProtectedPathSnapshot,
+ opts?: { home?: string }
+): ProtectedPathSnapshot {
+ const home = homeDir(opts?.home ?? before.home);
+ const cwd = before.cwd;
+ const snap: ProtectedPathSnapshot = {
+ entries: new Map(),
+ display: new Map(before.display),
+ takenAt: Date.now(),
+ cwd,
+ home,
+ };
+ const paths = new Set([...listWatchedProtectedPaths(cwd, home), ...before.entries.keys()]);
+ fillSnapshot(cwd, home, paths, snap);
+ return snap;
+}
+
+/**
+ * Diff two snapshots. Paths that were unavailable on either side are skipped —
+ * never false-report modified.
+ */
+export function diffProtectedPathSnapshots(
+ before: ProtectedPathSnapshot,
+ after: ProtectedPathSnapshot
+): ProtectedPathChange[] {
+ const changes: ProtectedPathChange[] = [];
+ const all = new Set([...before.entries.keys(), ...after.entries.keys()]);
+ for (const abs of all) {
+ const bHas = before.entries.has(abs);
+ const aHas = after.entries.has(abs);
+ const b = bHas ? before.entries.get(abs) : undefined;
+ const a = aHas ? after.entries.get(abs) : undefined;
+ // Unavailable on either side → skip (do not false-report).
+ if (b === UNAVAILABLE || a === UNAVAILABLE) continue;
+ const display = after.display.get(abs) ?? before.display.get(abs) ?? abs.replace(/\\/g, '/');
+ if (isDotGitFile(abs)) {
+ // O127: worktree `.git` file. Any content/type change, or its gitdir
+ // vanishing, is reported (fail-closed).
+ if (b && !a) {
+ changes.push({ path: display, kind: 'deleted' });
+ continue;
+ }
+ if (!a) continue;
+ const kind: PathChangeKind = b ? 'modified' : 'added';
+ if (a.type === 'symlink') changes.push({ path: display, kind, reason: 'symlink' });
+ else if (a.type === 'dir') changes.push({ path: display, kind, reason: 'dir' });
+ else if (b && (b.hash !== a.hash || b.type !== a.type))
+ changes.push({ path: display, kind, reason: 'gitlink-rewritten' });
+ else if (a.gitLink === 'missing' && (!b || b.gitLink !== 'missing'))
+ changes.push({ path: display, kind, reason: 'gitlink-missing' });
+ continue;
+ }
+ if (isGitRefsDir(abs)) {
+ // O123: the refs/ directory itself — only a type swap counts; its
+ // mtime moves on every branch create/delete.
+ if (b && a && b.type === a.type) continue;
+ if (b && a) {
+ changes.push({
+ path: display,
+ kind: 'modified',
+ ...(a.type === 'symlink' ? { reason: 'symlink' as const } : {}),
+ });
+ continue;
+ }
+ } else if (isGitHeadOrLooseRefPath(abs)) {
+ const c = gitRefChange(display, abs, b, a);
+ if (c) changes.push(c);
+ continue;
+ }
+ // After the UNAVAILABLE guard, b/a are PathMeta | undefined.
+ if (!bHas && aHas && a) {
+ if (isGitConfigPath(abs)) {
+ const c = gitConfigChange(display, 'added', undefined, a);
+ if (c) changes.push(c);
+ continue;
+ }
+ changes.push({ path: display, kind: 'added' });
+ continue;
+ }
+ if (bHas && !aHas && b) {
+ changes.push({ path: display, kind: 'deleted' });
+ continue;
+ }
+ if (bHas && aHas && b && a) {
+ if (b.hash !== a.hash || b.size !== a.size || b.type !== a.type) {
+ if (isGitConfigPath(abs)) {
+ const c = gitConfigChange(display, 'modified', b, a);
+ if (c) changes.push(c);
+ continue;
+ }
+ changes.push({ path: display, kind: 'modified' });
+ }
+ }
+ }
+ return changes;
+}
+
+const KIND_ZH: Record = {
+ added: '新增',
+ modified: '修改',
+ deleted: '删除',
+};
+const KIND_EN: Record = {
+ added: 'added',
+ modified: 'modified',
+ deleted: 'deleted',
+};
+
+function inspectHints(changes: readonly ProtectedPathChange[]): string[] {
+ const hints: string[] = [];
+ const joined = changes.map((c) => c.path).join(' ');
+ if (/\.ssh/.test(joined)) hints.push('ls -la ~/.ssh');
+ if (/\.git\/hooks/.test(joined)) hints.push('ls -la .git/hooks');
+ if (/\.git\/(config|HEAD|refs)/.test(joined)) hints.push('git status && ls -la .git');
+ if (/\.env/.test(joined)) hints.push('ls -la .env*');
+ if (hints.length === 0) hints.push('ls -la');
+ return [...new Set(hints)];
+}
+
+/**
+ * Human + machine notice for tool results / stderr.
+ * Uses「已被修改」not「已拦截」. No file contents, no full hashes.
+ */
+
+/** Git porcelain that legitimately updates HEAD / refs (not config/hooks). */
+const GIT_REF_UPDATING =
+ /(?:^|[\s;&|])git\s+(?:commit|checkout|switch|branch|merge|rebase|pull|fetch|reset|tag|stash|cherry-pick|revert|am|bisect)\b/i;
+
+function isGitHeadsOrRefsPath(p: string): boolean {
+ const n = p.replace(/\\/g, '/');
+ return (
+ /(^|\/)\.git\/HEAD$/i.test(n) ||
+ /(^|\/)\.git\/worktrees\/[^/]+\/HEAD$/i.test(n) ||
+ /(^|\/)\.git\/refs\//i.test(n) ||
+ n === 'HEAD' ||
+ n.startsWith('refs/')
+ );
+}
+
+/**
+ * Drop HEAD/refs changes when the command is ordinary git porcelain, so
+ * `git commit` / `git checkout -b` are not false positives. config / hooks /
+ * .ssh / .env changes are never filtered.
+ */
+export function filterExpectedGitMetadataChanges(
+ command: string,
+ changes: readonly ProtectedPathChange[]
+): ProtectedPathChange[] {
+ if (!GIT_REF_UPDATING.test(command)) return [...changes];
+ // O123: never drop an anomalous HEAD/ref change (`ref: ../x`, symlink, …).
+ return changes.filter((c) => c.reason !== undefined || !isGitHeadsOrRefsPath(c.path));
+}
+
+export function formatProtectedPathModifiedNotice(
+ changes: readonly ProtectedPathChange[],
+ zh: boolean,
+ command?: string
+): string {
+ if (changes.length === 0) return '';
+ const cmd = (command ?? '').replace(/\s+/g, ' ').trim().slice(0, 80);
+ const list = changes
+ .map((c) => {
+ const detail = describeGitConfigChange(c, zh);
+ if (detail) return detail;
+ return `${c.path} (${zh ? KIND_ZH[c.kind] : KIND_EN[c.kind]})`;
+ })
+ .join(zh ? ';' : '; ');
+ const hints = inspectHints(changes);
+ const markerPaths = changes.map((c) => c.path).join(',');
+ const detailLines = changes
+ .filter((c) => describeGitConfigChange(c, true))
+ .map(
+ (c) =>
+ `${PROTECTED_PATH_DETAIL_MARKER} ${[
+ c.path,
+ c.kind,
+ c.reason ?? '',
+ (c.keys ?? []).join(','),
+ ]
+ .map((x) => x.replace(/[\t\n]/g, ' '))
+ .join('\t')}`
+ );
+ const lines = zh
+ ? [
+ `${PROTECTED_PATH_MOD_MARKER} paths=${markerPaths}`,
+ ...detailLines,
+ `[moss] 受保护路径已被修改(命令已执行,Moss 没有拦截也没有自动还原):${list}`,
+ cmd ? `命令:${cmd}` : '',
+ `请自行检查。可复制:`,
+ ...hints.map((h) => ` ${h}`),
+ ]
+ : [
+ `${PROTECTED_PATH_MOD_MARKER} paths=${markerPaths}`,
+ ...detailLines,
+ `[moss] Protected paths were modified (command already ran; Moss did not intercept or restore): ${list}`,
+ cmd ? `command: ${cmd}` : '',
+ `Inspect yourself. Copyable:`,
+ ...hints.map((h) => ` ${h}`),
+ ];
+ return lines.filter(Boolean).join('\n');
+}
+
+/** Parse marker line from a tool result (for run-completion tracker). */
+export function parseProtectedPathModMarker(text: string): ProtectedPathChange[] | undefined {
+ if (!text.includes(PROTECTED_PATH_MOD_MARKER)) return undefined;
+ const m = text.match(/\[moss-o101-protected-path-modified\]\s+paths=([^\n]+)/);
+ if (!m) return [{ path: '(unknown)', kind: 'modified' }];
+ const details = new Map();
+ for (const line of text.split('\n')) {
+ if (!line.startsWith(PROTECTED_PATH_DETAIL_MARKER)) continue;
+ const [p, kind, reason, keys] = line.slice(PROTECTED_PATH_DETAIL_MARKER.length + 1).split('\t');
+ if (!p) continue;
+ const k = (['added', 'modified', 'deleted'] as const).find((x) => x === kind) ?? 'modified';
+ const r = ALL_REASONS.find((x) => x === reason);
+ const ks = (keys ?? '').split(',').filter(Boolean);
+ details.set(p, {
+ path: p,
+ kind: k,
+ ...(r ? { reason: r } : {}),
+ ...(ks.length ? { keys: ks } : {}),
+ });
+ }
+ return m[1]!
+ .split(',')
+ .map((p) => p.trim())
+ .filter(Boolean)
+ .map((p) => details.get(p) ?? { path: p, kind: 'modified' as const });
+}
+
+const KEYS_SHOWN = 3;
+
+/**
+ * O117 (UX): a git config file change in one phrase, e.g.
+ * 「.git/config 改了 `core.hooksPath`(不在安全清单内)」. Undefined for other
+ * paths / plain changes.
+ */
+export function describeGitConfigChange(c: ProtectedPathChange, zh: boolean): string | undefined {
+ if (c.kind === 'deleted' && isGitConfigPath(c.path)) {
+ return zh ? `${c.path} 被删除` : `${c.path} was deleted`;
+ }
+ if (c.reason === 'ref-outside') {
+ return zh
+ ? `${c.path} 指向仓库外路径(不在 refs/ 下)`
+ : `${c.path} points outside the repository (not under refs/)`;
+ }
+ if (c.reason === 'ref-invalid') {
+ return zh
+ ? `${c.path} 内容不是提交号或 refs/ 引用`
+ : `${c.path} no longer holds a commit id or refs/ reference`;
+ }
+ if (c.reason === 'gitlink-rewritten') {
+ return zh
+ ? `${c.path} 被改写(worktree 的 gitdir 指向变了)`
+ : `${c.path} was rewritten (worktree gitdir pointer changed)`;
+ }
+ if (c.reason === 'gitlink-missing') {
+ return zh
+ ? `${c.path} 指向的 gitdir 不存在或读不了`
+ : `${c.path} points to a gitdir that is missing or unreadable`;
+ }
+ if (c.reason === 'symlink') {
+ return zh ? `${c.path} 被换成软链` : `${c.path} was replaced by a symlink`;
+ }
+ if (c.reason === 'dir') {
+ return zh ? `${c.path} 被换成目录` : `${c.path} was replaced by a directory`;
+ }
+ if (c.reason === 'unparsable') {
+ return zh ? `${c.path} 无法解析` : `${c.path} could not be parsed`;
+ }
+ if (/(^|\/)\.git\/hooks\/./.test(c.path.replace(/\\/g, '/'))) {
+ const verb = zh
+ ? { added: '被新增', modified: '被改', deleted: '被删除' }[c.kind]
+ : { added: 'was added', modified: 'was changed', deleted: 'was deleted' }[c.kind];
+ return zh
+ ? `${c.path} ${verb}(git 钩子,会执行命令)`
+ : `${c.path} ${verb} (git hook, runs commands)`;
+ }
+ if (!c.keys?.length) return undefined;
+ const shown = c.keys.slice(0, KEYS_SHOWN).map((k) => `\`${k}\``);
+ const extra = c.keys.length > KEYS_SHOWN;
+ if (zh) {
+ const list = shown.join('、') + (extra ? ` 等 ${c.keys.length} 个` : '');
+ return `${c.path} 改了 ${list}(不在安全清单内)`;
+ }
+ const list = shown.join(', ') + (extra ? ` and ${c.keys.length - KEYS_SHOWN} more` : '');
+ return `${c.path} changed ${list} (not on the safe list)`;
+}
+
+/** Outcome reason strings (O98). */
+export function protectedPathModReason(
+ changes: readonly ProtectedPathChange[],
+ command?: string
+): { en: string; zh: string; stuckEn: string; stuckZh: string } {
+ const plain = changes.filter((c) => !describeGitConfigChange(c, true));
+ const detailZh = changes.map((c) => describeGitConfigChange(c, true)).filter(Boolean);
+ const detailEn = changes.map((c) => describeGitConfigChange(c, false)).filter(Boolean);
+ const paths = plain
+ .map((c) => c.path)
+ .slice(0, 5)
+ .join(', ');
+ const more = plain.length > 5 ? ` (+${plain.length - 5})` : '';
+ const cmd = (command ?? 'shell').replace(/\s+/g, ' ').trim().slice(0, 60);
+ const stuckEn = [paths ? `protected path modified: ${paths}${more}` : '', ...detailEn];
+ const stuckZh = [paths ? `受保护路径已被修改:${paths}${more}` : '', ...detailZh];
+ const en = [paths ? `${cmd} modified protected path(s) ${paths}${more}` : cmd, ...detailEn];
+ const zh = [paths ? `${cmd} 修改了受保护路径 ${paths}${more}` : cmd, ...detailZh];
+ return {
+ en: paths ? en.join('; ') : `${cmd}: protected path modified, ${detailEn.join('; ')}`,
+ zh: paths ? zh.join(';') : `${cmd}:受保护路径已被修改,${detailZh.join(';')}`,
+ stuckEn: stuckEn.filter(Boolean).join('; '),
+ stuckZh: stuckZh.filter(Boolean).join(';'),
+ };
+}
diff --git a/src/safety/protected-paths.ts b/src/safety/protected-paths.ts
new file mode 100644
index 00000000..1d623905
--- /dev/null
+++ b/src/safety/protected-paths.ts
@@ -0,0 +1,830 @@
+/**
+ * O94: protected paths. Writing, deleting or moving one of these always needs
+ * one explicit confirmation, even in `full` mode (interactive), and is denied
+ * outright in `-p` / non-interactive runs. Reads are never gated here, and
+ * git's own writes to `.git/` (`git commit`, `git checkout`, …) are not gated:
+ * only direct edits of files inside `.git/` are.
+ *
+ * Protected: any `.git` directory, any `.ssh` directory, shell rc/profile
+ * files, `.env` / `.env.*` / `.envrc`, and Moss's own config + trust files
+ * (user config dir, `~/.moss`, `workspace-trust.json`, project
+ * `.moss/config*.json`, `.moss/settings*.json`, `.moss/*trust*`).
+ *
+ * Matching is case-insensitive on every platform (stricter than needed on a
+ * case-sensitive FS; required on macOS/Windows) and checks both the lexical
+ * path (after `~`, `$HOME`, `../` normalisation) and the realpath of the
+ * nearest existing ancestor (symlink defense).
+ *
+ * Shell commands: this module is POLICY only. Write targets come from
+ * O97's shared `detectShellWriteTargets` (./shell-write-detect.ts) plus O101
+ * layers 1–2 (`indirect-write-detect.ts`: inline `-c`/`-e` and workspace
+ * script scan). Rule: prompt when a known target is protected, or when the
+ * detector is uncertain AND a protected path is mentioned, or when a scanned
+ * script/inline block would write a protected path. Best effort, not a sandbox.
+ * Layer 3 (post-run snapshot) lives in protected-path-snapshot.ts.
+ */
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+import { detectShellWriteTargets, type ShellWriteDetection } from './shell-write-detect.js';
+import { findIndirectProtectedWrites } from './indirect-write-detect.js';
+import { findPwshWriteTargets } from './pwsh-write-targets.js';
+
+export type ProtectedPathCategory =
+ | 'git'
+ | 'ssh'
+ | 'shell-rc'
+ | 'env'
+ | 'moss-config'
+ /** O103: write target built at runtime; cannot tell whether it is protected. */
+ | 'unresolved';
+
+export interface ProtectedPathHit {
+ raw: string;
+ resolved: string;
+ category: ProtectedPathCategory;
+ operation: 'write' | 'delete' | 'move' | 'shell';
+ /** O103: `core.hooksPath` redirection (copy says "would redirect git hooks"). */
+ kind?: 'hooks-path' | 'unresolved-var';
+ /**
+ * O101: when the write comes from inline interpreter code or a workspace
+ * script (layers 1–2). Used by the confirm prompt to show script + line.
+ */
+ source?: {
+ kind: 'inline' | 'script';
+ script: string;
+ line?: number;
+ detail?: string;
+ /** O129: script realpath outside the workspace; prompt says so. */
+ outsideWorkspace?: boolean;
+ };
+}
+
+export interface ProtectedPathContext {
+ cwd: string;
+ home?: string;
+ /** Moss user config dirs (resolveConfigDir + ~/.moss). */
+ configDirs?: readonly string[];
+ /** Workspace root (deleting it would delete its `.git`). */
+ workspaceRoot?: string;
+ env?: NodeJS.ProcessEnv;
+ /**
+ * Injected shell detector (tests). Defaults to detectShellWriteTargets.
+ * `mentions` optional for older stubs; when absent, uncertain hits fall back
+ * to commandMentionsProtectedPath.
+ */
+ shellWriteTargets?: (
+ command: string,
+ cwd: string
+ ) => Pick &
+ Partial>;
+}
+
+const SHELL_RC_BASENAMES = new Set([
+ '.bashrc',
+ '.bash_profile',
+ '.bash_login',
+ '.bash_logout',
+ '.bash_aliases',
+ '.profile',
+ '.zshrc',
+ '.zshenv',
+ '.zprofile',
+ '.zlogin',
+ '.zlogout',
+ '.cshrc',
+ '.tcshrc',
+ '.kshrc',
+ '.mkshrc',
+ '.login',
+ '.logout',
+ 'config.fish',
+]);
+
+export const PROTECTED_CATEGORY_LABEL: Record = {
+ git: { en: 'git repository internals', zh: 'git 仓库内部文件' },
+ ssh: { en: 'SSH keys and config', zh: 'SSH 密钥和配置' },
+ 'shell-rc': { en: 'shell startup file', zh: 'shell 配置文件' },
+ env: { en: 'environment / secrets file', zh: '环境变量和密钥文件' },
+ 'moss-config': { en: "Moss's own config or trust file", zh: 'Moss 自己的配置或信任文件' },
+ unresolved: {
+ en: 'download target built from a variable or command substitution; cannot tell if it is protected',
+ zh: '下载目标由变量/命令替换拼出,无法确定是否受保护',
+ },
+};
+
+function norm(p: string): string {
+ return path.resolve(p).replace(/\\/g, '/').toLowerCase();
+}
+
+function isUnder(child: string, parent: string): boolean {
+ return child === parent || child.startsWith(parent.endsWith('/') ? parent : `${parent}/`);
+}
+
+function classifyNormalised(
+ abs: string,
+ configDirs: readonly string[]
+): ProtectedPathCategory | undefined {
+ const parts = abs.split('/').filter(Boolean);
+ const base = parts[parts.length - 1] ?? '';
+ if (parts.includes('.git')) return 'git';
+ if (parts.includes('.ssh')) return 'ssh';
+ if (SHELL_RC_BASENAMES.has(base) || /_profile\.ps1$/.test(base) || base === 'profile.ps1')
+ return 'shell-rc';
+ if (base === '.env' || base.startsWith('.env.') || base === '.envrc') return 'env';
+ for (const dir of configDirs) if (isUnder(abs, dir)) return 'moss-config';
+ if (base === 'workspace-trust.json' || base === 'trusted-folders.json') return 'moss-config';
+ const mossIdx = parts.lastIndexOf('.moss');
+ if (mossIdx !== -1 && mossIdx === parts.length - 2) {
+ if (/^(config|settings|mcp|hooks)[\w.-]*\.json$/.test(base) || base.includes('trust')) {
+ return 'moss-config';
+ }
+ }
+ return undefined;
+}
+
+function realpathOfNearestAncestor(abs: string): string | undefined {
+ let current = abs;
+ const rest: string[] = [];
+ for (let i = 0; i < 64; i += 1) {
+ try {
+ const real = fs.realpathSync.native(current);
+ return rest.length ? path.join(real, ...[...rest].reverse()) : real;
+ } catch {
+ const parent = path.dirname(current);
+ if (parent === current) return undefined;
+ rest.push(path.basename(current));
+ current = parent;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * O103: follow a symlink chain on the path itself (lstat + readlink, relative
+ * targets resolved against the link's dir) even when the final target does not
+ * exist yet, so a dangling `link -> ~/.ssh/new_key` classifies as protected.
+ * Returns every hop (excluding `abs`); stops after 40 hops or on a loop.
+ */
+function symlinkChainTargets(abs: string): string[] {
+ const out: string[] = [];
+ const seen = new Set([abs]);
+ let current = abs;
+ for (let i = 0; i < 40; i += 1) {
+ let next: string;
+ try {
+ if (!fs.lstatSync(current).isSymbolicLink()) break;
+ next = path.resolve(path.dirname(current), fs.readlinkSync(current));
+ } catch {
+ break;
+ }
+ if (seen.has(next)) break;
+ seen.add(next);
+ out.push(next);
+ const viaAncestor = realpathOfNearestAncestor(next);
+ if (viaAncestor && viaAncestor !== next) out.push(viaAncestor);
+ current = next;
+ }
+ return out;
+}
+
+function homeOf(ctx: ProtectedPathContext): string {
+ const env = ctx.env ?? process.env;
+ return ctx.home ?? env.HOME ?? env.USERPROFILE ?? os.homedir();
+}
+
+function expandHome(raw: string, ctx: ProtectedPathContext): string {
+ const env = ctx.env ?? process.env;
+ const home = homeOf(ctx);
+ let out = raw.trim();
+ if (
+ (out.startsWith('"') && out.endsWith('"') && out.length >= 2) ||
+ (out.startsWith("'") && out.endsWith("'") && out.length >= 2)
+ ) {
+ out = out.slice(1, -1);
+ }
+ if (out === '~' || out.startsWith('~/') || out.startsWith('~\\')) out = home + out.slice(1);
+ return out
+ .replace(/\$\{HOME\}|\$HOME\b/g, home)
+ .replace(/\$\{(\w+)\}/g, (_, key: string) => env[key] ?? '')
+ .replace(/\$(\w+)/g, (_, key: string) => env[key] ?? '');
+}
+
+function normalisedConfigDirs(ctx: ProtectedPathContext): string[] {
+ const dirs = new Set();
+ for (const dir of ctx.configDirs ?? []) {
+ if (!dir) continue;
+ dirs.add(norm(dir));
+ const real = realpathOfNearestAncestor(path.resolve(dir));
+ if (real) dirs.add(norm(real));
+ }
+ return [...dirs];
+}
+
+/**
+ * Classify one path as named by a tool or command: the lexical form first,
+ * then the realpath of the nearest existing ancestor.
+ */
+export function classifyProtectedPath(
+ raw: string,
+ ctx: ProtectedPathContext
+): { category: ProtectedPathCategory; resolved: string } | undefined {
+ if (typeof raw !== 'string' || !raw.trim()) return undefined;
+ const expanded = expandHome(raw, ctx);
+ if (!expanded) return undefined;
+ const abs = path.resolve(ctx.cwd, expanded);
+ const configDirs = normalisedConfigDirs(ctx);
+ const lexical = classifyNormalised(norm(abs), configDirs);
+ if (lexical) return { category: lexical, resolved: abs };
+ const real = realpathOfNearestAncestor(abs);
+ if (real) {
+ const hit = classifyNormalised(norm(real), configDirs);
+ if (hit) return { category: hit, resolved: real };
+ }
+ for (const target of symlinkChainTargets(abs)) {
+ const hit = classifyNormalised(norm(target), configDirs);
+ if (hit) return { category: hit, resolved: target };
+ }
+ return undefined;
+}
+
+/** Deleting/moving an ancestor of home / workspace / config dir takes protected paths with it. */
+function deletesProtectedAncestor(
+ raw: string,
+ ctx: ProtectedPathContext
+): { category: ProtectedPathCategory; resolved: string } | undefined {
+ const expanded = expandHome(raw, ctx);
+ if (!expanded) return undefined;
+ const abs = path.resolve(ctx.cwd, expanded);
+ const candidates = [norm(abs)];
+ const real = realpathOfNearestAncestor(abs);
+ if (real) candidates.push(norm(real));
+ const anchors: Array<[string, ProtectedPathCategory]> = [];
+ // O129: anchor both the lexical and the realpath form (macOS /var → /private/var).
+ const addAnchor = (dir: string, category: ProtectedPathCategory) => {
+ anchors.push([norm(dir), category]);
+ const realDir = realpathOfNearestAncestor(path.resolve(dir));
+ if (realDir && norm(realDir) !== norm(dir)) anchors.push([norm(realDir), category]);
+ };
+ addAnchor(homeOf(ctx), 'ssh');
+ if (ctx.workspaceRoot) addAnchor(ctx.workspaceRoot, 'git');
+ for (const dir of normalisedConfigDirs(ctx)) anchors.push([dir, 'moss-config']);
+ for (const candidate of candidates) {
+ for (const [anchor, category] of anchors) {
+ if (isUnder(anchor, candidate)) return { category, resolved: candidate };
+ }
+ }
+ return undefined;
+}
+
+// ── shell (policy only; detection lives behind detectShellWriteTargets) ─
+
+/** Protected names as they may appear in a command we could not fully parse. */
+const PROTECTED_TEXT =
+ /(?:^|[\s'"`=/\\:(,[{])(\.git(?![\w.-])|\.ssh(?![\w-])|\.env(?![\w-])|\.env\.[\w.-]+|\.envrc(?![\w.-])|\.bashrc|\.bash_profile|\.bash_login|\.bash_aliases|\.profile(?![\w-])|\.zshrc|\.zshenv|\.zprofile|\.zlogin|\.cshrc|\.tcshrc|\.kshrc|config\.fish|\w*_profile\.ps1|workspace-trust\.json|\.moss[\\/](?:config|settings|mcp|hooks|[\w.-]*trust)[\w.-]*)/i;
+
+/** Show a detector's absolute target the way a user would write it. */
+function displayPath(abs: string, ctx: ProtectedPathContext): string {
+ const rel = path.relative(ctx.cwd, abs);
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) return rel;
+ const home = homeOf(ctx);
+ const fromHome = path.relative(home, abs);
+ if (!fromHome.startsWith('..') && !path.isAbsolute(fromHome))
+ return fromHome ? `~/${fromHome}` : '~';
+ return abs;
+}
+
+/** Does the command text itself name a protected path (used only when the detector is uncertain)? */
+export function commandMentionsProtectedPath(
+ command: string,
+ ctx: ProtectedPathContext
+): string | undefined {
+ const m = command.match(PROTECTED_TEXT);
+ if (m) return m[1];
+ // Absolute mentions of the Moss config dir (e.g. ~/.config/moss/x.json).
+ for (const token of command.split(/[\s'"`=;|&()<>]+/)) {
+ if (!token || !/[/\\~$]/.test(token)) continue;
+ if (classifyProtectedPath(token, ctx)) return token;
+ }
+ return undefined;
+}
+
+/**
+ * Shell policy: prompt when a known target is protected, OR the detector is
+ * uncertain AND the command text references a protected path. Uncertain but
+ * no protected reference (e.g. `python script.py`) → no prompt.
+ * `git …` writing its own `.git/` is the detector's business: it must not
+ * report git's internal writes as targets.
+ */
+
+/** Paths whose mode/owner a chmod/chown/chgrp would change (O94-only supplement). */
+function permissionChangeTargets(command: string, cwd: string): string[] {
+ const out: string[] = [];
+ for (const segment of command.split(/(?:&&|\|\||[;|\n])/g)) {
+ const tokens: string[] = [];
+ const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
+ let m: RegExpExecArray | null;
+ while ((m = pattern.exec(segment))) tokens.push(m[1] ?? m[2] ?? m[3] ?? '');
+ let i = 0;
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]!)) i += 1;
+ const head =
+ (tokens[i] ?? '')
+ .split(/[\\/]/)
+ .pop()
+ ?.toLowerCase()
+ .replace(/\.exe$/, '') ?? '';
+ if (head !== 'chmod' && head !== 'chown' && head !== 'chgrp') continue;
+ for (const t of tokens.slice(i + 1)) {
+ if (!t || t.startsWith('-')) continue;
+ // skip mode / owner specs: 755, u+w, user:group
+ if (/^[0-7]{3,4}$/.test(t) || /^[ugoa]*[+\-=]/.test(t) || /^[^/\\~.]+:[^/\\]*$/.test(t))
+ continue;
+ out.push(path.resolve(cwd, t));
+ }
+ }
+ return out;
+}
+
+/** O97's detector expands `~` via os.homedir(); honour ctx.env.HOME when set. */
+function withDetectorHome(ctx: ProtectedPathContext, fn: () => T): T {
+ const want = ctx.env?.HOME ?? ctx.home;
+ if (!want || want === process.env.HOME) return fn();
+ const prev = process.env.HOME;
+ process.env.HOME = want;
+ try {
+ return fn();
+ } finally {
+ if (prev === undefined) delete process.env.HOME;
+ else process.env.HOME = prev;
+ }
+}
+
+/** Map a shell command to the O94 prompt verb (rm → delete, mv → move). */
+function shellHitOperation(command: string): ProtectedPathHit['operation'] {
+ let sawDelete = false;
+ let sawMove = false;
+ for (const segment of command.split(/(?:&&|\|\||[;\n])/g)) {
+ const tokens: string[] = [];
+ const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
+ let m: RegExpExecArray | null;
+ while ((m = pattern.exec(segment))) tokens.push(m[1] ?? m[2] ?? m[3] ?? '');
+ let i = 0;
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]!)) i += 1;
+ // skip sudo/doas wrappers
+ while (i < tokens.length && /^(sudo|doas)$/i.test(tokens[i]!)) {
+ i += 1;
+ while (i < tokens.length && tokens[i]!.startsWith('-')) {
+ // sudo -u user → skip flag value when attached form not used
+ const flag = tokens[i]!;
+ i += 1;
+ if (/^-[^-]*[uCg]$/.test(flag) || flag === '--user' || flag === '--group') i += 1;
+ }
+ }
+ const head =
+ (tokens[i] ?? '')
+ .split(/[\\/]/)
+ .pop()
+ ?.toLowerCase()
+ .replace(/\.exe$/, '') ?? '';
+ if (head === 'rm' || head === 'rmdir' || head === 'unlink' || head === 'shred')
+ sawDelete = true;
+ else if (head === 'mv') sawMove = true;
+ }
+ if (sawDelete) return 'delete';
+ if (sawMove) return 'move';
+ return 'write';
+}
+
+/** Split a command into simple-command token lists (quotes stripped). */
+function simpleCommands(command: string): string[][] {
+ const out: string[][] = [];
+ for (const segment of command.split(/(?:&&|\|\||[;|\n()])/g)) {
+ const tokens: string[] = [];
+ const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
+ let m: RegExpExecArray | null;
+ while ((m = pattern.exec(segment))) tokens.push(m[1] ?? m[2] ?? m[3] ?? '');
+ let i = 0;
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]!)) i += 1;
+ while (i < tokens.length && /^(sudo|doas|env|command|nohup|exec|time)$/i.test(tokens[i]!)) {
+ i += 1;
+ while (i < tokens.length && (tokens[i]!.startsWith('-') || /^[A-Za-z_]\w*=/.test(tokens[i]!)))
+ i += 1;
+ }
+ if (i < tokens.length) out.push(tokens.slice(i));
+ }
+ return out;
+}
+
+const GIT_GLOBAL_OPTS_WITH_VALUE = new Set([
+ '-C',
+ '-c',
+ '--git-dir',
+ '--work-tree',
+ '--namespace',
+ '--exec-path',
+ '--config-env',
+]);
+const GIT_CONFIG_OPTS_WITH_VALUE = new Set([
+ '-f',
+ '--file',
+ '--blob',
+ '--type',
+ '--default',
+ '--comment',
+ '--value',
+]);
+const GIT_CONFIG_READ_OR_REMOVE =
+ /^(?:--get|--get-all|--get-regexp|--get-urlmatch|--get-color|--get-colorbool|-l|--list|--unset|--unset-all|--remove-section|--rename-section|-e|--edit|--show-origin|--show-scope|--name-only|-z|--null)$/;
+
+/**
+ * O103: `git config core.hooksPath ` (any scope) and
+ * `git -c core.hooksPath= …` swap the directory git runs hooks from,
+ * which bypasses `.git/hooks` protection without touching it. Treated as a
+ * protected `.git` write. Reads (`--get`, no value), `--unset` (restores the
+ * default) and every other config key are not gated.
+ */
+function gitHooksPathHits(command: string, ctx: ProtectedPathContext): ProtectedPathHit[] {
+ const hits: ProtectedPathHit[] = [];
+ for (const tokens of simpleCommands(command)) {
+ const head =
+ tokens[0]
+ ?.split(/[\\/]/)
+ .pop()
+ ?.toLowerCase()
+ .replace(/\.exe$/, '') ?? '';
+ if (head !== 'git') continue;
+ let i = 1;
+ let viaDashC: string | undefined;
+ for (; i < tokens.length; i += 1) {
+ const t = tokens[i]!;
+ if (t === '-c' || t === '--config-env') {
+ const kv = tokens[i + 1] ?? '';
+ if (/^core\.hookspath(?:=|$)/i.test(kv)) viaDashC = t === '-c' ? kv : `--config-env ${kv}`;
+ i += 1;
+ continue;
+ }
+ if (/^--config-env=core\.hookspath=/i.test(t)) {
+ viaDashC = t;
+ continue;
+ }
+ if (/^-c./.test(t) && /^-ccore\.hookspath(?:=|$)/i.test(t)) {
+ viaDashC = t.slice(2);
+ continue;
+ }
+ if (GIT_GLOBAL_OPTS_WITH_VALUE.has(t)) {
+ i += 1;
+ continue;
+ }
+ if (t.startsWith('-')) continue;
+ break;
+ }
+ const gitDirHint = path.join(ctx.workspaceRoot ?? ctx.cwd, '.git', 'config');
+ if (viaDashC) {
+ hits.push({
+ raw: viaDashC.startsWith('--config-env') ? `git ${viaDashC}` : `git -c ${viaDashC}`,
+ resolved: gitDirHint,
+ category: 'git',
+ operation: 'shell',
+ kind: 'hooks-path',
+ });
+ continue;
+ }
+ if ((tokens[i] ?? '').toLowerCase() !== 'config') continue;
+ const rest = tokens.slice(i + 1);
+ let global = false;
+ let readOrRemove = false;
+ const positional: string[] = [];
+ for (let k = 0; k < rest.length; k += 1) {
+ const t = rest[k]!;
+ if (t === '--') {
+ positional.push(...rest.slice(k + 1));
+ break;
+ }
+ if (t.startsWith('-') && t.length > 1) {
+ if (t === '--global' || t === '--system') global = true;
+ if (GIT_CONFIG_READ_OR_REMOVE.test(t)) readOrRemove = true;
+ if (GIT_CONFIG_OPTS_WITH_VALUE.has(t)) k += 1;
+ continue;
+ }
+ positional.push(t);
+ }
+ if (readOrRemove) continue;
+ // `git config set ` (git ≥ 2.46 subcommand form).
+ const sub = positional[0]?.toLowerCase();
+ if (sub === 'get' || sub === 'list' || sub === 'unset' || sub === 'remove-section') continue;
+ const args = sub === 'set' ? positional.slice(1) : positional;
+ if ((args[0] ?? '').toLowerCase() !== 'core.hookspath' || args.length < 2) continue;
+ hits.push({
+ raw: 'core.hooksPath',
+ resolved: global ? path.join(homeOf(ctx), '.gitconfig') : gitDirHint,
+ category: 'git',
+ operation: 'write',
+ kind: 'hooks-path',
+ });
+ }
+ return hits;
+}
+
+/**
+ * O127: absolute paths that appear in the command ONLY as the operand of a
+ * plain `cd ` / `pushd ` (no `$` / backtick). If the same path is
+ * also named anywhere else in the command it is not returned.
+ */
+function realOrResolved(p: string): string {
+ try {
+ return fs.realpathSync(p);
+ } catch {
+ return path.resolve(p);
+ }
+}
+
+function isWorkspaceRootPath(p: string, ctx: ProtectedPathContext): boolean {
+ const root = ctx.workspaceRoot;
+ if (!root) return false;
+ return realOrResolved(path.resolve(ctx.cwd, p)) === realOrResolved(root);
+}
+
+/**
+ * O127 QA B1: count cd/pushd/popd/chdir builtins (with or without an operand,
+ * whether or not the operand is resolvable). A second directory change cancels
+ * the workspace-root cd exemption — otherwise `cd && cd && node x` (bare
+ * cd → $HOME) and `cd && cd $HOME && node x` silently skipped the
+ * ancestor check.
+ */
+function countDirectoryChanges(command: string): number {
+ const re =
+ /(?:^|&&|\|\||;|\n|\()\s*(?:builtin\s+|command\s+)?(?:cd|pushd|popd|chdir)(?=\s|$|;|&|\||<|>|\)|\n)/gi;
+ let n = 0;
+ for (const _ of command.matchAll(re)) n += 1;
+ return n;
+}
+
+function cdOnlyOperands(command: string, cwd: string): Set {
+ const cd = new Set();
+ const re =
+ /(?:^|&&|\|\||;|\n|\()\s*(?:cd|pushd)\s+(?:--\s+)?(['"]?)([^\s'"`$;&|()<>]+)\1(?=\s*(?:$|&&|\|\||;|\n|\)))/g;
+ let rest = command;
+ for (const m of command.matchAll(re)) {
+ const raw = m[2]!;
+ if (raw.startsWith('~') || raw === '-') continue;
+ cd.add(path.resolve(cwd, raw));
+ rest = rest.replace(m[0], ' ');
+ }
+ if (cd.size === 0) return cd;
+ for (const word of rest.split(/[\s'"`;&|()<>=]+/)) {
+ if (!word) continue;
+ const abs = path.resolve(cwd, word);
+ cd.delete(abs);
+ cd.delete(abs.replace(/[\\/]+$/, ''));
+ }
+ return cd;
+}
+
+export function findShellProtectedWrites(
+ command: string,
+ ctx: ProtectedPathContext
+): ProtectedPathHit[] {
+ const detect = ctx.shellWriteTargets ?? detectShellWriteTargets;
+ const detection = withDetectorHome(ctx, () => detect(command, ctx.cwd));
+ const { uncertain, mentions = [], unresolved = [] } = detection;
+ // O97 does not flag chmod/chown/chgrp as writes; O94 still gates permission
+ // changes on protected paths.
+ const targets = [...detection.targets, ...permissionChangeTargets(command, ctx.cwd)];
+ const hits: ProtectedPathHit[] = [];
+ const seen = new Set();
+ for (const target of targets) {
+ const hit = classifyProtectedPath(target, ctx) ?? deletesProtectedAncestor(target, ctx);
+ if (!hit || seen.has(hit.resolved)) continue;
+ seen.add(hit.resolved);
+ hits.push({
+ raw: displayPath(target, ctx),
+ resolved: hit.resolved,
+ category: hit.category,
+ operation: shellHitOperation(command),
+ });
+ }
+ if (hits.length === 0 && uncertain) {
+ // O127 (S2): `cd && node x.mjs` is uncertain (a script
+ // could write anything), and the `cd` operand — the workspace root — was
+ // then judged as "deleting an ancestor of .git" and blocked. A `cd`
+ // operand is never written; it still gets the normal protected-path
+ // classification; only the ancestor-delete rule is skipped, and only when
+ // the path is named nowhere else in the command.
+ const cdOnly = cdOnlyOperands(command, ctx.cwd);
+ // QA B1 / PM 10-12: a second cd/pushd/popd/chdir (any operand) cancels the
+ // exemption — the first cd-to-root must also be the last directory change.
+ const allowCdExempt = countDirectoryChanges(command) <= 1;
+ // Prefer absolute mentions from the shared detector; fall back to text regex.
+ for (const m of mentions) {
+ // O103: a word still holding `$VAR` / `$(…)` is not a path we resolved;
+ // expanding it to '' used to misreport the workspace root as git internals.
+ if (/\$|`/.test(m)) continue;
+ // PM 10-12: exempt only when the cd target IS the workspace/worktree root
+ // (realpath); `cd /`, `cd ~`, `cd ..` still hit the ancestor rule.
+ const isCd =
+ allowCdExempt && cdOnly.has(path.resolve(ctx.cwd, m)) && isWorkspaceRootPath(m, ctx);
+ const hit =
+ classifyProtectedPath(m, ctx) ?? (isCd ? undefined : deletesProtectedAncestor(m, ctx));
+ if (!hit || seen.has(hit.resolved)) continue;
+ seen.add(hit.resolved);
+ hits.push({
+ raw: displayPath(m, ctx),
+ resolved: hit.resolved,
+ category: hit.category,
+ operation: 'shell',
+ });
+ }
+ if (hits.length === 0 && unresolved.length > 0) {
+ // O103: download target we cannot resolve → one honest confirmation line.
+ const raw = unresolved[0]!;
+ hits.push({ raw, resolved: raw, category: 'unresolved', operation: 'write' });
+ }
+ if (hits.length === 0) {
+ const name = commandMentionsProtectedPath(command, ctx);
+ if (name) {
+ const cls = classifyProtectedPath(name, ctx);
+ hits.push({
+ raw: name,
+ resolved: cls?.resolved ?? name,
+ category:
+ cls?.category ?? classifyNormalised(`/x/${name.toLowerCase()}/f`, []) ?? 'moss-config',
+ operation: 'shell',
+ });
+ }
+ }
+ }
+
+ // O103: hooksPath redirection.
+ for (const hit of gitHooksPathHits(command, ctx)) {
+ const key = `hookspath:${hit.raw}:${hit.resolved}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ hits.push(hit);
+ }
+
+ // O101 layers 1–2: inline interpreter code + workspace script scan.
+ // Merge with existing hits; attach source metadata when the same path already hit.
+ for (const finding of findIndirectProtectedWrites(
+ command,
+ ctx.cwd,
+ ctx.workspaceRoot ?? ctx.cwd
+ )) {
+ const hit =
+ classifyProtectedPath(finding.raw, ctx) ?? deletesProtectedAncestor(finding.raw, ctx);
+ if (!hit) continue;
+ const key = hit.resolved;
+ if (seen.has(key)) {
+ const existing = hits.find((h) => h.resolved === key);
+ if (existing && !existing.source) existing.source = finding.source;
+ continue;
+ }
+ seen.add(key);
+ hits.push({
+ raw: finding.raw,
+ resolved: hit.resolved,
+ category: hit.category,
+ operation: 'shell',
+ source: finding.source,
+ });
+ }
+
+ // O130 (a): write targets of PowerShell cmdlets / aliases, [IO.File]::Write*,
+ // every redirection form, with `$env:X` / `${env:X}` / `%X%` / `$X` expanded
+ // from the current env. Only write targets are examined (a `$` in an argument
+ // or a read never prompts). A target that cannot be resolved (unknown or
+ // same-line-assigned variable, command substitution, relative after an
+ // unresolvable `cd`) is gated as possibly protected. Stricter only.
+ const pw = findPwshWriteTargets(command, {
+ cwd: ctx.cwd,
+ home: homeOf(ctx),
+ env: ctx.env ?? process.env,
+ });
+ for (const target of pw.resolved) {
+ const hit = classifyProtectedPath(target, ctx) ?? deletesProtectedAncestor(target, ctx);
+ if (!hit || seen.has(hit.resolved)) continue;
+ seen.add(hit.resolved);
+ hits.push({
+ raw: displayPath(hit.resolved, ctx),
+ resolved: hit.resolved,
+ category: hit.category,
+ operation: 'shell',
+ });
+ }
+ if (hits.length === 0 && pw.unresolved.length > 0) {
+ const raw = pw.unresolved[0]!;
+ hits.push({
+ raw,
+ resolved: raw,
+ category: 'unresolved',
+ operation: 'write',
+ kind: 'unresolved-var',
+ });
+ }
+
+ // O130 (b): inline interpreter code (`node -e`, `python -c` …) naming a Moss
+ // config file through process.env.X / os.environ[...] (+ string concat).
+ if (!hits.some((h) => h.category === 'moss-config') && INLINE_INTERP.test(command)) {
+ for (const token of configDirTokens(command, ctx)) {
+ const hit = classifyProtectedPath(token, ctx);
+ if (!hit || hit.category !== 'moss-config' || seen.has(hit.resolved)) continue;
+ seen.add(hit.resolved);
+ hits.push({
+ raw: displayPath(hit.resolved, ctx),
+ resolved: hit.resolved,
+ category: hit.category,
+ operation: 'shell',
+ });
+ }
+ }
+ return hits;
+}
+
+const INLINE_INTERP =
+ /(?:^|[\s;|&(])(?:node|nodejs|bun|deno|python[23]?(?:\.\d+)?|py|ruby|perl|php)(?:\.exe)?\s+(?:[^;|&]*\s)?(?:-e|-c|-p|-r|-E|--eval|--print|eval)(?=\s)/i;
+
+/** O130: tokens of the command with Windows / interpreter env references expanded. */
+function configDirTokens(command: string, ctx: ProtectedPathContext): string[] {
+ const env = ctx.env ?? process.env;
+ const lookup = (name: string): string => {
+ const direct = env[name];
+ if (direct !== undefined) return direct;
+ const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase());
+ return key ? (env[key] ?? '') : '';
+ };
+ const expanded = command
+ .replace(/\$\{env:(\w+)\}|\$env:(\w+)/gi, (_, a: string, b: string) => lookup(a ?? b))
+ .replace(/%(\w+)%/g, (_, k: string) => lookup(k))
+ .replace(
+ /process\.env\.(\w+)|process\.env\[\s*['"](\w+)['"]\s*\]|os\.environ\[\s*['"](\w+)['"]\s*\]|os\.getenv\(\s*['"](\w+)['"]\s*\)|os\.environ\.get\(\s*['"](\w+)['"]\s*\)/g,
+ (_m, a?: string, b?: string, c?: string, d?: string, e?: string) =>
+ lookup((a ?? b ?? c ?? d ?? e)!)
+ )
+ // join string concatenation: X+'/moss/config.json' / X + "\\moss" / path.join(X,'moss')
+ .replace(/['"]?\s*\+\s*['"]/g, '')
+ .replace(/(path\.join|os\.path\.join|Join-Path)\s*\(?/gi, ' ');
+ const out: string[] = [];
+ for (const token of expanded.split(/[\s'"`=;|&()<>,]+/)) {
+ if (token && /[/\\]/.test(token)) out.push(token);
+ }
+ return out;
+}
+
+/** Paths a file-mutation tool call would touch. */
+export function fileToolProtectedTargets(
+ toolName: string,
+ input: Record
+): Array<{ raw: string; operation: ProtectedPathHit['operation'] }> {
+ if (toolName === 'move_file') {
+ return [input.source, input.destination]
+ .filter((v): v is string => typeof v === 'string')
+ .map((raw) => ({ raw, operation: 'move' as const }));
+ }
+ if (toolName === 'apply_patch' && typeof input.patch === 'string') {
+ const out: Array<{ raw: string; operation: ProtectedPathHit['operation'] }> = [];
+ for (const m of input.patch.matchAll(/^\*\*\* (Update|Add|Delete) File: (.+)$/gm)) {
+ out.push({ raw: m[2]!.trim(), operation: m[1] === 'Delete' ? 'delete' : 'write' });
+ }
+ for (const m of input.patch.matchAll(/^\*\*\* Move to: (.+)$/gm)) {
+ out.push({ raw: m[1]!.trim(), operation: 'move' });
+ }
+ for (const m of input.patch.matchAll(/^(?:---|\+\+\+) (?:[ab]\/)?(\S+)/gm)) {
+ if (m[1] !== '/dev/null') out.push({ raw: m[1]!, operation: 'write' });
+ }
+ return out;
+ }
+ const p = input.path ?? input.file_path;
+ return typeof p === 'string' ? [{ raw: p, operation: 'write' }] : [];
+}
+
+export const PROTECTED_FILE_TOOLS: ReadonlySet = new Set([
+ 'write_file',
+ 'edit_file',
+ 'multi_edit',
+ 'apply_patch',
+ 'move_file',
+]);
+export const PROTECTED_SHELL_TOOLS: ReadonlySet = new Set(['exec', 'exec_background']);
+
+/** All protected-path hits for one tool call (file tools + shell). */
+export function findProtectedPathHits(
+ toolName: string,
+ input: Record