@nanhara/hara 0.121.1 → 0.122.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +40 -6
  3. package/dist/agent/loop.js +136 -21
  4. package/dist/agent/reminders.js +22 -7
  5. package/dist/agent/repeat-guard.js +26 -7
  6. package/dist/agent/structured.js +231 -0
  7. package/dist/agent/touched.js +20 -6
  8. package/dist/config.js +33 -23
  9. package/dist/cron/deliver.js +37 -3
  10. package/dist/feedback.js +3 -2
  11. package/dist/fs-read.js +106 -12
  12. package/dist/fs-write.js +242 -16
  13. package/dist/gateway/dingtalk.js +4 -1
  14. package/dist/gateway/discord.js +53 -18
  15. package/dist/gateway/feishu.js +158 -57
  16. package/dist/gateway/flows-pending.js +720 -0
  17. package/dist/gateway/flows.js +391 -16
  18. package/dist/gateway/matrix.js +80 -15
  19. package/dist/gateway/mattermost.js +44 -32
  20. package/dist/gateway/media.js +659 -0
  21. package/dist/gateway/serve.js +657 -162
  22. package/dist/gateway/sessions.js +475 -78
  23. package/dist/gateway/signal.js +27 -22
  24. package/dist/gateway/slack.js +26 -17
  25. package/dist/gateway/telegram.js +32 -18
  26. package/dist/gateway/wecom.js +32 -24
  27. package/dist/gateway/weixin.js +127 -49
  28. package/dist/hooks.js +32 -20
  29. package/dist/index.js +631 -222
  30. package/dist/org/projects.js +347 -0
  31. package/dist/org/roles.js +38 -11
  32. package/dist/security/secrets.js +84 -9
  33. package/dist/serve/server.js +772 -317
  34. package/dist/serve/sessions.js +113 -33
  35. package/dist/session/store.js +298 -47
  36. package/dist/tools/all.js +1 -0
  37. package/dist/tools/builtin.js +30 -28
  38. package/dist/tools/codebase.js +3 -1
  39. package/dist/tools/computer.js +98 -92
  40. package/dist/tools/edit.js +11 -8
  41. package/dist/tools/patch.js +230 -31
  42. package/dist/tools/search.js +482 -72
  43. package/dist/tools/task.js +453 -0
  44. package/dist/tools/todo.js +67 -16
  45. package/dist/tools/web.js +168 -54
  46. package/dist/undo.js +83 -7
  47. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,72 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.122.0 — 2026-07-13 — structured runs, durable work, and a fail-closed gateway
9
+
10
+ - **Machine-safe headless runs.** `hara -p … --schema <json|file>` installs a run-scoped
11
+ `structured_output` contract, validates the value against JSON Schema, and writes exactly one JSON value to
12
+ stdout. Prose, auth notices, retries, and provider/schema failures cannot masquerade as machine output:
13
+ diagnostics go to stderr and failures exit non-zero. `hara -p … --role <id>` now applies the role's persona,
14
+ model, and allow/deny tool policy instead of changing the prompt alone.
15
+ - **Agents have stable homes.** `hara projects add/list/remove` maintains a private, atomic registry of project
16
+ homes; `hara agents` builds one address book from global and registered-project roles.
17
+ `hara org --role project:agent …` resolves unambiguously and executes at that home with its project context/config. Concurrent
18
+ CLIs safely share the registry, and dead lock owners can be reclaimed without stealing a live lock.
19
+ - **Two levels of work state.** `todo_write` is now isolated per interactive/headless/sub-agent/serve session
20
+ and persists with the session, so concurrent agents cannot overwrite each other's checklist. The new `task`
21
+ tool is a durable cross-session project pool with owners, statuses, `blockedBy` dependencies, cycle checks,
22
+ and private atomic cross-process persistence. `hara serve` exposes the pool through `tasks.list`.
23
+ - **10-platform gateway, with project-agent roaming.** Telegram, WeChat, Discord, Feishu/Lark, Slack,
24
+ Mattermost, Matrix, DingTalk, WeCom, and Signal adapters now classify direct vs multi-party chats
25
+ conservatively. The full coding agent accepts only verified private messages; unknown channel shapes fail
26
+ closed. `/agent <name|project:name>` pins a thread to an indexed agent and its home, while `/agent main`
27
+ restores the prior project thread. Chat/session preferences survive project round-trips and group-member
28
+ state is isolated.
29
+ - **Untrusted group flows cannot reach coding tools.** Opt-in `~/.hara/flows.json` rules are hot-reloaded and
30
+ evaluated through a bounded, stateless provider call with `tools: []` — no shell, files, MCP, session, or
31
+ project context. Rules support trigger filters, JSON Schema, disposition-based notification/auto-reply,
32
+ redacted rotating logs, and rate/concurrency caps. Proposed sends and agent dispatches are parked unless an
33
+ explicitly configured safe `replyOn` disposition applies.
34
+ - **Single-owner, idempotent approvals.** Consequential flow actions require a unique allowlisted owner and a
35
+ verified private channel. Deterministic `/approve <id>`, `/edit <id> <content>`, and `/reject <id>` commands
36
+ avoid “latest draft” ambiguity; blank edits fail closed. Pending actions use private atomic storage and a
37
+ compare-and-set execution claim, shared by chat and the new serve `approvals.list/resolve` inbox, so two
38
+ approval surfaces cannot double-send. Deferred actions are parked only for one-shot delivery targets
39
+ (Telegram, Feishu/Lark, or WeChat); other adapters fail closed instead of presenting an unusable approval.
40
+ - **Bounded gateway execution and media.** Turns are FIFO per session (depth 8), with four active children,
41
+ bounded global/session-key backlogs, a 15-minute default/30-minute hard timeout, and TERM-to-KILL shutdown;
42
+ non-zero or signalled children fail visibly. Inbound attachments are authorized before download, limited to
43
+ four files and 20 MiB each, streamed into private random paths with time/concurrency/retention quotas, and
44
+ expired after 24 hours. Progress markers are recalled in `finally`, gateway chat/session files are `0600`
45
+ with lock-before-load persistence, and ambiguous/stale locks are never reclaimed merely by age.
46
+ - **Layered flow admission.** Group classifiers are capped globally (20/minute, 120/hour), per rule/platform/chat
47
+ (10/minute, 60/hour), per sender (5/minute), and at four active runs. Rate maps and key sizes are bounded and
48
+ saturation fails closed, so rotating identities cannot grow memory or bypass the host-wide ceiling.
49
+ - **Live config for persistent clients.** `hara serve` rebuilds cwd-specific provider routes, credentials, and
50
+ guardian settings on sessions/turns; `initialize`, `models.list`, and new sessions reflect current defaults.
51
+ Resumed sessions retain their explicit model pin while using the live provider route, so credential rotation
52
+ and project config edits no longer require a server restart.
53
+ - **Coding-path robustness.** File reads/search/edit/patch now reject FIFOs/devices and symlink races, cap reads,
54
+ use bounded subprocess search with a linear glob matcher, preserve exact file modes, and roll back multi-file
55
+ patches without overwriting concurrent replacements; writes move-claim the expected inode and commit with
56
+ create-if-absent semantics, while `/undo` applies the same identity checks. Blank
57
+ project/env routing values no longer mask valid global config; precedence is environment > project > overlay
58
+ > global. Web fetch/search blocks IPv4-mapped and full link-local IPv6 SSRF forms and tries regional providers
59
+ sequentially instead of broadcasting a successful query. Package installs stay attached with bounded
60
+ long-operation timeouts, tunnel commands preflight their binaries, TUI resize repaints reliably, persisted/
61
+ public text redacts secrets, and session writes are private, atomic, and cross-process safe.
62
+ - **Interrupts and non-interactive outcomes are honest.** Cancelling a parallel tool round records matching
63
+ interrupted results while preserving completed side effects; serve approvals settle immediately on interrupt.
64
+ The stream-stall watchdog is a hard Promise boundary even when a provider ignores its abort signal.
65
+ Headless and org runs now exit non-zero on provider errors, empty/halted turns, schema failures, and rejected
66
+ reviews; plan atoms stop on the same outcomes instead of being verified as done, and failed implementers are
67
+ never committed. Read-only roles are resolved before startup and launch neither hooks nor configured/plugin
68
+ MCP subprocesses.
69
+ - **Private serve lifecycle.** `~/.hara`/`serve.json` are tightened to `0700`/`0600` and discovery is fsynced,
70
+ atomically replaced without following symlinks, and removed only by its owning instance. Discovery failures
71
+ close the listening socket. Serve-side compaction is mutually exclusive with turns/config changes, carries an
72
+ abort signal, releases locks on interrupt/shutdown, and has a 60-second hard deadline.
73
+
8
74
  ## 0.121.1 — field-feedback reliability & credential safety
