@nanhara/hara 0.121.1 → 0.122.1

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 (80) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +57 -10
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +169 -31
  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 +24 -6
  9. package/dist/checkpoints.js +103 -17
  10. package/dist/cli.js +16 -0
  11. package/dist/config.js +173 -34
  12. package/dist/context/agents-md.js +44 -9
  13. package/dist/context/mentions.js +10 -4
  14. package/dist/context/subdir-hints.js +40 -7
  15. package/dist/cron/deliver.js +37 -3
  16. package/dist/cron/runner.js +372 -37
  17. package/dist/cron/store.js +11 -3
  18. package/dist/exec/jobs.js +88 -20
  19. package/dist/feedback.js +3 -2
  20. package/dist/fs-read.js +421 -12
  21. package/dist/fs-walk.js +8 -2
  22. package/dist/fs-write.js +433 -21
  23. package/dist/gateway/dingtalk.js +4 -1
  24. package/dist/gateway/discord.js +53 -20
  25. package/dist/gateway/feishu.js +157 -58
  26. package/dist/gateway/flows-pending.js +727 -0
  27. package/dist/gateway/flows.js +391 -16
  28. package/dist/gateway/matrix.js +81 -18
  29. package/dist/gateway/mattermost.js +44 -34
  30. package/dist/gateway/media.js +659 -0
  31. package/dist/gateway/outbound-files.js +379 -0
  32. package/dist/gateway/serve.js +712 -169
  33. package/dist/gateway/sessions.js +475 -78
  34. package/dist/gateway/signal.js +31 -28
  35. package/dist/gateway/slack.js +28 -21
  36. package/dist/gateway/telegram.js +33 -21
  37. package/dist/gateway/tmux-routes.js +11 -3
  38. package/dist/gateway/wecom.js +38 -31
  39. package/dist/gateway/weixin.js +147 -59
  40. package/dist/hooks.js +41 -23
  41. package/dist/index.js +763 -273
  42. package/dist/mcp/client.js +164 -12
  43. package/dist/memory/store.js +68 -22
  44. package/dist/org/planner.js +36 -10
  45. package/dist/org/projects.js +347 -0
  46. package/dist/org/review-chain.js +360 -24
  47. package/dist/org/roles.js +42 -13
  48. package/dist/profile/profile.js +152 -27
  49. package/dist/recall.js +4 -2
  50. package/dist/runtime.js +37 -0
  51. package/dist/sandbox.js +142 -33
  52. package/dist/search/semindex.js +182 -53
  53. package/dist/search/zvec-store.js +121 -42
  54. package/dist/security/permissions.js +326 -19
  55. package/dist/security/private-state.js +299 -0
  56. package/dist/security/project-trust.js +6 -0
  57. package/dist/security/secrets.js +84 -9
  58. package/dist/security/sensitive-files.js +723 -0
  59. package/dist/security/subprocess-env.js +210 -0
  60. package/dist/serve/server.js +774 -318
  61. package/dist/serve/sessions.js +113 -33
  62. package/dist/session/store.js +298 -47
  63. package/dist/skills/skills.js +16 -7
  64. package/dist/tools/all.js +1 -0
  65. package/dist/tools/builtin.js +77 -49
  66. package/dist/tools/codebase.js +3 -1
  67. package/dist/tools/computer.js +98 -92
  68. package/dist/tools/cron.js +6 -0
  69. package/dist/tools/edit.js +22 -9
  70. package/dist/tools/external_agent.js +110 -16
  71. package/dist/tools/memory.js +38 -8
  72. package/dist/tools/patch.js +253 -34
  73. package/dist/tools/search.js +543 -73
  74. package/dist/tools/send.js +11 -5
  75. package/dist/tools/task.js +453 -0
  76. package/dist/tools/todo.js +67 -16
  77. package/dist/tools/web.js +168 -54
  78. package/dist/undo.js +83 -7
  79. package/package.json +11 -10
  80. package/runtime-bootstrap.cjs +72 -0
@@ -17,6 +17,7 @@ import { setTurnPhase } from "./phase.js";
17
17
  import { recordTouch } from "./touched.js";
18
18
  import { resolve as resolvePath } from "node:path";
19
19
  import { redactSensitiveText } from "../security/secrets.js";
