@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.
- package/CHANGELOG.md +85 -0
- package/README.md +40 -6
- package/dist/agent/failover.js +1 -1
- package/dist/agent/loop.js +158 -21
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +20 -6
- package/dist/config.js +62 -26
- package/dist/cron/deliver.js +37 -3
- package/dist/feedback.js +5 -9
- package/dist/fs-read.js +106 -12
- package/dist/fs-write.js +242 -16
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -18
- package/dist/gateway/feishu.js +158 -57
- package/dist/gateway/flows-pending.js +720 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +80 -15
- package/dist/gateway/mattermost.js +44 -32
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/serve.js +657 -162
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +27 -22
- package/dist/gateway/slack.js +26 -17
- package/dist/gateway/telegram.js +32 -18
- package/dist/gateway/wecom.js +32 -24
- package/dist/gateway/weixin.js +127 -49
- package/dist/hooks.js +32 -20
- package/dist/index.js +640 -219
- package/dist/org/projects.js +347 -0
- package/dist/org/roles.js +38 -11
- package/dist/security/secrets.js +150 -0
- package/dist/serve/server.js +772 -317
- package/dist/serve/sessions.js +112 -28
- package/dist/session/store.js +337 -44
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +61 -23
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/edit.js +11 -8
- package/dist/tools/patch.js +230 -31
- package/dist/tools/search.js +482 -72
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +364 -64
- package/dist/tui/run.js +26 -23
- package/dist/undo.js +83 -7
- 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
|
|
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`.
|
|
139
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
package/dist/agent/failover.js
CHANGED
|
@@ -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 " —
|
|
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":
|
package/dist/agent/loop.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
: (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|