9
75
 
10
76
  - **Terminal resize no longer erases the composer.** Hara clears stale Ink output only before a real
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  **Highlights**
14
14
  - **An org, not just an agent** — `hara org "<task>"` routes work to the role that *owns* it; `hara plan "<task>"` decomposes a task into a verified DAG of atoms (frame → atomize → sequence → execute → **verify gate**), and `hara plan --parallel` runs independent atoms concurrently.
15
- - **Drive it from chat** — `hara gateway` runs your local hara from **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk · WeCom · Signal** (10 platforms), with **two-way images** (send a photo it sees it; ask for a file → it sends one), per-chat resumable sessions, and project roaming. Connects out — no public webhook. See **[docs/gateway.md](docs/gateway.md)**.
15
+ - **Drive it from chat** — `hara gateway` runs your local hara from **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk · WeCom · Signal** (10 platforms), with **two-way images**, resumable per-chat sessions, project/agent roaming, bounded per-thread queues, and approval-gated group automations. Connects out — no public webhook. See **[docs/gateway.md](docs/gateway.md)**.
16
16
  - **Real terminal UX** — an **ink TUI**: bottom-pinned input box, **plan mode** (read-only investigation → the model submits its plan via `exit_plan` → approve → execute), selectable approvals with "don't ask again", windowed reasoning, **paste images** (Ctrl+V) for vision models, light/dark theme.
17
17
  - **Persistent memory + self-evolution** — `memory_*` tools over global/project `MEMORY.md`; the agent recalls before acting, **proactively saves** durable facts, and grows its own playbooks (a lexical guard screens what it writes). Inspect/consolidate it with **`hara memory show`** and **`hara memory distill`** (promote recent daily logs → durable memory). Lexical-first by design — semantic search is opt-in, never required.
18
18
  - **Multi-provider, all streamed** — Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) with live Markdown + visible reasoning.
@@ -135,8 +135,11 @@ hara expresses it the way each endpoint wants (OpenAI `reasoning_effort`, Anthro
135
135
  DashScope `enable_thinking`, **DeepSeek** V4 `thinking` + `reasoning_effort` where `max` genuinely raises the
136
136
  effort). In the TUI, bare `/model` opens a picker — ↑↓ pick a model, **←→ set the thinking level**.
137
137
 
138
- Config lives in `~/.hara/config.json`. Env vars override it: `HARA_PROVIDER`, `HARA_MODEL`,
139
- `HARA_BASE_URL`, `HARA_API_KEY`, or the provider key (`ANTHROPIC_API_KEY` / `DASHSCOPE_API_KEY`).
138
+ Config lives in `~/.hara/config.json`; the nearest project `.hara/config.json` can specialize it. Effective
139
+ precedence is **environment > project > selected overlay > global**. Empty routing values are ignored, so an
140
+ empty project/env value cannot hide a working global credential or endpoint. Env overrides include
141
+ `HARA_PROVIDER`, `HARA_MODEL`, `HARA_BASE_URL`, `HARA_API_KEY`, and the provider key
142
+ (`ANTHROPIC_API_KEY` / `DASHSCOPE_API_KEY`).
140
143
 