20
+ import { redactToolSubprocessOutput } from "../security/subprocess-env.js";
20
21
  /** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
21
22
  const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
22
23
  /** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
@@ -72,7 +73,17 @@ source file or shell command. Reference an environment variable instead (for exa
72
73
  $API_KEY). Keep real values in the user's environment or an approved secret store; do not create/populate a
73
74
  .env file with a real secret unless the user explicitly asks and it is excluded from version control. Never
74
75
  echo credentials back. Session persistence redacts likely secrets as a last line of defense, but that does
75
- not make embedding credentials acceptable.
76
+ not make embedding credentials acceptable. Built-in file, search, and context paths hard-reject protected
77
+ files (.env/.env.*, credential stores, private keys, and private Hara state) before ordinary approval/dispatch;
78
+ do not try to bypass that policy through shell indirection, another tool, a sub-agent, or full-auto. Safe
79
+ templates such as .env.example may be read. Only a user who restarts Hara with
80
+ HARA_ALLOW_SENSITIVE_FILES=1 explicitly removes the built-in deny and shell protected-read mask for that
81
+ process. Shell subprocesses have credentials removed from their environment. macOS also applies an OS read
82
+ mask to existing protected paths; Linux/Windows shell checks are
83
+ static guardrails, not a kernel sandbox. MCP and external coding agents run outside this boundary: use them
84
+ only as reviewed trusted extensions. Their tool calls require confirmation every time in interactive use and
85
+ are disabled without an interactive approval channel unless the user launched with
86
+ HARA_ALLOW_TRUSTED_EXTENSIONS=1.
76
87
  For broad,
77
88
  open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
78
89
  independent questions (role "explore") — each returns conclusions, not dumps. Messages the user sends
@@ -96,8 +107,8 @@ site, build output), check they are newer than their sources (compare mtimes or
96
107
  the sources changed since the artifacts were built, run the project's documented build/render steps FIRST.
97
108
  When AGENTS.md / README / package.json document a command sequence (e.g. pull → render → build → preview),
98
109
  that ordering is authoritative — never skip the middle steps, or you serve stale output and the user sees
99
- two-day-old work. Package-manager installs auto-start as background jobs when you omit background/timeout;
100
- poll the returned job until it exits before depending on its packages. Before opening a public tunnel,
110
+ two-day-old work. Package-manager installs receive a longer attached timeout by default; use background jobs
111
+ only when explicitly appropriate, and poll a background job before depending on it. Before opening a public tunnel,
101
112
  verify that provider's authentication/config once; if it is missing, stop and ask instead of trying a chain
102
113
  of unrelated tunnel tools. After completing a task, give a one-line summary.`;
103
114
  /** When running inside `hara gateway`, tell the agent it's in a chat — so it delivers files via send_file
@@ -111,7 +122,9 @@ function gatewayNote() {
111
122
  `To send a file or image to them, call the \`send_file\` tool with an absolute path; that is the ONLY channel ` +
112
123
  `that reaches this chat. Do NOT use the \`computer\` tool, AppleScript, or any desktop/${plat}-client automation ` +
113
124
  `to deliver files — that drives a different window and silently fails to reach the user. Never tell the user a ` +
114
- `file was sent unless \`send_file\` returned success. Keep replies short and chat-friendly.`);
125
+ `file was sent unless \`send_file\` returned success. Keep replies short and chat-friendly. ` +
126
+ `PLAIN TEXT ONLY: chat bubbles render markdown literally — never use **bold**, # headers, backticks, tables, ` +
127
+ `or [text](url); write list items as "- " lines and links as bare URLs.`);
115
128
  }
116
129
  function composeSystem(cwd, projectContext, override, memory) {
117
130
  const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
@@ -129,6 +142,16 @@ export async function runAgent(history, opts) {
129
142
  let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
130
143
  let triedFallback = false;
131
144
  let emptyRetried = false; // one-shot: a genuinely empty model turn gets a single nudge before we give up
145
+ const interruptedOutcome = () => {
146
+ const msg = "(interrupted)";
147
+ if (!opts.quiet) {
148
+ if (ctx.ui)
149
+ ctx.ui.notice(msg);
150
+ else
151
+ out(c.dim(`\n${msg}\n`));
152
+ }
153
+ return { status: "error", error: msg };
154
+ };
132
155
  // Warn at the interaction boundary without echoing the value. Headless/gateway stdout is the response
133
156
  // transport, so keep the banner to interactive surfaces; persistence is still redacted everywhere.
134
157
  const latestUser = [...history].reverse().find((m) => m.role === "user");
@@ -166,7 +189,7 @@ export async function runAgent(history, opts) {
166
189
  // lands as ONE wrapped user message the UI never renders. Quiet runs don't drain — a parallel
167
190
  // sub-agent must not steal the main conversation's reminders.
168
191
  if (!opts.quiet) {
169
- const reminders = drainReminders();
192
+ const reminders = drainReminders(ctx.todoScope);
170
193
  if (reminders.length)
171
194
  history.push({ role: "user", content: wrapReminders(reminders) });
172
195
  }
@@ -212,7 +235,12 @@ export async function runAgent(history, opts) {
212
235
  const STALL_MS = stallMs();
213
236
  const attempt = new AbortController();
214
237
  const onUserAbort = () => attempt.abort();
215
- opts.signal?.addEventListener("abort", onUserAbort, { once: true });
238
+ // AbortSignal does not replay an already-fired event to a late listener. A serve shutdown can cancel
239
+ // while provider routing is still refreshing, so inherit that state synchronously before the call.
240
+ if (opts.signal?.aborted)
241
+ attempt.abort();
242
+ else
243
+ opts.signal?.addEventListener("abort", onUserAbort, { once: true });
216
244
  let lastEvent = Date.now();
217
245
  let stalled = false;
218
246
  const stallTimer = setInterval(() => {
@@ -229,17 +257,33 @@ export async function runAgent(history, opts) {
229
257
  if (!opts.quiet)
230
258
  setTurnPhase("waiting"); // request sent, nothing streamed yet — the status row shows it
231
259
  let r;
260
+ let removeAttemptStop = () => { };
232
261
  try {
233
- r = await activeProvider.turn({
262
+ // AbortSignal is advisory: a custom/provider SDK can ignore it and leave its Promise pending forever.
263
+ // Race the attempt itself so the watchdog and user cancellation remain hard boundaries. The abandoned
264
+ // Promise retains a rejection handler through Promise.race, and all late stream callbacks below are muted.
265
+ const attemptStopped = new Promise((resolveStopped) => {
266
+ const onAttemptStop = () => resolveStopped({ text: "", toolUses: [], stop: "error", errorMsg: "interrupted" });
267
+ removeAttemptStop = () => attempt.signal.removeEventListener("abort", onAttemptStop);
268
+ if (attempt.signal.aborted)
269
+ onAttemptStop();
270
+ else
271
+ attempt.signal.addEventListener("abort", onAttemptStop, { once: true });
272
+ });
273
+ const providerTurn = activeProvider.turn({
234
274
  system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
235
275
  history,
236
276
  tools: specs,
237
277
  // Any stream chunk keeps the connection considered alive — even suppressed reasoning_content, so a
238
278
  // reasoning model thinking for a long while before its first `content` token can't be false-timed-out.
239
279
  onActivity: () => {
280
+ if (attempt.signal.aborted)
281
+ return;
240
282
  lastEvent = Date.now();
241
283
  },
242
284
  onText: (d) => {
285
+ if (attempt.signal.aborted)
286
+ return;
243
287
  alive();
244
288
  if (opts.quiet)
245
289
  return;
@@ -256,6 +300,8 @@ export async function runAgent(history, opts) {
256
300
  },
257
301
  onReasoning: sink || tty
258
302
  ? (d) => {
303
+ if (attempt.signal.aborted)
304
+ return;
259
305
  alive();
260
306
  if (opts.quiet)
261
307
  return;
@@ -281,12 +327,23 @@ export async function runAgent(history, opts) {
281
327
  }
282
328
  }
283
329
  }
284
- : (d) => alive(), // quiet runs still feed the watchdog (reasoning-only stretches are progress)
330
+ : () => {
331
+ if (!attempt.signal.aborted)
332
+ alive();
333
+ }, // quiet runs still feed the watchdog (reasoning-only stretches are progress)
285
334
  signal: attempt.signal,
286
335
  });
336
+ opts.onProviderTurn?.(providerTurn);
337
+ r = await Promise.race([providerTurn, attemptStopped]);
338
+ }
339
+ catch (error) {
340
+ if (!opts.signal?.aborted)
341
+ throw error;
342
+ r = { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
287
343
  }
288
344
  finally {
289
345
  clearInterval(stallTimer);
346
+ removeAttemptStop();
290
347
  opts.signal?.removeEventListener("abort", onUserAbort);
291
348
  }
292
349
  // A watchdog abort surfaces from the provider as "interrupted" — rewrite it to a timeout-class
@@ -304,6 +361,10 @@ export async function runAgent(history, opts) {
304
361
  opts.stats.output += r.usage.output;
305
362
  opts.stats.lastInput = r.usage.input;
306
363
  }
364
+ // A provider may ignore AbortSignal and return a perfectly valid-looking tool_use after cancellation.
365
+ // The original run signal is authoritative: do not append/approve/execute any late response.
366
+ if (opts.signal?.aborted)
367
+ return interruptedOutcome();
307
368
  history.push({ role: "assistant", text: r.text, toolUses: r.toolUses });
308
369
  if (r.stop === "error") {
309
370
  const kind = classifyError(r.errorMsg ?? "");
@@ -321,13 +382,26 @@ export async function runAgent(history, opts) {
321
382
  continue; // retry once on the fallback model (guarded by triedFallback)
322
383
  }
323
384
  const msg = kind === "interrupted" ? "(interrupted)" : `[${activeProvider.id} error] ${r.errorMsg ?? "unknown"}${errorHint(kind)}`;
385
+ if (r.toolUses.length) {
386
+ // A provider can fail after partially assembling tool calls. The assistant turn is already persisted;
387
+ // close every call explicitly so the next request is valid, while never executing partial work.
388
+ history.push({
389
+ role: "tool",
390
+ results: r.toolUses.map((toolUse) => ({
391
+ id: toolUse.id,
392
+ name: toolUse.name,
393
+ content: `Error: provider failed before this tool call could be executed. ${r.errorMsg ?? "unknown provider error"}`,
394
+ isError: true,
395
+ })),
396
+ });
397
+ }
324
398
  if (!opts.quiet) {
325
399
  if (sink)
326
400
  sink.notice(msg);
327
401
  else
328
402
  out(kind === "interrupted" ? c.dim(`\n${msg}\n`) : c.red(`${msg}\n`));
329
403
  }
330
- return;
404
+ return { status: "error", error: msg };
331
405
  }
332
406
  // Empty-turn guard. The model returned nothing actionable — no text AND no tool calls (a blank
333
407
  // completion, or a "tool_use" stop with an empty tool list). Silently returning here leaves the
@@ -355,16 +429,37 @@ export async function runAgent(history, opts) {
355
429
  else
356
430
  out(c.dim(`${note}\n`));
357
431
  }
358
- return;
432
+ return { status: "empty" };
359
433
  }
360
434
  // A "tool_use" stop with text but no tools (rare) has nothing to execute — end after showing the text
361
435
  // rather than pushing an empty tool round and re-requesting in a loop.
362
436
  if (r.stop !== "tool_use" || r.toolUses.length === 0)
363
- return;
437
+ return { status: "completed" };
438
+ // Once an assistant tool_use turn enters history, every tool_use MUST receive a matching tool result.
439
+ // OpenAI/Anthropic both reject a later user turn after an unclosed tool round. Cancellation can happen
440
+ // while planning, approving, or executing, so finalize the round with real results for work that already
441
+ // completed and explicit interruption errors for everything else before persisting the session.
442
+ const results = new Array(r.toolUses.length);
443
+ const finalizeInterruptedToolRound = () => {
444
+ history.push({
445
+ role: "tool",
446
+ results: r.toolUses.map((tu, idx) => results[idx] ?? ({
447
+ id: tu.id,
448
+ name: tu.name,
449
+ content: "Error: interrupted before this tool call completed.",
450
+ isError: true,
451
+ })),
452
+ });
453
+ return interruptedOutcome();
454
+ };
455
+ if (opts.signal?.aborted)
456
+ return finalizeInterruptedToolRound();
364
457
  const plans = [];
365
458
  // Extra (per-run) tools win over the registry so a run-scoped tool can't be shadowed by a global one.
366
459
  const resolveTool = (name) => opts.extraTools?.find((t) => t.name === name) ?? getTool(name);
367
460
  for (const tu of r.toolUses) {
461
+ if (opts.signal?.aborted)
462
+ return finalizeInterruptedToolRound();
368
463
  if (breakerHalt) {
369
464
  // Circuit-breaker halted the run: refuse every remaining call in this round with a clear message
370
465
  // (no hang, no further tools) so the model + user get a definitive stop.
@@ -377,11 +472,21 @@ export async function runAgent(history, opts) {
377
472
  continue;
378
473
  }
379
474
  const input = tu.input;
380
- const preview = String(input.path ?? input.command ?? input.pattern ?? input.url ?? input.task ?? "")
475
+ const preview = redactToolSubprocessOutput(String(input.path ?? input.command ?? input.pattern ?? input.url ?? input.task ?? "")
381
476
  .replace(/\s+/g, " ")
382
- .trim();
383
- // Screen control is gated on EVERY action — a prior "don't ask again" must never satisfy it.
384
- const alwaysGate = tool.kind === "computer";
477
+ .trim());
478
+ // Screen control and opaque host extensions are gated on EVERY action — a prior "don't ask again"
479
+ // and even full-auto must never silently turn them into a side channel.
480
+ const alwaysGate = tool.kind === "computer" || tool.trustBoundary === "external";
481
+ if (tool.trustBoundary === "external" && !ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
482
+ plans.push({
483
+ tu,
484
+ tool,
485
+ denied: "Trusted extension blocked in this non-interactive run. MCP and external coding agents run outside Hara's file boundary; " +
486
+ "restart with HARA_ALLOW_TRUSTED_EXTENSIONS=1 only after reviewing that extension.",
487
+ });
488
+ continue;
489
+ }
385
490
  // Command-level policy for shell commands: a deny rule blocks even in full-auto; an allow rule (or a
386
491
  // read-only command) auto-runs even in suggest mode. Composes with, doesn't replace, the approval mode.
387
492
  const cmdDecision = tool.kind === "exec" && typeof input.command === "string" ? decideCommand(input.command, permRules) : null;
@@ -396,17 +501,20 @@ export async function runAgent(history, opts) {
396
501
  if (guardianOn && !breakerHalt) {
397
502
  const risk = classifyRisk(tu.name, tool.kind, input, ctx.cwd);
398
503
  if (risk.level === "high") {
399
- const detail = String(input.command ?? input.path ?? "").replace(/\s+/g, " ").trim().slice(0, 400);
400
- const verdict = await guardianVeto(opts.guardian.provider, { tool: tu.name, detail, classifierReason: risk.reason }, history, { signal: opts.signal });
504
+ const safeRiskReason = redactToolSubprocessOutput(risk.reason);
505
+ const detail = redactToolSubprocessOutput(String(input.command ?? input.path ?? "").replace(/\s+/g, " ").trim().slice(0, 400));
506
+ const verdict = await guardianVeto(opts.guardian.provider, { tool: tu.name, detail, classifierReason: safeRiskReason }, history, { signal: opts.signal });
507
+ if (opts.signal?.aborted)
508
+ return finalizeInterruptedToolRound();
401
509
  if (verdict.decision === "block") {
402
510
  const tripped = recordBlock(breaker); // deterministic circuit-breaker: N blocks → hard stop
403
511
  plans.push({
404
512
  tu,
405
513
  tool,
406
- denied: `Guardian blocked this high-risk action: ${verdict.reason || risk.reason}. Reconsider — take a safer, in-scope step, or ask the user before doing this.`,
514
+ denied: `Guardian blocked this high-risk action: ${verdict.reason || safeRiskReason}. Reconsider — take a safer, in-scope step, or ask the user before doing this.`,
407
515
  });
408
516
  if (!opts.quiet) {
409
- const note = `⛔ guardian blocked ${tu.name} — ${verdict.reason || risk.reason}`;
517
+ const note = `⛔ guardian blocked ${tu.name} — ${verdict.reason || safeRiskReason}`;
410
518
  if (sink)
411
519
  sink.notice(note);
412
520
  else
@@ -421,6 +529,8 @@ export async function runAgent(history, opts) {
421
529
  const cont = interactive
422
530
  ? await opts.confirm(`${c.red("⛔ guardian circuit-breaker")} — ${breaker.blocks} high-risk actions blocked this turn. Continue anyway?`)
423
531
  : false;
532
+ if (opts.signal?.aborted)
533
+ return finalizeInterruptedToolRound();
424
534
  if (cont === false) {
425
535
  breakerHalt = true;
426
536
  }
@@ -433,8 +543,11 @@ export async function runAgent(history, opts) {
433
543
  }
434
544
  }
435
545
  }
436
- if (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && (alwaysGate || !opts.autoApprove?.has(tu.name))) {
546
+ const shouldConfirm = alwaysGate || (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && !opts.autoApprove?.has(tu.name));
547
+ if (shouldConfirm) {
437
548
  const reply = await opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`);
549
+ if (opts.signal?.aborted)
550
+ return finalizeInterruptedToolRound();
438
551
  if (reply === false) {
439
552
  plans.push({ tu, tool, denied: "User denied this action." });
440
553
  continue;
@@ -452,8 +565,11 @@ export async function runAgent(history, opts) {
452
565
  }
453
566
  }
454
567
  // Execute: read-only tools run concurrently; edit/exec run alone, in order.
455
- const results = new Array(plans.length);
568
+ if (opts.signal?.aborted)
569
+ return finalizeInterruptedToolRound();
456
570
  const runOne = async (idx, p) => {
571
+ if (opts.signal?.aborted)
572
+ return;
457
573
  if (p.denied !== undefined) {
458
574
  results[idx] = { id: p.tu.id, name: p.tu.name, content: p.denied, isError: true };
459
575
  return;
@@ -467,28 +583,42 @@ export async function runAgent(history, opts) {
467
583
  if (missing.length) {
468
584
  const msg = `Error: tool call NOT executed — missing required parameter${missing.length > 1 ? "s" : ""}: ` +
469
585
  `${missing.join(", ")}. Send the call again with ALL required parameters (${(p.tool.input_schema.required ?? []).join(", ")}) present and complete.`;
470
- results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true), isError: true };
586
+ results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true, ctx.todoScope), isError: true };
471
587
  return;
472
588
  }
473
- const pre = runHooks("PreToolUse", p.tu.name, p.tu.input, ctx.cwd); // a hook may veto the call
589
+ if (opts.signal?.aborted)
590
+ return;
591
+ const pre = opts.hooks === false
592
+ ? { block: false, message: "" }
593
+ : runHooks("PreToolUse", p.tu.name, p.tu.input, ctx.cwd); // a hook may veto the call
474
594
  if (pre.block) {
475
595
  results[idx] = { id: p.tu.id, name: p.tu.name, content: pre.message, isError: true };
476
596
  return;
477
597
  }
598
+ if (opts.signal?.aborted)
599
+ return;
478
600
  // Track the MAIN conversation's working files for post-compaction restore (quiet fan-out
479
601
  // sub-agents read broadly — their files aren't "what the user was working on").
480
602
  if (!opts.quiet && FILE_TOUCH_TOOLS.has(p.tu.name) && typeof p.tu.input?.path === "string") {
481
- recordTouch(resolvePath(ctx.cwd, String(p.tu.input.path)));
603
+ recordTouch(resolvePath(ctx.cwd, String(p.tu.input.path)), ctx.todoScope);
482
604
  }
483
605
  const res = await p.tool.run(p.tu.input, ctx);
484
606
  // append any not-yet-seen subdirectory AGENTS.md/CLAUDE.md this call touched (monorepo-local conventions)
485
607
  // + the repeat-guard's anti-spinning note when this exact call keeps failing (repeat-guard.ts)
486
- results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) + recordCall(p.tu.name, p.tu.input, res) };
487
- runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd); // observe-only
608
+ 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) };
609
+ // The tool may have completed a side effect and then triggered/observed cancellation. Preserve its
610
+ // actual result in the closing tool round, but do not run any post hook or later tool afterward.
611
+ if (opts.signal?.aborted)
612
+ return;
613
+ if (opts.hooks !== false) {
614
+ runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd); // observe-only
615
+ }
488
616
  }
489
617
  catch (e) {
490
618
  const msg = `Error: ${e.message}`;
491
- results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true), isError: true };
619
+ 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 };
620
+ if (opts.signal?.aborted)
621
+ return;
492
622
  }
493
623
  finally {
494
624
  activity.dec();
@@ -503,16 +633,24 @@ export async function runAgent(history, opts) {
503
633
  await mapLimit(idx, maxParallel(), (i) => runOne(i, plans[i])); // bounded fan-out (e.g. 20 parallel agents → 8 at a time)
504
634
  };
505
635
  for (let i = 0; i < plans.length; i++) {
636
+ if (opts.signal?.aborted)
637
+ return finalizeInterruptedToolRound();
506
638
  const p = plans[i];
507
639
  if (p.denied === undefined && p.tool?.kind === "read") {
508
640
  batch.push(i); // safe → accumulate to run concurrently
509
641
  }
510
642
  else {
511
643
  await flush(); // flush pending reads before an edit/exec
644
+ if (opts.signal?.aborted)
645
+ return finalizeInterruptedToolRound();
512
646
  await runOne(i, p);
647
+ if (opts.signal?.aborted)
648
+ return finalizeInterruptedToolRound();
513
649
  }
514
650
  }
515
651
  await flush();
652
+ if (opts.signal?.aborted)
653
+ return finalizeInterruptedToolRound();
516
654
  history.push({ role: "tool", results });
517
655
  // Synthesis nudge (CC's KN5, hara-shaped): a round that fanned out to several parallel agents just
518
656
  // produced N independent reports — remind the model to merge/reconcile them before acting, instead
@@ -520,7 +658,7 @@ export async function runAgent(history, opts) {
520
658
  if (!opts.quiet) {
521
659
  const fanout = r.toolUses.filter((tu) => tu.name === "agent").length;
522
660
  if (fanout >= SYNTHESIS_MIN_AGENTS)
523
- pushReminder(synthesisReminder(fanout));
661
+ pushReminder(synthesisReminder(fanout), ctx.todoScope);
524
662
  }
525
663
  // Todo attention-refresh: a round that touched the checklist resets the clock; rounds that leave
526
664
  // unfinished items untouched accumulate, and at TODO_STALE_ROUNDS the model gets a system-reminder
@@ -529,10 +667,10 @@ export async function runAgent(history, opts) {
529
667
  if (r.toolUses.some((tu) => tu.name === "todo_write")) {
530
668
  todoIdleRounds = 0;
531
669
  }
532
- else if (currentTodos().some((t) => t.status !== "done")) {
670
+ else if (currentTodos(ctx.todoScope).some((t) => t.status !== "done")) {
533
671
  todoIdleRounds++;
534
672
  if (todoIdleRounds >= TODO_STALE_ROUNDS) {
535
- pushReminder(todoStaleReminder(renderTodos(currentTodos())));
673
+ pushReminder(todoStaleReminder(renderTodos(currentTodos(ctx.todoScope))), ctx.todoScope);
536
674
  todoIdleRounds = 0;
537
675
  }
538
676
  }
@@ -547,7 +685,7 @@ export async function runAgent(history, opts) {
547
685
  else
548
686
  out(c.red(`${note}\n`));
549
687
  }
550
- return;
688
+ return { status: "halted" };
551
689
  }
552
690
  if (guard && !nudged) {
553
691
  for (const p of plans)
@@ -6,19 +6,34 @@
6
6
  //
7
7
  // Claude Code's disclaimer is preserved: the model is told the context may be irrelevant, so an
8
8
  // injected nudge never derails an unrelated task.
9
- const queue = [];
9
+ const DEFAULT_SCOPE = "default";
10
+ const queues = new Map();
11
+ function scopeKey(scope) {
12
+ return scope?.trim() || DEFAULT_SCOPE;
13
+ }
10
14
  /** Queue a reminder for injection before the next model call (main loop only — quiet/sub-agent runs
11
15
  * neither push nor drain, so a parallel fan-out can't steal the main conversation's reminders). */
