@nanhara/hara 0.121.0 → 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 (49) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +40 -6
  3. package/dist/agent/failover.js +1 -1
  4. package/dist/agent/loop.js +158 -21
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +20 -6
  9. package/dist/config.js +62 -26
  10. package/dist/cron/deliver.js +37 -3
  11. package/dist/feedback.js +5 -9
  12. package/dist/fs-read.js +106 -12
  13. package/dist/fs-write.js +242 -16
  14. package/dist/gateway/dingtalk.js +4 -1
  15. package/dist/gateway/discord.js +53 -18
  16. package/dist/gateway/feishu.js +158 -57
  17. package/dist/gateway/flows-pending.js +720 -0
  18. package/dist/gateway/flows.js +391 -16
  19. package/dist/gateway/matrix.js +80 -15
  20. package/dist/gateway/mattermost.js +44 -32
  21. package/dist/gateway/media.js +659 -0
  22. package/dist/gateway/serve.js +657 -162
  23. package/dist/gateway/sessions.js +475 -78
  24. package/dist/gateway/signal.js +27 -22
  25. package/dist/gateway/slack.js +26 -17
  26. package/dist/gateway/telegram.js +32 -18
  27. package/dist/gateway/wecom.js +32 -24
  28. package/dist/gateway/weixin.js +127 -49
  29. package/dist/hooks.js +32 -20
  30. package/dist/index.js +640 -219
  31. package/dist/org/projects.js +347 -0
  32. package/dist/org/roles.js +38 -11
  33. package/dist/security/secrets.js +150 -0
  34. package/dist/serve/server.js +772 -317
  35. package/dist/serve/sessions.js +112 -28
  36. package/dist/session/store.js +337 -44
  37. package/dist/tools/all.js +1 -0
  38. package/dist/tools/builtin.js +61 -23
  39. package/dist/tools/codebase.js +3 -1
  40. package/dist/tools/computer.js +98 -92
  41. package/dist/tools/edit.js +11 -8
  42. package/dist/tools/patch.js +230 -31
  43. package/dist/tools/search.js +482 -72
  44. package/dist/tools/task.js +453 -0
  45. package/dist/tools/todo.js +67 -16
  46. package/dist/tools/web.js +364 -64
  47. package/dist/tui/run.js +26 -23
  48. package/dist/undo.js +83 -7
  49. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,91 @@ 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
+
74
+ ## 0.121.1 — field-feedback reliability & credential safety
75
+
76
+ - **Terminal resize no longer erases the composer.** Hara clears stale Ink output only before a real
77
+ width change; height-only window drags keep the input box visible and width changes repaint immediately.
78
+ - **Credentials stay out of durable transcripts and generated code.** Session writes deeply redact likely
79
+ keys/tokens/passwords (including tool inputs/results); legacy content is redacted when loaded and migrated
80
+ on its next atomic, private save. Interactive pastes get a warning without echoing the value. The agent must use environment references,
81
+ never literal secrets in code/commands, and may only populate a real-secret `.env` when explicitly asked.
82
+ - **`web_search` has a verified mainland path.** A configured Tavily request races the keyless Bing China
83
+ HTML path; Baidu, Google, and DuckDuckGo remain concurrent secondary fallbacks. Provider timeouts are
84
+ isolated, and JavaScript-only `web_fetch` pages now explain that a browser/API/connector is required
85
+ instead of returning a misleading empty body.
86
+ - **Long setup commands fail usefully.** Package installs automatically become background jobs unless the
87
+ caller explicitly sets timeout/background, while ngrok tunnels preflight local authentication once and
88
+ stop with a focused fix instead of cycling through unrelated tunnel tools.
89
+ - **Persistent desktop sessions re-read configuration.** New/resumed `hara serve` sessions pick up rotated
90
+ `apiKey`/model/baseURL values without a restart; empty env/project values no longer mask valid global
91
+ config, and a 401 says the configured credential expired rather than asking users to paste it into chat.
92
+
8
93
  ## 0.121.0 — `hara desk` · crash-safe coding/files · bounded interactive I/O
9
94
 