141
144
  ## Use
142
145
 
@@ -146,12 +149,17 @@ hara init # analyze the project & (re)generate AGENTS.md
146
149
  hara doctor # check your setup (auth / model / node / assets / roles)
147
150
  hara roles init # scaffold role-agents (implementer / reviewer / docs)
148
151
  hara org "review src/ for bugs" # dispatch a task to the role that owns it (or --role <id>)
152
+ hara projects add shop /absolute/path/to/shop # register an agent home
153
+ hara agents # list global + registered project agents
154
+ hara org --role shop:reviewer "audit auth" # run that agent at its own home
149
155
  hara plan "add a /health endpoint with a test" # decompose → sequence (DAG) → run each step + verify
150
156
  hara plan --parallel "..." # run independent atoms concurrently · hara plan resume # continue a stopped plan
151
157
  hara review # review uncommitted changes for bugs/security/missing tests (--staged · --base main)
152
158
  hara commit # AI commit message from staged changes, then commit (-a to stage all · -y to skip confirm)
153
159
  hara index # build the semantic search index (after: hara config set embedProvider ollama|qwen)
154
160
  hara -p "summarize @README.md and fix the lint errors in src/" # one-shot; @path attaches a file
161
+ hara -p "extract package metadata" --schema ./schema.json # stdout is exactly schema-valid JSON
162
+ hara -p "review the current diff" --role reviewer # persona + model + tool policy from the role
155
163
  hara --approval auto-edit # suggest (default) | auto-edit | full-auto (-y = full-auto)
156
164
  hara --sandbox workspace-write # confine shell writes to the project (macOS Seatbelt)
157
165
  hara -c # resume the most recent session in this directory
@@ -159,6 +167,12 @@ hara --profile work # use a named profile from ~/.hara/config.json
159
167
  hara -m glm-5 # pick a model
160
168
  ```
161
169
 
170
+ For automation, `--schema` accepts inline JSON Schema or a schema file. The model must return through the
171
+ validated `structured_output` tool; on success stdout contains only the JSON value, while diagnostics go to
172
+ stderr and missing/invalid output exits non-zero. `--role reviewer` resolves locally, `--role global:reviewer`
173
+ uses the portable global persona in the current project, and `--role shop:reviewer` runs at that registered
174
+ project home. Each form enforces the role's persona, model, `allowTools`/`denyTools`, and `readOnly` policy.
175
+
162
176
  Inside the REPL: `/help` `/init` `/tools` `/model` `/approval` `/org` `/plan` `/roles` `/usage` `/doctor` `/sessions` `/undo` `/compact` `/recall` `/reset` `/exit` (type `/`+Tab to complete). Type `@` + Tab to attach a file (fuzzy, walks subdirectories).
163
177
 
164
178
  The interactive REPL is an **ink TUI**: a bordered **input box pinned at the bottom** — session name in
@@ -216,9 +230,22 @@ vision model into **actionable** output — interactive elements + positions (pa
216
230
  **MCP**: add an `mcpServers` map to config (global or project `.hara/config.json`); their tools appear to the agent as `mcp__<server>__<tool>`. hara can also **be** an MCP server — `hara mcp` exposes its read/search tools (esp. **`codebase_search`**) over stdio so other clients (Claude Desktop, Cursor, another hara) can use them; read-only by default (`HARA_MCP_TOOLS` to override).
217
231
  **Vim mode**: `hara config set vimMode true` makes the prompt modal — Esc → normal, `i/a/A/I` insert, `h l 0 $ w b e` motions, `x D C dd cw p` edits. Off by default.
218
232
  **Scheduled tasks**: `hara cron add "0 9 * * 1-5" "<task>"` (or `"every 30m"`, `"in 2h"`) runs a task on a schedule — each run is a fresh hara session. `hara cron install` wires a per-minute tick into launchd/crontab (no daemon); `--org` routes through the role org. Manage with `hara cron list/run/enable/disable/remove/logs`.
233
+ **Work coordination**: `todo_write` is the agent's short, session-scoped checklist; it persists with that
234
+ session and is isolated between simultaneous sub-agents and serve sessions. `task` is the durable project pool
235
+ for work that outlives a conversation: add/update/list/remove items with `pending|in_progress|done`, an optional
236
+ owner, and `blockedBy` dependencies. The private, atomic store is shared by concurrent hara processes for the
237
+ same project and rejects missing/self/cyclic dependencies.
219
238
  **Notifications**: `hara config set notify bell` (terminal bell) or `notify system` (OS notification) pings you when a turn finishes — handy for long runs you've stepped away from. Gated on elapsed time so quick turns stay quiet; off by default.
220
239
  **Hooks**: run your own shell commands around tool calls via a `"hooks"` map in config. A **`PreToolUse`** hook can **veto** a call (non-zero exit blocks it; its output becomes the reason the model sees) — gate `bash`, forbid edits outside a path, require a clean tree. A **`PostToolUse`** hook observes (format/lint a file the agent just wrote, log, notify). Each has a `matcher` (regex/literal on the tool name, `*` = all) and gets `{tool, payload}` on stdin + `HARA_TOOL_NAME` in env. Plugins can contribute hooks too.
221
- **Profiles**: add a `profiles` map to `~/.hara/config.json` (`--profile <name>`), or drop a project-level `.hara/config.json` that overrides the global config.
240
+ Reviewer/read-only/plan runs and parallel read-only sub-agents suppress both hook phases: PreToolUse and
241
+ PostToolUse commands are arbitrary shell, so either could otherwise bypass their read-only contract indirectly.
242
+ They also skip configured and plugin-provided MCP server processes, so starting an external tool server cannot
243
+ bypass the same contract before the first model turn.
244
+ **Profiles and live config**: select an identity with `--profile <name>`; use `overlays` in
245
+ `~/.hara/config.json` for named config overlays, and a project `.hara/config.json` for project-specific routing.
246
+ Long-lived `hara serve` processes reload provider credentials/routes and guardian settings for the target cwd on
247
+ new sessions and turns. `models.list` and new sessions see current defaults; a resumed session keeps its explicit
248
+ model pin while using the live provider route. No server restart is required after a credential rotation.
222
249
 
223
250
  ### The org — what makes hara different
224
251
 
@@ -234,6 +261,13 @@ to a clean start tree; a review that doesn't pass leaves the work uncommitted).
234
261
  **`agent`** tool spawns **parallel read-only sub-agents** for fan-out — analyze / review / search
235
262
  several things at once (each can take a `role`), bounded to 8 concurrent (`HARA_MAX_CONCURRENCY`).
236
263
 
264
+ Register project homes with `hara projects add <name> <absolute-path>`, then `hara agents` becomes a global
265
+ address book across `~/.hara/roles` and each registered project's roles. A qualified address such as
266
+ `shop:reviewer` is unambiguous; both `hara org --role shop:reviewer "<task>"` and one-shot `hara -p "<task>"
267
+ --role shop:reviewer` execute at that agent's home, with its own `AGENTS.md`, live project config, role model,
268
+ and allow/deny/read-only tool policy. `global:<name>` is portable and runs in the current project. A bare name
269
+ uses the local role first and otherwise must resolve unambiguously.
270
+
237
271
  Beyond routing, **`hara plan "<task>"`** makes the org *plan*: it decomposes the task into atoms,
238
272
  sequences them as a DAG, and executes each step (optionally routed to a role) behind a per-step
239
273
  **verify gate** — frame → atomize → sequence → execute → verify. Each atom may carry a `check` shell
@@ -255,11 +289,11 @@ turn, or **`/undo`** to revert the last edit. In-session **`/diff`**, **`/review
255
289
  - **Project context**: auto-loads `AGENTS.md` (the cross-tool standard) walking up to the repo root; `hara init` writes one by analyzing the repo.