12
- export function pushReminder(text) {
16
+ export function pushReminder(text, scope) {
13
17
  const t = text.trim();
14
- if (t)
15
- queue.push(t);
18
+ if (!t)
19
+ return;
20
+ const key = scopeKey(scope);
21
+ const queue = queues.get(key) ?? [];
22
+ queue.push(t);
23
+ queues.set(key, queue);
16
24
  }
17
25
  /** Take everything queued (FIFO), clearing the queue. */
18
- export function drainReminders() {
19
- if (!queue.length)
26
+ export function drainReminders(scope) {
27
+ const key = scopeKey(scope);
28
+ const queue = queues.get(key);
29
+ if (!queue?.length)
20
30
  return [];
21
- return queue.splice(0, queue.length);
31
+ queues.delete(key);
32
+ return queue;
33
+ }
34
+ /** Drop an ephemeral session's pending reminders without exposing them to another run. */
35
+ export function disposeReminderScope(scope) {
36
+ queues.delete(scopeKey(scope));
22
37
  }
23
38
  /** Merge queued reminders into the single injected message. */
24
39
  export function wrapReminders(items) {
@@ -4,8 +4,16 @@
4
4
  // covers FAILED ones. Deterministic and session-scoped (module state, same pattern as net-reachability):
5
5
  // when an identical (tool, args) call fails twice in a row, the tool result gets an explicit "stop
6
6
  // repeating this" note the model can't miss. Successful repeats are NOT flagged — a re-read after an edit
7
- // or a re-run after a fix is legitimate, and a success resets the failure streak.
8
- const seen = new Map();
7
+ // or a re-run after a fix is legitimate, and a success resets the failure streak. Serve can run several
8
+ // sessions in one process, so streaks are keyed by the same run scope as todo/reminder state.
9
+ const DEFAULT_SCOPE = "default";
10
+ const seenByScope = new Map();
11
+ function scopedSeen(scope) {
12
+ const key = scope?.trim() || DEFAULT_SCOPE;
13
+ const seen = seenByScope.get(key) ?? new Map();
14
+ seenByScope.set(key, seen);
15
+ return seen;
16
+ }
9
17
  /** Identity of a call = tool name + exact JSON of its arguments (tool names contain no spaces,
10
18
  * so a space separator is unambiguous). */
11
19
  export function keyOf(name, input) {
@@ -24,12 +32,20 @@ export function looksFailed(content) {
24
32
  }
25
33
  /** Record a completed call; returns a warning to APPEND to the tool result when the same call has now
26
34
  * failed >=2x in a row (empty string otherwise). Pure aside from the session-scoped map. */
27
- export function recordCall(name, input, content, isError = false) {
35
+ export function recordCall(name, input, content, isError = false, scope) {
28
36
  const k = keyOf(name, input);
29
37
  const failed = isError || looksFailed(content);
38
+ const seen = scopedSeen(scope);
30
39
  const s = seen.get(k) ?? { fails: 0 };
31
- s.fails = failed ? s.fails + 1 : 0; // success resets the streak
32
- seen.set(k, s);
40
+ if (!failed) {
41
+ seen.delete(k); // successes have no useful state; don't leak every unique tool call in a long-lived server
42
+ return "";
43
+ }
44
+ s.fails++;
45
+ seen.delete(k);
46
+ seen.set(k, s); // refresh insertion order for the bounded per-scope LRU
47
+ if (seen.size > 500)
48
+ seen.delete(seen.keys().next().value);
33
49
  if (s.fails < 2)
34
50
  return "";
35
51
  return (`\n\n⟳ hara: this exact ${name} call has now FAILED ${s.fails}× with identical arguments — ` +
@@ -37,6 +53,9 @@ export function recordCall(name, input, content, isError = false) {
37
53
  `or step back and re-plan; if you're out of ideas, ask the user and say what you tried.`);
38
54
  }
39
55
  /** Clear the streaks — /reset (fresh start) and tests. */
40
- export function resetRepeatGuard() {
41
- seen.clear();
56
+ export function resetRepeatGuard(scope) {
57
+ if (scope)
58
+ seenByScope.delete(scope.trim() || DEFAULT_SCOPE);
59
+ else
60
+ seenByScope.clear();
42
61
  }