10
95
  - **`hara desk` connects the CLI to the shared coordination desk.** Register an agent, post/list/
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
@@ -38,7 +38,7 @@ export function failoverAction(kind, s) {
38
38
  export function errorHint(kind) {
39
39
  switch (kind) {
40
40
  case "auth":
41
- return " — check your API key / auth (try `hara setup`)";
41
+ return " — the configured credential was rejected or expired; update ~/.hara/config.json, the active profile, or its environment variable, then retry. Do not paste the key into chat";
42
42
  case "rate_limit":
43
43
  return " — rate-limited; wait a moment, or set `fallbackModel` to auto-switch";
44
44
  case "overloaded":
@@ -16,6 +16,7 @@ import { drainReminders, wrapReminders, pushReminder, todoStaleReminder, TODO_ST
16
16
  import { setTurnPhase } from "./phase.js";
17
17
  import { recordTouch } from "./touched.js";
18
18
  import { resolve as resolvePath } from "node:path";
19
+ import { redactSensitiveText } from "../security/secrets.js";
19
20
  /** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
20
21
  const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
21
22
  /** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
@@ -65,7 +66,14 @@ re-reading a big file after every edit is the slowest habit an agent can have.
65
66
  When an attempt FAILS, never repeat it unchanged — read the error, form a hypothesis about the cause, and
66
67
  change something (arguments / approach / tool) before trying again. After two failed variants of the same
67
68
  approach, stop: re-plan from what you learned, or ask the user, stating concisely what you tried and what
68
- the errors said. Repeating a failed action hoping for a different result is how sessions die. For broad,
69
+ the errors said. Repeating a failed action hoping for a different result is how sessions die.
70
+ Never put a literal password, API key, token, App Secret, Authorization header, or other credential in a
71
+ source file or shell command. Reference an environment variable instead (for example process.env.API_KEY or
72
+ $API_KEY). Keep real values in the user's environment or an approved secret store; do not create/populate a
73
+ .env file with a real secret unless the user explicitly asks and it is excluded from version control. Never
74
+ echo credentials back. Session persistence redacts likely secrets as a last line of defense, but that does
75
+ not make embedding credentials acceptable.
76
+ For broad,
69
77
  open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
70
78
  independent questions (role "explore") — each returns conclusions, not dumps. Messages the user sends
71
79
  mid-task arrive marked as interjections — triage them (refine current / queue as todo / urgent-switch)
@@ -88,7 +96,10 @@ site, build output), check they are newer than their sources (compare mtimes or
88
96
  the sources changed since the artifacts were built, run the project's documented build/render steps FIRST.
89
97
  When AGENTS.md / README / package.json document a command sequence (e.g. pull → render → build → preview),
90
98
  that ordering is authoritative — never skip the middle steps, or you serve stale output and the user sees
91
- two-day-old work. After completing a task, give a one-line summary.`;
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
+ verify that provider's authentication/config once; if it is missing, stop and ask instead of trying a chain
102
+ of unrelated tunnel tools. After completing a task, give a one-line summary.`;
92
103
  /** When running inside `hara gateway`, tell the agent it's in a chat — so it delivers files via send_file
93
104
  * (the only channel that reaches the peer) and never reaches for the desktop client / computer tool. */
94
105
  function gatewayNote() {
@@ -100,7 +111,9 @@ function gatewayNote() {
100
111
  `To send a file or image to them, call the \`send_file\` tool with an absolute path; that is the ONLY channel ` +
101
112
  `that reaches this chat. Do NOT use the \`computer\` tool, AppleScript, or any desktop/${plat}-client automation ` +
102
113
  `to deliver files — that drives a different window and silently fails to reach the user. Never tell the user a ` +
103
- `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.`);
104
117
  }
105
118
  function composeSystem(cwd, projectContext, override, memory) {
106
119
  const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
@@ -118,6 +131,27 @@ export async function runAgent(history, opts) {
118
131
  let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
119
132
  let triedFallback = false;
120
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
+ };
144
+ // Warn at the interaction boundary without echoing the value. Headless/gateway stdout is the response
145
+ // transport, so keep the banner to interactive surfaces; persistence is still redacted everywhere.
146
+ const latestUser = [...history].reverse().find((m) => m.role === "user");
147
+ const sensitive = latestUser?.role === "user" ? redactSensitiveText(latestUser.content).redactions : [];
148
+ if (sensitive.length && !opts.quiet) {
149
+ const note = "⚠ possible credential detected — the saved session copy will be redacted; prefer passing secrets through environment variables.";
150
+ if (ctx.ui)
151
+ ctx.ui.notice(note);
152
+ else if (stdout.isTTY)
153
+ out(c.yellow(note + "\n"));
154
+ }
121
155
  // Stuck/loop guard — only in headless chat (`hara gateway`), where a wrong approach can grind forever with
122
156
  // nobody to hit Esc (e.g. screenshots it can't read). Once per run, when the agent keeps repeating one
123
157
  // non-read tool or acting blind, we inject a reflection nudge so it steps back instead of spinning.
@@ -144,7 +178,7 @@ export async function runAgent(history, opts) {
144
178
  // lands as ONE wrapped user message the UI never renders. Quiet runs don't drain — a parallel
145
179
  // sub-agent must not steal the main conversation's reminders.
146
180
  if (!opts.quiet) {
147
- const reminders = drainReminders();
181
+ const reminders = drainReminders(ctx.todoScope);
148
182
  if (reminders.length)
149
183
  history.push({ role: "user", content: wrapReminders(reminders) });
150
184
  }
@@ -190,7 +224,12 @@ export async function runAgent(history, opts) {
190
224
  const STALL_MS = stallMs();
191
225
  const attempt = new AbortController();
192
226
  const onUserAbort = () => attempt.abort();
193
- 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 });
194
233
  let lastEvent = Date.now();
195
234
  let stalled = false;
196
235
  const stallTimer = setInterval(() => {
@@ -207,17 +246,33 @@ export async function runAgent(history, opts) {
207
246
  if (!opts.quiet)
208
247
  setTurnPhase("waiting"); // request sent, nothing streamed yet — the status row shows it
209
248
  let r;
249
+ let removeAttemptStop = () => { };
210
250
  try {
211
- 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({
212
263
  system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
213
264
  history,
214
265
  tools: specs,
215
266
  // Any stream chunk keeps the connection considered alive — even suppressed reasoning_content, so a
216
267
  // reasoning model thinking for a long while before its first `content` token can't be false-timed-out.
217
268
  onActivity: () => {
269
+ if (attempt.signal.aborted)
270
+ return;
218
271
  lastEvent = Date.now();
219
272
  },
220
273
  onText: (d) => {
274
+ if (attempt.signal.aborted)
275
+ return;
221
276
  alive();
222
277
  if (opts.quiet)
223
278
  return;
@@ -234,6 +289,8 @@ export async function runAgent(history, opts) {
234
289
  },
235
290
  onReasoning: sink || tty
236
291
  ? (d) => {
292
+ if (attempt.signal.aborted)
293
+ return;
237
294
  alive();
238
295
  if (opts.quiet)
239
296
  return;
@@ -259,12 +316,23 @@ export async function runAgent(history, opts) {
259
316
  }
260
317
  }
261
318
  }
262
- : (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)
263
323
  signal: attempt.signal,
264
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" };
265
332
  }
266
333
  finally {
267
334
  clearInterval(stallTimer);
335
+ removeAttemptStop();
268
336
  opts.signal?.removeEventListener("abort", onUserAbort);
269
337
  }
270
338
  // A watchdog abort surfaces from the provider as "interrupted" — rewrite it to a timeout-class
@@ -282,6 +350,10 @@ export async function runAgent(history, opts) {
282
350
  opts.stats.output += r.usage.output;
283
351
  opts.stats.lastInput = r.usage.input;
284
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();
285
357
  history.push({ role: "assistant", text: r.text, toolUses: r.toolUses });
286
358
  if (r.stop === "error") {
287
359
  const kind = classifyError(r.errorMsg ?? "");
@@ -299,13 +371,26 @@ export async function runAgent(history, opts) {
299
371
  continue; // retry once on the fallback model (guarded by triedFallback)
300
372
  }
301
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
+ }
302
387
  if (!opts.quiet) {
303
388
  if (sink)
304
389
  sink.notice(msg);
305
390
  else
306
391
  out(kind === "interrupted" ? c.dim(`\n${msg}\n`) : c.red(`${msg}\n`));
307
392
  }
308
- return;
393
+ return { status: "error", error: msg };
309
394
  }
310
395
  // Empty-turn guard. The model returned nothing actionable — no text AND no tool calls (a blank
311
396
  // completion, or a "tool_use" stop with an empty tool list). Silently returning here leaves the
@@ -333,16 +418,37 @@ export async function runAgent(history, opts) {
333
418
  else
334
419
  out(c.dim(`${note}\n`));
335
420
  }
336
- return;
421
+ return { status: "empty" };
337
422
  }
338
423
  // A "tool_use" stop with text but no tools (rare) has nothing to execute — end after showing the text
339
424
  // rather than pushing an empty tool round and re-requesting in a loop.
340
425
  if (r.stop !== "tool_use" || r.toolUses.length === 0)
341
- 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();
342
446
  const plans = [];
343
447
  // Extra (per-run) tools win over the registry so a run-scoped tool can't be shadowed by a global one.
344
448
  const resolveTool = (name) => opts.extraTools?.find((t) => t.name === name) ?? getTool(name);
345
449
  for (const tu of r.toolUses) {
450
+ if (opts.signal?.aborted)
451
+ return finalizeInterruptedToolRound();
346
452
  if (breakerHalt) {
347
453
  // Circuit-breaker halted the run: refuse every remaining call in this round with a clear message
348
454
  // (no hang, no further tools) so the model + user get a definitive stop.
@@ -376,6 +482,8 @@ export async function runAgent(history, opts) {
376
482
  if (risk.level === "high") {
377
483
  const detail = String(input.command ?? input.path ?? "").replace(/\s+/g, " ").trim().slice(0, 400);
378
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();
379
487
  if (verdict.decision === "block") {
380
488
  const tripped = recordBlock(breaker); // deterministic circuit-breaker: N blocks → hard stop
381
489
  plans.push({
@@ -399,6 +507,8 @@ export async function runAgent(history, opts) {
399
507
  const cont = interactive
400
508
  ? await opts.confirm(`${c.red("⛔ guardian circuit-breaker")} — ${breaker.blocks} high-risk actions blocked this turn. Continue anyway?`)
401
509
  : false;
510
+ if (opts.signal?.aborted)
511
+ return finalizeInterruptedToolRound();
402
512
  if (cont === false) {
403
513
  breakerHalt = true;
404
514
  }
@@ -413,6 +523,8 @@ export async function runAgent(history, opts) {
413
523
  }
414
524
  if (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && (alwaysGate || !opts.autoApprove?.has(tu.name))) {
415
525
  const reply = await opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`);
526
+ if (opts.signal?.aborted)
527
+ return finalizeInterruptedToolRound();
416
528
  if (reply === false) {
417
529
  plans.push({ tu, tool, denied: "User denied this action." });
418
530
  continue;
@@ -430,8 +542,11 @@ export async function runAgent(history, opts) {
430
542
  }
431
543
  }
432
544
  // Execute: read-only tools run concurrently; edit/exec run alone, in order.
433
- const results = new Array(plans.length);
545
+ if (opts.signal?.aborted)
546
+ return finalizeInterruptedToolRound();
434
547
  const runOne = async (idx, p) => {
548
+ if (opts.signal?.aborted)
549
+ return;
435
550
  if (p.denied !== undefined) {
436
551
  results[idx] = { id: p.tu.id, name: p.tu.name, content: p.denied, isError: true };
437
552
  return;
@@ -445,28 +560,42 @@ export async function runAgent(history, opts) {
445
560
  if (missing.length) {
446
561
  const msg = `Error: tool call NOT executed — missing required parameter${missing.length > 1 ? "s" : ""}: ` +
447
562
  `${missing.join(", ")}. Send the call again with ALL required parameters (${(p.tool.input_schema.required ?? []).join(", ")}) present and complete.`;
448
- 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 };
449
564
  return;
450
565
  }
451
- 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
452
571
  if (pre.block) {
453
572
  results[idx] = { id: p.tu.id, name: p.tu.name, content: pre.message, isError: true };
454
573
  return;
455
574
  }
575
+ if (opts.signal?.aborted)
576
+ return;
456
577
  // Track the MAIN conversation's working files for post-compaction restore (quiet fan-out
457
578
  // sub-agents read broadly — their files aren't "what the user was working on").
458
579
  if (!opts.quiet && FILE_TOUCH_TOOLS.has(p.tu.name) && typeof p.tu.input?.path === "string") {
459
- recordTouch(resolvePath(ctx.cwd, String(p.tu.input.path)));
580
+ recordTouch(resolvePath(ctx.cwd, String(p.tu.input.path)), ctx.todoScope);
460
581
  }
461
582
  const res = await p.tool.run(p.tu.input, ctx);
462
583
  // append any not-yet-seen subdirectory AGENTS.md/CLAUDE.md this call touched (monorepo-local conventions)
463
584
  // + the repeat-guard's anti-spinning note when this exact call keeps failing (repeat-guard.ts)
464
- 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) };
465
- 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
+ }
466
593
  }
467
594
  catch (e) {
468
595
  const msg = `Error: ${e.message}`;
469
- 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;
470
599
  }
471
600
  finally {
472
601
  activity.dec();
@@ -481,16 +610,24 @@ export async function runAgent(history, opts) {
481
610
  await mapLimit(idx, maxParallel(), (i) => runOne(i, plans[i])); // bounded fan-out (e.g. 20 parallel agents → 8 at a time)
482
611
  };
483
612
  for (let i = 0; i < plans.length; i++) {
613
+ if (opts.signal?.aborted)
614
+ return finalizeInterruptedToolRound();
484
615
  const p = plans[i];
485
616
  if (p.denied === undefined && p.tool?.kind === "read") {
486
617
  batch.push(i); // safe → accumulate to run concurrently
487
618
  }
488
619
  else {
489
620
  await flush(); // flush pending reads before an edit/exec
621
+ if (opts.signal?.aborted)
622
+ return finalizeInterruptedToolRound();
490
623
  await runOne(i, p);
624
+ if (opts.signal?.aborted)
625
+ return finalizeInterruptedToolRound();
491
626
  }
492
627
  }
493
628
  await flush();
629
+ if (opts.signal?.aborted)
630
+ return finalizeInterruptedToolRound();
494
631
  history.push({ role: "tool", results });
495
632
  // Synthesis nudge (CC's KN5, hara-shaped): a round that fanned out to several parallel agents just
496
633
  // produced N independent reports — remind the model to merge/reconcile them before acting, instead
@@ -498,7 +635,7 @@ export async function runAgent(history, opts) {
498
635
  if (!opts.quiet) {
499
636
  const fanout = r.toolUses.filter((tu) => tu.name === "agent").length;
500
637
  if (fanout >= SYNTHESIS_MIN_AGENTS)
501
- pushReminder(synthesisReminder(fanout));
638
+ pushReminder(synthesisReminder(fanout), ctx.todoScope);
502
639
  }
503
640
  // Todo attention-refresh: a round that touched the checklist resets the clock; rounds that leave
504
641
  // unfinished items untouched accumulate, and at TODO_STALE_ROUNDS the model gets a system-reminder
@@ -507,10 +644,10 @@ export async function runAgent(history, opts) {
507
644
  if (r.toolUses.some((tu) => tu.name === "todo_write")) {
508
645
  todoIdleRounds = 0;
509
646
  }
510
- else if (currentTodos().some((t) => t.status !== "done")) {
647
+ else if (currentTodos(ctx.todoScope).some((t) => t.status !== "done")) {
511
648
  todoIdleRounds++;
512
649
  if (todoIdleRounds >= TODO_STALE_ROUNDS) {
513
- pushReminder(todoStaleReminder(renderTodos(currentTodos())));
650
+ pushReminder(todoStaleReminder(renderTodos(currentTodos(ctx.todoScope))), ctx.todoScope);
514
651
  todoIdleRounds = 0;
515
652
  }
516
653
  }
@@ -525,7 +662,7 @@ export async function runAgent(history, opts) {
525
662
  else
526
663
  out(c.red(`${note}\n`));
527
664
  }
528
- return;
665
+ return { status: "halted" };
529
666
  }
530
667
  if (guard && !nudged) {
531
668
  for (const p of plans)