256
290
  - **`@file` mentions**: attach file contents to a message (`@path`); Tab-completes with a **fuzzy** matcher over the project (subdirs, git-tracked + untracked) — `@idx` → `src/index.ts`. `@<dir>` loads a directory listing, `@src/`+Tab drills into a folder, and mistyped tool/file paths get a "did you mean" suggestion.
257
291
  - **Multi-provider**: Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) — **all streamed live**.
258
- - **Chat gateway**: drive your local hara from a chat app — **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk**. The daemon connects out (no public webhook), with per-chat sessions, project roaming (`/cd`), and **two-way images** (send a photo → it sees it; ask for a file → it sends one). Setup per platform: **[docs/gateway.md](docs/gateway.md)**.
292
+ - **Chat gateway**: drive your local hara from **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk · WeCom · Signal**. The daemon connects out (no public webhook), with per-chat sessions, project roaming (`/cd`), agent switching (`/agent`), and **two-way images** (send a photo → it sees it; ask for a file → it sends one). Setup and the group-flow security model: **[docs/gateway.md](docs/gateway.md)**.
259
293
 
260
294
  ### Roadmap
261
295
 
262
- **Shipped:** ink TUI · plan mode · persistent memory + self-evolution · atomization planner · parallel plan atoms · **multi-role review chains** · parallel sub-agents · MCP client *and* server · **scheduled tasks (`hara cron`)** · **chat gateway (8 platforms, two-way images)** · **single-binary distribution** · **Docker image** · `/compact` context management.
296
+ **Shipped:** ink TUI · plan mode · persistent memory + self-evolution · atomization planner · parallel plan atoms · **multi-role review chains** · global project-agent index · durable project tasks · parallel sub-agents · MCP client *and* server · **scheduled tasks (`hara cron`)** · **chat gateway (10 platforms, two-way images)** · **single-binary distribution** · **Docker image** · `/compact` context management.
263
297
  **Next:** SSOT data authority · an enterprise control-plane (fleet + central token management).
264
298
 
265
299
  ## Security
@@ -96,8 +96,8 @@ site, build output), check they are newer than their sources (compare mtimes or
96
96
  the sources changed since the artifacts were built, run the project's documented build/render steps FIRST.
97
97
  When AGENTS.md / README / package.json document a command sequence (e.g. pull → render → build → preview),
98
98
  that ordering is authoritative — never skip the middle steps, or you serve stale output and the user sees
99
- two-day-old work. Package-manager installs auto-start as background jobs when you omit background/timeout;
100
- poll the returned job until it exits before depending on its packages. Before opening a public tunnel,
99
+ two-day-old work. Package-manager installs receive a longer attached timeout by default; use background jobs
100
+ only when explicitly appropriate, and poll a background job before depending on it. Before opening a public tunnel,
101
101
  verify that provider's authentication/config once; if it is missing, stop and ask instead of trying a chain
102
102
  of unrelated tunnel tools. After completing a task, give a one-line summary.`;
103
103
  /** When running inside `hara gateway`, tell the agent it's in a chat — so it delivers files via send_file
@@ -111,7 +111,9 @@ function gatewayNote() {
111
111
  `To send a file or image to them, call the \`send_file\` tool with an absolute path; that is the ONLY channel ` +
112
112
  `that reaches this chat. Do NOT use the \`computer\` tool, AppleScript, or any desktop/${plat}-client automation ` +
113
113
  `to deliver files — that drives a different window and silently fails to reach the user. Never tell the user a ` +
114
- `file was sent unless \`send_file\` returned success. Keep replies short and chat-friendly.`);
114
+ `file was sent unless \`send_file\` returned success. Keep replies short and chat-friendly. ` +
115
+ `PLAIN TEXT ONLY: chat bubbles render markdown literally — never use **bold**, # headers, backticks, tables, ` +
116
+ `or [text](url); write list items as "- " lines and links as bare URLs.`);
115
117
  }
116
118
  function composeSystem(cwd, projectContext, override, memory) {
117
119
  const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
@@ -129,6 +131,16 @@ export async function runAgent(history, opts) {
129
131
  let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
130
132
  let triedFallback = false;
131
133
  let emptyRetried = false; // one-shot: a genuinely empty model turn gets a single nudge before we give up
134
+ const interruptedOutcome = () => {
135
+ const msg = "(interrupted)";
136
+ if (!opts.quiet) {
137
+ if (ctx.ui)
138
+ ctx.ui.notice(msg);
139
+ else
140
+ out(c.dim(`\n${msg}\n`));
141
+ }
142
+ return { status: "error", error: msg };
143
+ };
132
144
  // Warn at the interaction boundary without echoing the value. Headless/gateway stdout is the response
133
145
  // transport, so keep the banner to interactive surfaces; persistence is still redacted everywhere.
134
146
  const latestUser = [...history].reverse().find((m) => m.role === "user");
@@ -166,7 +178,7 @@ export async function runAgent(history, opts) {
166
178
  // lands as ONE wrapped user message the UI never renders. Quiet runs don't drain — a parallel
167
179
  // sub-agent must not steal the main conversation's reminders.
168
180
  if (!opts.quiet) {
169
- const reminders = drainReminders();
181
+ const reminders = drainReminders(ctx.todoScope);
170
182
  if (reminders.length)
171
183
  history.push({ role: "user", content: wrapReminders(reminders) });
172
184
  }
@@ -212,7 +224,12 @@ export async function runAgent(history, opts) {
212
224
  const STALL_MS = stallMs();
213
225
  const attempt = new AbortController();
214
226
  const onUserAbort = () => attempt.abort();
215
- opts.signal?.addEventListener("abort", onUserAbort, { once: true });
227
+ // AbortSignal does not replay an already-fired event to a late listener. A serve shutdown can cancel
228
+ // while provider routing is still refreshing, so inherit that state synchronously before the call.
229
+ if (opts.signal?.aborted)
230
+ attempt.abort();
231
+ else
232
+ opts.signal?.addEventListener("abort", onUserAbort, { once: true });
216
233
  let lastEvent = Date.now();
217
234
  let stalled = false;
218
235
  const stallTimer = setInterval(() => {
@@ -229,17 +246,33 @@ export async function runAgent(history, opts) {
229
246
  if (!opts.quiet)
230
247
  setTurnPhase("waiting"); // request sent, nothing streamed yet — the status row shows it
231
248
  let r;
249
+ let removeAttemptStop = () => { };
232
250
  try {
233
- r = await activeProvider.turn({
251
+ // AbortSignal is advisory: a custom/provider SDK can ignore it and leave its Promise pending forever.
252
+ // Race the attempt itself so the watchdog and user cancellation remain hard boundaries. The abandoned
253
+ // Promise retains a rejection handler through Promise.race, and all late stream callbacks below are muted.
254
+ const attemptStopped = new Promise((resolveStopped) => {
255
+ const onAttemptStop = () => resolveStopped({ text: "", toolUses: [], stop: "error", errorMsg: "interrupted" });
256
+ removeAttemptStop = () => attempt.signal.removeEventListener("abort", onAttemptStop);
257
+ if (attempt.signal.aborted)
258
+ onAttemptStop();
259
+ else
260
+ attempt.signal.addEventListener("abort", onAttemptStop, { once: true });
261
+ });
262
+ const providerTurn = activeProvider.turn({
234
263
  system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
235
264
  history,
236
265
  tools: specs,
237
266
  // Any stream chunk keeps the connection considered alive — even suppressed reasoning_content, so a
238
267
  // reasoning model thinking for a long while before its first `content` token can't be false-timed-out.
239
268
  onActivity: () => {
269
+ if (attempt.signal.aborted)
270
+ return;
240
271
  lastEvent = Date.now();
241
272
  },
242
273
  onText: (d) => {
274
+ if (attempt.signal.aborted)
275
+ return;
243
276
  alive();
244
277
  if (opts.quiet)
245
278
  return;
@@ -256,6 +289,8 @@ export async function runAgent(history, opts) {
256
289
  },
257
290
  onReasoning: sink || tty
258
291
  ? (d) => {
292
+ if (attempt.signal.aborted)
293
+ return;
259
294
  alive();
260
295
  if (opts.quiet)
261
296
  return;
@@ -281,12 +316,23 @@ export async function runAgent(history, opts) {
281
316
  }
282
317
  }
283
318
  }
284
- : (d) => alive(), // quiet runs still feed the watchdog (reasoning-only stretches are progress)
319
+ : () => {
320
+ if (!attempt.signal.aborted)
321
+ alive();
322
+ }, // quiet runs still feed the watchdog (reasoning-only stretches are progress)
285
323
  signal: attempt.signal,
286
324
  });
325
+ opts.onProviderTurn?.(providerTurn);
326
+ r = await Promise.race([providerTurn, attemptStopped]);
327
+ }
328
+ catch (error) {
329
+ if (!opts.signal?.aborted)
330
+ throw error;
331
+ r = { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
287
332
  }
288
333
  finally {
289
334
  clearInterval(stallTimer);
335
+ removeAttemptStop();
290
336
  opts.signal?.removeEventListener("abort", onUserAbort);
291
337
  }
292
338
  // A watchdog abort surfaces from the provider as "interrupted" — rewrite it to a timeout-class
@@ -304,6 +350,10 @@ export async function runAgent(history, opts) {
304
350
  opts.stats.output += r.usage.output;
305
351
  opts.stats.lastInput = r.usage.input;
306
352
  }
353
+ // A provider may ignore AbortSignal and return a perfectly valid-looking tool_use after cancellation.
354
+ // The original run signal is authoritative: do not append/approve/execute any late response.
355
+ if (opts.signal?.aborted)
356
+ return interruptedOutcome();
307
357
  history.push({ role: "assistant", text: r.text, toolUses: r.toolUses });
308
358
  if (r.stop === "error") {
309
359
  const kind = classifyError(r.errorMsg ?? "");
@@ -321,13 +371,26 @@ export async function runAgent(history, opts) {
321
371
  continue; // retry once on the fallback model (guarded by triedFallback)
322
372
  }
323
373
  const msg = kind === "interrupted" ? "(interrupted)" : `[${activeProvider.id} error] ${r.errorMsg ?? "unknown"}${errorHint(kind)}`;
374
+ if (r.toolUses.length) {
375
+ // A provider can fail after partially assembling tool calls. The assistant turn is already persisted;
376
+ // close every call explicitly so the next request is valid, while never executing partial work.
377
+ history.push({
378
+ role: "tool",
379
+ results: r.toolUses.map((toolUse) => ({
380
+ id: toolUse.id,
381
+ name: toolUse.name,
382
+ content: `Error: provider failed before this tool call could be executed. ${r.errorMsg ?? "unknown provider error"}`,
383
+ isError: true,
384
+ })),
385
+ });
386
+ }
324
387
  if (!opts.quiet) {
325
388
  if (sink)
326
389
  sink.notice(msg);
327
390
  else
328
391
  out(kind === "interrupted" ? c.dim(`\n${msg}\n`) : c.red(`${msg}\n`));
329
392
  }
330
- return;
393
+ return { status: "error", error: msg };
331
394
  }
332
395
  // Empty-turn guard. The model returned nothing actionable — no text AND no tool calls (a blank
333
396
  // completion, or a "tool_use" stop with an empty tool list). Silently returning here leaves the
@@ -355,16 +418,37 @@ export async function runAgent(history, opts) {
355
418
  else
356
419
  out(c.dim(`${note}\n`));
357
420
  }
358
- return;
421
+ return { status: "empty" };
359
422
  }
360
423
  // A "tool_use" stop with text but no tools (rare) has nothing to execute — end after showing the text
361
424
  // rather than pushing an empty tool round and re-requesting in a loop.
362
425
  if (r.stop !== "tool_use" || r.toolUses.length === 0)
363
- return;
426
+ return { status: "completed" };
427
+ // Once an assistant tool_use turn enters history, every tool_use MUST receive a matching tool result.
428
+ // OpenAI/Anthropic both reject a later user turn after an unclosed tool round. Cancellation can happen
429
+ // while planning, approving, or executing, so finalize the round with real results for work that already
430
+ // completed and explicit interruption errors for everything else before persisting the session.
431
+ const results = new Array(r.toolUses.length);
432
+ const finalizeInterruptedToolRound = () => {
433
+ history.push({
434
+ role: "tool",
435
+ results: r.toolUses.map((tu, idx) => results[idx] ?? ({
436
+ id: tu.id,
437
+ name: tu.name,
438
+ content: "Error: interrupted before this tool call completed.",
439
+ isError: true,
440
+ })),
441
+ });
442
+ return interruptedOutcome();
443
+ };
444
+ if (opts.signal?.aborted)
445
+ return finalizeInterruptedToolRound();
364
446
  const plans = [];
365
447
  // Extra (per-run) tools win over the registry so a run-scoped tool can't be shadowed by a global one.
366
448
  const resolveTool = (name) => opts.extraTools?.find((t) => t.name === name) ?? getTool(name);
367
449
  for (const tu of r.toolUses) {
450
+ if (opts.signal?.aborted)
451
+ return finalizeInterruptedToolRound();
368
452
  if (breakerHalt) {
369
453
  // Circuit-breaker halted the run: refuse every remaining call in this round with a clear message
370
454
  // (no hang, no further tools) so the model + user get a definitive stop.
@@ -398,6 +482,8 @@ export async function runAgent(history, opts) {
398
482
  if (risk.level === "high") {
399
483
  const detail = String(input.command ?? input.path ?? "").replace(/\s+/g, " ").trim().slice(0, 400);
400
484
  const verdict = await guardianVeto(opts.guardian.provider, { tool: tu.name, detail, classifierReason: risk.reason }, history, { signal: opts.signal });
485
+ if (opts.signal?.aborted)
486
+ return finalizeInterruptedToolRound();
401
487
  if (verdict.decision === "block") {
402
488
  const tripped = recordBlock(breaker); // deterministic circuit-breaker: N blocks → hard stop
403
489
  plans.push({
@@ -421,6 +507,8 @@ export async function runAgent(history, opts) {
421
507
  const cont = interactive
422
508
  ? await opts.confirm(`${c.red("⛔ guardian circuit-breaker")} — ${breaker.blocks} high-risk actions blocked this turn. Continue anyway?`)
423
509
  : false;
510
+ if (opts.signal?.aborted)
511
+ return finalizeInterruptedToolRound();
424
512
  if (cont === false) {
425
513
  breakerHalt = true;
426
514
  }
@@ -435,6 +523,8 @@ export async function runAgent(history, opts) {
435
523
  }
436
524
  if (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && (alwaysGate || !opts.autoApprove?.has(tu.name))) {
437
525
  const reply = await opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`);
526
+ if (opts.signal?.aborted)
527
+ return finalizeInterruptedToolRound();
438
528
  if (reply === false) {
439
529
  plans.push({ tu, tool, denied: "User denied this action." });
440
530
  continue;
@@ -452,8 +542,11 @@ export async function runAgent(history, opts) {
452
542
  }
453
543
  }
454
544
  // Execute: read-only tools run concurrently; edit/exec run alone, in order.
455
- const results = new Array(plans.length);
545
+ if (opts.signal?.aborted)
546
+ return finalizeInterruptedToolRound();
456
547
  const runOne = async (idx, p) => {
548
+ if (opts.signal?.aborted)
549
+ return;
457
550
  if (p.denied !== undefined) {
458
551
  results[idx] = { id: p.tu.id, name: p.tu.name, content: p.denied, isError: true };
459
552
  return;
@@ -467,28 +560,42 @@ export async function runAgent(history, opts) {
467
560
  if (missing.length) {
468
561
  const msg = `Error: tool call NOT executed — missing required parameter${missing.length > 1 ? "s" : ""}: ` +
469
562
  `${missing.join(", ")}. Send the call again with ALL required parameters (${(p.tool.input_schema.required ?? []).join(", ")}) present and complete.`;
470
- results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true), isError: true };
563
+ results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true, ctx.todoScope), isError: true };
471
564
  return;
472
565
  }
473
- const pre = runHooks("PreToolUse", p.tu.name, p.tu.input, ctx.cwd); // a hook may veto the call
566
+ if (opts.signal?.aborted)
567
+ return;
568
+ const pre = opts.hooks === false
569
+ ? { block: false, message: "" }
570
+ : runHooks("PreToolUse", p.tu.name, p.tu.input, ctx.cwd); // a hook may veto the call
474
571
  if (pre.block) {
475
572
  results[idx] = { id: p.tu.id, name: p.tu.name, content: pre.message, isError: true };
476
573
  return;
477
574
  }
575
+ if (opts.signal?.aborted)
576
+ return;
478
577
  // Track the MAIN conversation's working files for post-compaction restore (quiet fan-out
479
578
  // sub-agents read broadly — their files aren't "what the user was working on").
480
579
  if (!opts.quiet && FILE_TOUCH_TOOLS.has(p.tu.name) && typeof p.tu.input?.path === "string") {
481
- recordTouch(resolvePath(ctx.cwd, String(p.tu.input.path)));
580
+ recordTouch(resolvePath(ctx.cwd, String(p.tu.input.path)), ctx.todoScope);
482
581
  }
483
582
  const res = await p.tool.run(p.tu.input, ctx);
484
583
  // append any not-yet-seen subdirectory AGENTS.md/CLAUDE.md this call touched (monorepo-local conventions)
485
584
  // + the repeat-guard's anti-spinning note when this exact call keeps failing (repeat-guard.ts)
486
- results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) + recordCall(p.tu.name, p.tu.input, res) };
487
- runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd); // observe-only
585
+ results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) + recordCall(p.tu.name, p.tu.input, res, false, ctx.todoScope) };
586
+ // The tool may have completed a side effect and then triggered/observed cancellation. Preserve its
587
+ // actual result in the closing tool round, but do not run any post hook or later tool afterward.
588
+ if (opts.signal?.aborted)
589
+ return;
590
+ if (opts.hooks !== false) {
591
+ runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd); // observe-only
592
+ }
488
593
  }
489
594
  catch (e) {
490
595
  const msg = `Error: ${e.message}`;
491
- results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true), isError: true };
596
+ results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true, ctx.todoScope), isError: true };
597
+ if (opts.signal?.aborted)
598
+ return;
492
599
  }
493
600
  finally {
494
601
  activity.dec();
@@ -503,16 +610,24 @@ export async function runAgent(history, opts) {
503
610
  await mapLimit(idx, maxParallel(), (i) => runOne(i, plans[i])); // bounded fan-out (e.g. 20 parallel agents → 8 at a time)
504
611
  };
505
612
  for (let i = 0; i < plans.length; i++) {
613
+ if (opts.signal?.aborted)
614
+ return finalizeInterruptedToolRound();
506
615
  const p = plans[i];
507
616
  if (p.denied === undefined && p.tool?.kind === "read") {
508
617
  batch.push(i); // safe → accumulate to run concurrently
509
618
  }
510
619
  else {
511
620
  await flush(); // flush pending reads before an edit/exec
621
+ if (opts.signal?.aborted)
622
+ return finalizeInterruptedToolRound();
512
623
  await runOne(i, p);
624
+ if (opts.signal?.aborted)
625
+ return finalizeInterruptedToolRound();
513
626
  }
514
627
  }
515
628
  await flush();
629
+ if (opts.signal?.aborted)
630
+ return finalizeInterruptedToolRound();
516
631
  history.push({ role: "tool", results });
517
632
  // Synthesis nudge (CC's KN5, hara-shaped): a round that fanned out to several parallel agents just
518
633
  // produced N independent reports — remind the model to merge/reconcile them before acting, instead
@@ -520,7 +635,7 @@ export async function runAgent(history, opts) {
520
635
  if (!opts.quiet) {
521
636
  const fanout = r.toolUses.filter((tu) => tu.name === "agent").length;
522
637
  if (fanout >= SYNTHESIS_MIN_AGENTS)
523
- pushReminder(synthesisReminder(fanout));
638
+ pushReminder(synthesisReminder(fanout), ctx.todoScope);
524
639
  }
525
640
  // Todo attention-refresh: a round that touched the checklist resets the clock; rounds that leave
526
641
  // unfinished items untouched accumulate, and at TODO_STALE_ROUNDS the model gets a system-reminder
@@ -529,10 +644,10 @@ export async function runAgent(history, opts) {
529
644
  if (r.toolUses.some((tu) => tu.name === "todo_write")) {
530
645
  todoIdleRounds = 0;
531
646
  }
532
- else if (currentTodos().some((t) => t.status !== "done")) {
647
+ else if (currentTodos(ctx.todoScope).some((t) => t.status !== "done")) {
533
648
  todoIdleRounds++;
534
649
  if (todoIdleRounds >= TODO_STALE_ROUNDS) {
535
- pushReminder(todoStaleReminder(renderTodos(currentTodos())));
650
+ pushReminder(todoStaleReminder(renderTodos(currentTodos(ctx.todoScope))), ctx.todoScope);
536
651
  todoIdleRounds = 0;
537
652
  }
538
653
  }
@@ -547,7 +662,7 @@ export async function runAgent(history, opts) {
547
662
  else
548
663
  out(c.red(`${note}\n`));
549
664
  }
550
- return;
665
+ return { status: "halted" };
551
666
  }
552
667
  if (guard && !nudged) {
553
668
  for (const p of plans)
@@ -6,19 +6,34 @@
6
6
  //
7
7
  // Claude Code's disclaimer is preserved: the model is told the context may be irrelevant, so an
8
8
  // injected nudge never derails an unrelated task.
9
- const queue = [];
9
+ const DEFAULT_SCOPE = "default";
10
+ const queues = new Map();
11
+ function scopeKey(scope) {
12
+ return scope?.trim() || DEFAULT_SCOPE;
13
+ }
10
14
  /** Queue a reminder for injection before the next model call (main loop only — quiet/sub-agent runs
11
15
  * neither push nor drain, so a parallel fan-out can't steal the main conversation's reminders). */
12
- export function pushReminder(text) {
16
+ export function pushReminder(text, scope) {
13
17
  const t = text.trim();
14
- if (t)
15
- queue.push(t);
18
+ if (!t)
19
+ return;
20
+ const key = scopeKey(scope);
21
+ const queue = queues.get(key) ?? [];
22
+ queue.push(t);
23
+ queues.set(key, queue);
16
24
  }
17
25
  /** Take everything queued (FIFO), clearing the queue. */
18
- export function drainReminders() {
19
- if (!queue.length)
26
+ export function drainReminders(scope) {
27
+ const key = scopeKey(scope);
28
+ const queue = queues.get(key);
29
+ if (!queue?.length)
20
30
  return [];
21
- return queue.splice(0, queue.length);
31
+ queues.delete(key);
32
+ return queue;
33
+ }
34
+ /** Drop an ephemeral session's pending reminders without exposing them to another run. */
35
+ export function disposeReminderScope(scope) {
36
+ queues.delete(scopeKey(scope));
22
37
  }
23
38
  /** Merge queued reminders into the single injected message. */
24
39
  export function wrapReminders(items) {
@@ -4,8 +4,16 @@
4
4
  // covers FAILED ones. Deterministic and session-scoped (module state, same pattern as net-reachability):
5
5
  // when an identical (tool, args) call fails twice in a row, the tool result gets an explicit "stop
6
6
  // repeating this" note the model can't miss. Successful repeats are NOT flagged — a re-read after an edit
7
- // or a re-run after a fix is legitimate, and a success resets the failure streak.
8
- const seen = new Map();
7
+ // or a re-run after a fix is legitimate, and a success resets the failure streak. Serve can run several
8
+ // sessions in one process, so streaks are keyed by the same run scope as todo/reminder state.
9
+ const DEFAULT_SCOPE = "default";
10
+ const seenByScope = new Map();
11
+ function scopedSeen(scope) {
12
+ const key = scope?.trim() || DEFAULT_SCOPE;
13
+ const seen = seenByScope.get(key) ?? new Map();
14
+ seenByScope.set(key, seen);
15
+ return seen;
16
+ }
9
17
  /** Identity of a call = tool name + exact JSON of its arguments (tool names contain no spaces,
10
18
  * so a space separator is unambiguous). */
11
19
  export function keyOf(name, input) {
@@ -24,12 +32,20 @@ export function looksFailed(content) {
24
32
  }
25
33
  /** Record a completed call; returns a warning to APPEND to the tool result when the same call has now
26
34
  * failed >=2x in a row (empty string otherwise). Pure aside from the session-scoped map. */
27
- export function recordCall(name, input, content, isError = false) {
35
+ export function recordCall(name, input, content, isError = false, scope) {
28
36
  const k = keyOf(name, input);
29
37
  const failed = isError || looksFailed(content);
38
+ const seen = scopedSeen(scope);
30
39
  const s = seen.get(k) ?? { fails: 0 };
31
- s.fails = failed ? s.fails + 1 : 0; // success resets the streak
32
- seen.set(k, s);
40
+ if (!failed) {
41
+ seen.delete(k); // successes have no useful state; don't leak every unique tool call in a long-lived server
42
+ return "";
43
+ }
44
+ s.fails++;
45
+ seen.delete(k);
46
+ seen.set(k, s); // refresh insertion order for the bounded per-scope LRU
47
+ if (seen.size > 500)
48
+ seen.delete(seen.keys().next().value);
33
49
  if (s.fails < 2)
34
50
  return "";
35
51
  return (`\n\n⟳ hara: this exact ${name} call has now FAILED ${s.fails}× with identical arguments — ` +
@@ -37,6 +53,9 @@ export function recordCall(name, input, content, isError = false) {
37
53
  `or step back and re-plan; if you're out of ideas, ask the user and say what you tried.`);
38
54
  }
39
55
  /** Clear the streaks — /reset (fresh start) and tests. */
40
- export function resetRepeatGuard() {
41
- seen.clear();
56
+ export function resetRepeatGuard(scope) {
57
+ if (scope)
58
+ seenByScope.delete(scope.trim() || DEFAULT_SCOPE);
59
+ else
60
+ seenByScope.clear();
42
61
  }