@nanhara/hara 0.122.3 → 0.122.5

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 (61) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +15 -2
  3. package/SECURITY.md +4 -0
  4. package/dist/agent/limits.js +51 -0
  5. package/dist/agent/loop.js +367 -112
  6. package/dist/agent/repeat-guard.js +49 -12
  7. package/dist/checkpoints.js +9 -0
  8. package/dist/cli.js +2 -1
  9. package/dist/config.js +10 -2
  10. package/dist/context/agents-md.js +21 -2
  11. package/dist/context/mentions.js +84 -1
  12. package/dist/context/workspace-scope.js +49 -0
  13. package/dist/cron/deliver.js +61 -38
  14. package/dist/cron/runner.js +423 -65
  15. package/dist/cron/schedule.js +23 -7
  16. package/dist/cron/store.js +709 -43
  17. package/dist/fs-walk.js +279 -7
  18. package/dist/fs-write.js +9 -0
  19. package/dist/gateway/feishu.js +351 -64
  20. package/dist/gateway/flows-pending.js +28 -14
  21. package/dist/gateway/flows.js +306 -31
  22. package/dist/gateway/outbound-files.js +20 -6
  23. package/dist/gateway/runtime-state.js +1485 -0
  24. package/dist/gateway/serve.js +550 -242
  25. package/dist/gateway/telegram.js +279 -29
  26. package/dist/gateway/tts.js +182 -38
  27. package/dist/hooks.js +22 -22
  28. package/dist/index.js +466 -158
  29. package/dist/memory/store.js +8 -5
  30. package/dist/org/planner.js +11 -6
  31. package/dist/process-identity.js +52 -0
  32. package/dist/providers/bounded-turn.js +42 -0
  33. package/dist/recall.js +69 -1
  34. package/dist/runtime.js +24 -0
  35. package/dist/sandbox.js +54 -4
  36. package/dist/search/embed.js +13 -2
  37. package/dist/search/hybrid.js +6 -3
  38. package/dist/search/semindex.js +87 -5
  39. package/dist/security/guardian.js +3 -15
  40. package/dist/security/permissions.js +11 -0
  41. package/dist/serve/server.js +70 -42
  42. package/dist/serve/sessions.js +4 -3
  43. package/dist/tools/agent.js +1 -1
  44. package/dist/tools/ask_user.js +5 -1
  45. package/dist/tools/builtin.js +5 -2
  46. package/dist/tools/codebase.js +67 -18
  47. package/dist/tools/computer.js +149 -68
  48. package/dist/tools/cron.js +72 -18
  49. package/dist/tools/edit.js +3 -2
  50. package/dist/tools/external_agent.js +66 -12
  51. package/dist/tools/memory.js +25 -7
  52. package/dist/tools/patch.js +11 -2
  53. package/dist/tools/registry.js +16 -1
  54. package/dist/tools/search.js +68 -9
  55. package/dist/tools/send.js +1 -1
  56. package/dist/tools/skill.js +1 -1
  57. package/dist/tools/web.js +43 -13
  58. package/dist/tui/App.js +93 -25
  59. package/dist/vision.js +5 -6
  60. package/package.json +3 -3
  61. package/runtime-bootstrap.cjs +22 -0
@@ -8,7 +8,8 @@ import { runHooks } from "../hooks.js";
8
8
  import { mapLimit, maxParallel } from "../concurrency.js";
9
9
  import { decideCommand, loadPermissionRules } from "../security/permissions.js";
10
10
  import { classifyRisk, guardianVeto, guardianEnabled, newBreaker, recordBlock } from "../security/guardian.js";
11
- import { recordCall } from "./repeat-guard.js";
11
+ import { keyOf, looksFailed, recordCall } from "./repeat-guard.js";
12
+ import { agentMaxRounds, agentRunTimeoutMs, formatAgentDuration } from "./limits.js";
12
13
  import { subdirHint } from "../context/subdir-hints.js";
13
14
  import { classifyError, failoverAction, errorHint } from "./failover.js";
14
15
  import { currentTodos, renderTodos } from "../tools/todo.js";
@@ -135,9 +136,114 @@ function composeSystem(cwd, projectContext, override, memory) {
135
136
  (memory ? `\n\n# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "") +
136
137
  (skills ? `\n\n# Skills (capabilities you can load — call the \`skill\` tool with the id for full instructions before using one)\n${skills}` : ""));
137
138
  }
139
+ const RUN_STOPPED = Symbol("agent-run-stopped");
140
+ const REPEATED_FAILURE_LIMIT = 3;
141
+ function showRunNotice(opts, message, critical = false) {
142
+ if (opts.quiet)
143
+ return;
144
+ if (opts.ctx.ui)
145
+ opts.ctx.ui.notice(message);
146
+ else {
147
+ try {
148
+ const rendered = process.stderr.isTTY ? (critical ? c.red(message) : c.yellow(message)) : message;
149
+ process.stderr.write(rendered + "\n");
150
+ }
151
+ catch {
152
+ /* diagnostics must never break lifecycle enforcement */
153
+ }
154
+ }
155
+ }
156
+ function warnRun(opts, life) {
157
+ if (life.disposed || life.warned || life.signal.aborted)
158
+ return;
159
+ life.warned = true;
160
+ showRunNotice(opts, `⚠ agent still running: ${formatAgentDuration(Date.now() - life.startedAt)} elapsed, round ${life.rounds}/${life.maxRounds}; hard stop at ${formatAgentDuration(life.timeoutMs)}.`);
161
+ }
162
+ function createRunLifecycle(opts) {
163
+ const timeoutMs = agentRunTimeoutMs(opts.timeoutMs);
164
+ const maxRounds = agentMaxRounds(opts.maxRounds);
165
+ const timeoutController = new AbortController();
166
+ const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutController.signal]) : timeoutController.signal;
167
+ const startedAt = Date.now();
168
+ const life = {};
169
+ const timeoutTimer = setTimeout(() => {
170
+ if (life.disposed || signal.aborted)
171
+ return;
172
+ life.timedOut = true;
173
+ timeoutController.abort(new Error("agent run deadline reached"));
174
+ }, timeoutMs);
175
+ // This timer is the hard boundary for a provider/tool Promise that owns no event-loop handles. Keep it
176
+ // referenced while the run is active; unref would let headless Node exit with the run still unresolved.
177
+ // Long legitimate work gets an in-band heads-up; a fast active loop gets the same warning at 75% rounds.
178
+ const warningDelay = Math.min(5 * 60_000, Math.max(250, Math.floor(timeoutMs * 0.8)));
179
+ const warningTimer = setTimeout(() => warnRun(opts, life), warningDelay);
180
+ warningTimer.unref?.();
181
+ let removeStopListener = () => { };
182
+ const stopPromise = new Promise((resolveStopped) => {
183
+ const stopped = () => resolveStopped(RUN_STOPPED);
184
+ removeStopListener = () => signal.removeEventListener("abort", stopped);
185
+ if (signal.aborted)
186
+ stopped();
187
+ else
188
+ signal.addEventListener("abort", stopped, { once: true });
189
+ });
190
+ Object.assign(life, {
191
+ signal,
192
+ timeoutController,
193
+ timeoutTimer,
194
+ warningTimer,
195
+ stopPromise,
196
+ removeStopListener,
197
+ startedAt,
198
+ timeoutMs,
199
+ maxRounds,
200
+ rounds: 0,
201
+ timedOut: false,
202
+ warned: false,
203
+ limitAnnounced: false,
204
+ disposed: false,
205
+ failedCalls: new Map(),
206
+ });
207
+ return life;
208
+ }
209
+ function disposeRunLifecycle(life) {
210
+ life.disposed = true;
211
+ clearTimeout(life.timeoutTimer);
212
+ clearTimeout(life.warningTimer);
213
+ life.removeStopListener();
214
+ }
215
+ function hardStop(opts, life, kind, detail) {
216
+ const elapsedMs = Date.now() - life.startedAt;
217
+ const message = kind === "deadline"
218
+ ? `⛔ agent run stopped: total deadline ${formatAgentDuration(life.timeoutMs)} reached after ${life.rounds} round(s). No further model or tool calls will start. Increase it with \`hara config set runTimeoutMs 45m\` (maximum 2h) only for intentional long work.`
219
+ : kind === "max_rounds"
220
+ ? `⛔ agent run stopped: ${life.maxRounds}-round safety limit reached after ${formatAgentDuration(elapsedMs)}. This usually means the model is looping. Increase it with \`hara config set maxAgentRounds <n>\` (maximum 256) only if the extra rounds are intentional.`
221
+ : `⛔ agent run stopped: the same failing ${detail?.tool ?? "tool"} call repeated ${detail?.count ?? REPEATED_FAILURE_LIMIT} times. Change the approach or fix the reported cause before retrying.`;
222
+ const event = { kind, message, elapsedMs, rounds: life.rounds, timeoutMs: life.timeoutMs, maxRounds: life.maxRounds };
223
+ if (!life.limitAnnounced) {
224
+ life.limitAnnounced = true;
225
+ showRunNotice(opts, message, true);
226
+ try {
227
+ opts.onLimit?.(event);
228
+ }
229
+ catch { /* observers cannot weaken the hard stop */ }
230
+ }
231
+ return { status: "halted", error: message, stopReason: kind };
232
+ }
138
233
  /** Provider-agnostic agentic loop. Mutates `history` in place. */
139
234
  export async function runAgent(history, opts) {
235
+ const life = createRunLifecycle(opts);
236
+ try {
237
+ return await runAgentInner(history, opts, life);
238
+ }
239
+ finally {
240
+ disposeRunLifecycle(life);
241
+ }
242
+ }
243
+ async function runAgentInner(history, opts, life) {
140
244
  const { provider, ctx } = opts;
245
+ const runSignal = life.signal;
246
+ const toolCtx = { ...ctx, signal: runSignal };
141
247
  const permRules = loadPermissionRules(ctx.cwd); // command-level allow/ask/deny policy for the bash tool
142
248
  let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
143
249
  let triedFallback = false;
@@ -152,6 +258,21 @@ export async function runAgent(history, opts) {
152
258
  }
153
259
  return { status: "error", error: msg };
154
260
  };
261
+ const stoppedOutcome = () => life.timedOut
262
+ ? hardStop(opts, life, "deadline")
263
+ : interruptedOutcome();
264
+ const bounded = (promise) => {
265
+ if (runSignal.aborted)
266
+ return Promise.resolve(RUN_STOPPED);
267
+ return Promise.race([promise, life.stopPromise]);
268
+ };
269
+ const interactionFailure = (label, error) => {
270
+ const raw = error instanceof Error ? error.message : String(error ?? "unknown error");
271
+ const detail = redactSensitiveText(raw).text.trim().slice(0, 500) || "unknown error";
272
+ const message = `Interactive ${label} failed: ${detail}`;
273
+ showRunNotice(opts, message, true);
274
+ return { status: "error", error: message };
275
+ };
155
276
  // Warn at the interaction boundary without echoing the value. Headless/gateway stdout is the response
156
277
  // transport, so keep the banner to interactive surfaces; persistence is still redacted everywhere.
157
278
  const latestUser = [...history].reverse().find((m) => m.role === "user");
@@ -179,10 +300,32 @@ export async function runAgent(history, opts) {
179
300
  // unfinished items exist. Main loop only — quiet (sub-agent) runs share the global list and must not nag.
180
301
  let todoIdleRounds = 0;
181
302
  for (;;) {
303
+ // A cancellation that already happened is authoritative: do not start pending-input work, a provider
304
+ // request, or any later tool round merely to give it an already-aborted signal.
305
+ if (runSignal.aborted)
306
+ return stoppedOutcome();
307
+ if (life.rounds >= life.maxRounds)
308
+ return hardStop(opts, life, "max_rounds");
309
+ life.rounds += 1;
310
+ if (life.rounds >= Math.ceil(life.maxRounds * 0.75))
311
+ warnRun(opts, life);
182
312
  // Type-ahead steering: fold in anything the user submitted while the previous step ran, so it
183
313
  // reaches the model on this next call (drained after the last tool round; empty on the 1st pass).
184
- if (opts.pendingInput) {
185
- for (const m of await opts.pendingInput())
314
+ if (opts.pendingInput && !runSignal.aborted) {
315
+ let pending;
316
+ try {
317
+ // Defer the callback by one microtask so a synchronous throw follows the same explicit error path
318
+ // as a rejected Promise instead of escaping runAgent and leaving the caller to guess what failed.
319
+ pending = await bounded(Promise.resolve().then(() => opts.pendingInput()));
320
+ }
321
+ catch (error) {
322
+ if (runSignal.aborted)
323
+ return stoppedOutcome();
324
+ return interactionFailure("pending-input channel", error);
325
+ }
326
+ if (pending === RUN_STOPPED)
327
+ return stoppedOutcome();
328
+ for (const m of pending)
186
329
  history.push(m);
187
330
  }
188
331
  // system-reminder injection: event-driven context queued since the last call (todo staleness today)
@@ -230,17 +373,17 @@ export async function runAgent(history, opts) {
230
373
  }, 100);
231
374
  }
232
375
  // Stall watchdog: any stream event resets the clock; STALL_MS of silence aborts THIS attempt via
233
- // its own controller (the user's opts.signal chains into it, so Esc still interrupts). The abort
376
+ // its own controller (the combined run signal chains into it, so Esc/deadline both interrupt). The abort
234
377
  // is then rewritten from "interrupted" to a timeout-class error so failover can take over.
235
378
  const STALL_MS = stallMs();
236
379
  const attempt = new AbortController();
237
- const onUserAbort = () => attempt.abort();
380
+ const onRunAbort = () => attempt.abort();
238
381
  // AbortSignal does not replay an already-fired event to a late listener. A serve shutdown can cancel
239
382
  // while provider routing is still refreshing, so inherit that state synchronously before the call.
240
- if (opts.signal?.aborted)
383
+ if (runSignal.aborted)
241
384
  attempt.abort();
242
385
  else
243
- opts.signal?.addEventListener("abort", onUserAbort, { once: true });
386
+ runSignal.addEventListener("abort", onRunAbort, { once: true });
244
387
  let lastEvent = Date.now();
245
388
  let stalled = false;
246
389
  const stallTimer = setInterval(() => {
@@ -270,92 +413,109 @@ export async function runAgent(history, opts) {
270
413
  else
271
414
  attempt.signal.addEventListener("abort", onAttemptStop, { once: true });
272
415
  });
273
- const providerTurn = activeProvider.turn({
274
- system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
275
- history,
276
- tools: specs,
277
- // Any stream chunk keeps the connection considered alive — even suppressed reasoning_content, so a
278
- // reasoning model thinking for a long while before its first `content` token can't be false-timed-out.
279
- onActivity: () => {
280
- if (attempt.signal.aborted)
281
- return;
282
- lastEvent = Date.now();
283
- },
284
- onText: (d) => {
285
- if (attempt.signal.aborted)
286
- return;
287
- alive();
288
- if (opts.quiet)
289
- return;
290
- if (sink) {
291
- sink.text(d);
292
- return;
293
- }
294
- stopSpin();
295
- flushReasoningTail();
296
- if (md)
297
- md.push(d);
298
- else
299
- out(d);
300
- },
301
- onReasoning: sink || tty
302
- ? (d) => {
416
+ // Enter through a microtask so a cancellation/deadline that lands during routing/setup is observed
417
+ // immediately before the provider side effect starts. Promise.resolve(activeProvider.turn(...)) is
418
+ // insufficient here: a custom provider can throw synchronously before Promise.resolve ever sees it.
419
+ const providerTurn = Promise.resolve().then(() => {
420
+ if (attempt.signal.aborted || runSignal.aborted) {
421
+ return { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
422
+ }
423
+ return activeProvider.turn({
424
+ system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
425
+ history,
426
+ tools: specs,
427
+ // Any stream chunk keeps the connection considered alive — even suppressed reasoning_content, so a
428
+ // reasoning model thinking for a long while before its first `content` token can't be false-timed-out.
429
+ onActivity: () => {
430
+ if (attempt.signal.aborted)
431
+ return;
432
+ lastEvent = Date.now();
433
+ },
434
+ onText: (d) => {
303
435
  if (attempt.signal.aborted)
304
436
  return;
305
437
  alive();
306
438
  if (opts.quiet)
307
439
  return;
308
440
  if (sink) {
309
- sink.reasoning(d);
441
+ sink.text(d);
310
442
  return;
311
443
  }
312
- // Terminal mode: render reasoning on its own dim lines (prefix `│ ` per line). Each
313
- // line is committed once and never overwritten — so a subsequent spinner tick can't
314
- // clobber it (the old `out(c.dim(d))` bug). Multi-line deltas split cleanly; the
315
- // current line resumes mid-output when the next delta arrives.
316
444
  stopSpin();
317
- const lines = d.split("\n");
318
- for (let i = 0; i < lines.length; i++) {
319
- if (!reasoningOpen) {
320
- out(c.dim("│ "));
321
- reasoningOpen = true;
445
+ flushReasoningTail();
446
+ if (md)
447
+ md.push(d);
448
+ else
449
+ out(d);
450
+ },
451
+ onReasoning: sink || tty
452
+ ? (d) => {
453
+ if (attempt.signal.aborted)
454
+ return;
455
+ alive();
456
+ if (opts.quiet)
457
+ return;
458
+ if (sink) {
459
+ sink.reasoning(d);
460
+ return;
322
461
  }
323
- out(c.dim(lines[i]));
324
- if (i < lines.length - 1) {
325
- out("\n");
326
- reasoningOpen = false;
462
+ // Terminal mode: render reasoning on its own dim lines (prefix `│ ` per line). Each
463
+ // line is committed once and never overwritten — so a subsequent spinner tick can't
464
+ // clobber it (the old `out(c.dim(d))` bug). Multi-line deltas split cleanly; the
465
+ // current line resumes mid-output when the next delta arrives.
466
+ stopSpin();
467
+ const lines = d.split("\n");
468
+ for (let i = 0; i < lines.length; i++) {
469
+ if (!reasoningOpen) {
470
+ out(c.dim("│ "));
471
+ reasoningOpen = true;
472
+ }
473
+ out(c.dim(lines[i]));
474
+ if (i < lines.length - 1) {
475
+ out("\n");
476
+ reasoningOpen = false;
477
+ }
327
478
  }
328
479
  }
329
- }
330
- : () => {
331
- if (!attempt.signal.aborted)
332
- alive();
333
- }, // quiet runs still feed the watchdog (reasoning-only stretches are progress)
334
- signal: attempt.signal,
480
+ : () => {
481
+ if (!attempt.signal.aborted)
482
+ alive();
483
+ }, // quiet runs still feed the watchdog (reasoning-only stretches are progress)
484
+ signal: attempt.signal,
485
+ });
335
486
  });
336
487
  opts.onProviderTurn?.(providerTurn);
337
488
  r = await Promise.race([providerTurn, attemptStopped]);
338
489
  }
339
490
  catch (error) {
340
- if (!opts.signal?.aborted)
341
- throw error;
342
- r = { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
491
+ // Provider launch/stream failures are turn results, not uncaught loop exceptions. This preserves the
492
+ // normal classifyError → fallback path for both synchronous throws and rejected provider promises.
493
+ r = runSignal.aborted
494
+ ? { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" }
495
+ : {
496
+ text: "",
497
+ toolUses: [],
498
+ stop: "error",
499
+ errorMsg: error instanceof Error ? error.message : String(error),
500
+ };
343
501
  }
344
502
  finally {
345
503
  clearInterval(stallTimer);
346
504
  removeAttemptStop();
347
- opts.signal?.removeEventListener("abort", onUserAbort);
505
+ runSignal.removeEventListener("abort", onRunAbort);
506
+ // Every exit path (sync throw, rejected promise, watchdog, Esc, deadline) owns the same terminal
507
+ // teardown. Leaving any of these after the try makes a failed provider strand the spinner/markdown.
508
+ stopSpin();
509
+ flushReasoningTail();
510
+ md?.end();
511
+ if (!opts.quiet && !sink)
512
+ out("\n");
348
513
  }
349
514
  // A watchdog abort surfaces from the provider as "interrupted" — rewrite it to a timeout-class
350
515
  // error (unless the USER really did interrupt) so classifyError → failover/fallback handles it.
351
- if (stalled && r.stop === "error" && !opts.signal?.aborted) {
516
+ if (stalled && r.stop === "error" && !runSignal.aborted) {
352
517
  r = { ...r, errorMsg: `model stream timeout — no output for ${Math.round(STALL_MS / 1000)}s (stalled connection?)` };
353
518
  }
354
- stopSpin();
355
- flushReasoningTail();
356
- md?.end();
357
- if (!opts.quiet && !sink)
358
- out("\n");
359
519
  if (r.usage && opts.stats) {
360
520
  opts.stats.input += r.usage.input;
361
521
  opts.stats.output += r.usage.output;
@@ -363,8 +523,8 @@ export async function runAgent(history, opts) {
363
523
  }
364
524
  // A provider may ignore AbortSignal and return a perfectly valid-looking tool_use after cancellation.
365
525
  // The original run signal is authoritative: do not append/approve/execute any late response.
366
- if (opts.signal?.aborted)
367
- return interruptedOutcome();
526
+ if (runSignal.aborted)
527
+ return stoppedOutcome();
368
528
  history.push({ role: "assistant", text: r.text, toolUses: r.toolUses });
369
529
  if (r.stop === "error") {
370
530
  const kind = classifyError(r.errorMsg ?? "");
@@ -440,26 +600,64 @@ export async function runAgent(history, opts) {
440
600
  // while planning, approving, or executing, so finalize the round with real results for work that already
441
601
  // completed and explicit interruption errors for everything else before persisting the session.
442
602
  const results = new Array(r.toolUses.length);
443
- const finalizeInterruptedToolRound = () => {
603
+ const finalizeStoppedToolRound = () => {
604
+ const pendingMessage = life.timedOut
605
+ ? `Error: agent run deadline ${formatAgentDuration(life.timeoutMs)} reached before this tool call completed.`
606
+ : "Error: interrupted before this tool call completed.";
607
+ history.push({
608
+ role: "tool",
609
+ results: r.toolUses.map((tu, idx) => results[idx] ?? ({
610
+ id: tu.id,
611
+ name: tu.name,
612
+ content: pendingMessage,
613
+ isError: true,
614
+ })),
615
+ });
616
+ return stoppedOutcome();
617
+ };
618
+ const finalizeInteractionError = (label, error) => {
619
+ const outcome = interactionFailure(label, error);
620
+ const pendingMessage = `Error: ${outcome.error}. This tool call was not executed.`;
444
621
  history.push({
445
622
  role: "tool",
446
623
  results: r.toolUses.map((tu, idx) => results[idx] ?? ({
447
624
  id: tu.id,
448
625
  name: tu.name,
449
- content: "Error: interrupted before this tool call completed.",
626
+ content: pendingMessage,
450
627
  isError: true,
451
628
  })),
452
629
  });
453
- return interruptedOutcome();
630
+ return outcome;
631
+ };
632
+ if (runSignal.aborted)
633
+ return finalizeStoppedToolRound();
634
+ let repeatHalt = null;
635
+ const noteCall = (name, input, content, isError = false) => {
636
+ const note = recordCall(name, input, content, isError, ctx.todoScope);
637
+ const key = keyOf(name, input);
638
+ if (isError || looksFailed(content, name)) {
639
+ const count = (life.failedCalls.get(key) ?? 0) + 1;
640
+ // This is a *consecutive no-progress* streak, not a lifetime counter for the call. A different
641
+ // failure is a changed attempt; keep only that new streak instead of letting an old failure
642
+ // silently accumulate across intervening work.
643
+ life.failedCalls.clear();
644
+ life.failedCalls.set(key, count);
645
+ if (count >= REPEATED_FAILURE_LIMIT && !repeatHalt)
646
+ repeatHalt = { tool: name, count };
647
+ }
648
+ else {
649
+ // Any successful action is progress (in particular edit/exec calls that may have fixed the
650
+ // underlying cause), so a later retry starts a fresh failure streak.
651
+ life.failedCalls.clear();
652
+ }
653
+ return note;
454
654
  };
455
- if (opts.signal?.aborted)
456
- return finalizeInterruptedToolRound();
457
655
  const plans = [];
458
656
  // Extra (per-run) tools win over the registry so a run-scoped tool can't be shadowed by a global one.
459
657
  const resolveTool = (name) => opts.extraTools?.find((t) => t.name === name) ?? getTool(name);
460
658
  for (const tu of r.toolUses) {
461
- if (opts.signal?.aborted)
462
- return finalizeInterruptedToolRound();
659
+ if (runSignal.aborted)
660
+ return finalizeStoppedToolRound();
463
661
  if (breakerHalt) {
464
662
  // Circuit-breaker halted the run: refuse every remaining call in this round with a clear message
465
663
  // (no hang, no further tools) so the model + user get a definitive stop.
@@ -503,9 +701,18 @@ export async function runAgent(history, opts) {
503
701
  if (risk.level === "high") {
504
702
  const safeRiskReason = redactToolSubprocessOutput(risk.reason);
505
703
  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();
704
+ let verdictResult;
705
+ try {
706
+ verdictResult = await bounded(Promise.resolve().then(() => guardianVeto(opts.guardian.provider, { tool: tu.name, detail, classifierReason: safeRiskReason }, history, { signal: runSignal })));
707
+ }
708
+ catch (error) {
709
+ if (runSignal.aborted)
710
+ return finalizeStoppedToolRound();
711
+ return finalizeInteractionError("guardian check", error);
712
+ }
713
+ if (verdictResult === RUN_STOPPED)
714
+ return finalizeStoppedToolRound();
715
+ const verdict = verdictResult;
509
716
  if (verdict.decision === "block") {
510
717
  const tripped = recordBlock(breaker); // deterministic circuit-breaker: N blocks → hard stop
511
718
  plans.push({
@@ -526,11 +733,20 @@ export async function runAgent(history, opts) {
526
733
  // (gateway/cron/-p, where `confirm` is auto-yes and there's no real user), abort SAFELY —
527
734
  // never auto-continue past the breaker, and never hang.
528
735
  const interactive = !!ctx.ask;
529
- const cont = interactive
530
- ? await opts.confirm(`${c.red("⛔ guardian circuit-breaker")} — ${breaker.blocks} high-risk actions blocked this turn. Continue anyway?`)
531
- : false;
532
- if (opts.signal?.aborted)
533
- return finalizeInterruptedToolRound();
736
+ let contResult;
737
+ try {
738
+ contResult = interactive
739
+ ? await bounded(Promise.resolve().then(() => opts.confirm(`${c.red("⛔ guardian circuit-breaker")} — ${breaker.blocks} high-risk actions blocked this turn. Continue anyway?`, runSignal)))
740
+ : false;
741
+ }
742
+ catch (error) {
743
+ if (runSignal.aborted)
744
+ return finalizeStoppedToolRound();
745
+ return finalizeInteractionError("guardian confirmation", error);
746
+ }
747
+ if (contResult === RUN_STOPPED)
748
+ return finalizeStoppedToolRound();
749
+ const cont = contResult;
534
750
  if (cont === false) {
535
751
  breakerHalt = true;
536
752
  }
@@ -545,9 +761,18 @@ export async function runAgent(history, opts) {
545
761
  }
546
762
  const shouldConfirm = alwaysGate || (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && !opts.autoApprove?.has(tu.name));
547
763
  if (shouldConfirm) {
548
- const reply = await opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`);
549
- if (opts.signal?.aborted)
550
- return finalizeInterruptedToolRound();
764
+ let replyResult;
765
+ try {
766
+ replyResult = await bounded(Promise.resolve().then(() => opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`, runSignal)));
767
+ }
768
+ catch (error) {
769
+ if (runSignal.aborted)
770
+ return finalizeStoppedToolRound();
771
+ return finalizeInteractionError("approval prompt", error);
772
+ }
773
+ if (replyResult === RUN_STOPPED)
774
+ return finalizeStoppedToolRound();
775
+ const reply = replyResult;
551
776
  if (reply === false) {
552
777
  plans.push({ tu, tool, denied: "User denied this action." });
553
778
  continue;
@@ -565,13 +790,22 @@ export async function runAgent(history, opts) {
565
790
  }
566
791
  }
567
792
  // Execute: read-only tools run concurrently; edit/exec run alone, in order.
568
- if (opts.signal?.aborted)
569
- return finalizeInterruptedToolRound();
793
+ if (runSignal.aborted)
794
+ return finalizeStoppedToolRound();
570
795
  const runOne = async (idx, p) => {
571
- if (opts.signal?.aborted)
796
+ if (runSignal.aborted)
797
+ return;
798
+ if (repeatHalt) {
799
+ results[idx] = { id: p.tu.id, name: p.tu.name, content: "Error: not executed because the repeated-failure circuit-breaker stopped this run.", isError: true };
572
800
  return;
801
+ }
573
802
  if (p.denied !== undefined) {
574
- results[idx] = { id: p.tu.id, name: p.tu.name, content: p.denied, isError: true };
803
+ results[idx] = {
804
+ id: p.tu.id,
805
+ name: p.tu.name,
806
+ content: p.denied + noteCall(p.tu.name, p.tu.input, p.denied, true),
807
+ isError: true,
808
+ };
575
809
  return;
576
810
  }
577
811
  activity.inc();
@@ -583,42 +817,59 @@ export async function runAgent(history, opts) {
583
817
  if (missing.length) {
584
818
  const msg = `Error: tool call NOT executed — missing required parameter${missing.length > 1 ? "s" : ""}: ` +
585
819
  `${missing.join(", ")}. Send the call again with ALL required parameters (${(p.tool.input_schema.required ?? []).join(", ")}) present and complete.`;
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 };
820
+ results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + noteCall(p.tu.name, p.tu.input, msg, true), isError: true };
587
821
  return;
588
822
  }
589
- if (opts.signal?.aborted)
823
+ if (runSignal.aborted)
590
824
  return;
591
825
  const pre = opts.hooks === false
592
826
  ? { block: false, message: "" }
593
- : runHooks("PreToolUse", p.tu.name, p.tu.input, ctx.cwd); // a hook may veto the call
827
+ : await runHooks("PreToolUse", p.tu.name, p.tu.input, ctx.cwd, 30_000, runSignal); // a hook may veto the call
594
828
  if (pre.block) {
595
- results[idx] = { id: p.tu.id, name: p.tu.name, content: pre.message, isError: true };
829
+ results[idx] = { id: p.tu.id, name: p.tu.name, content: pre.message + noteCall(p.tu.name, p.tu.input, pre.message, true), isError: true };
596
830
  return;
597
831
  }
598
- if (opts.signal?.aborted)
832
+ if (runSignal.aborted)
599
833
  return;
600
834
  // Track the MAIN conversation's working files for post-compaction restore (quiet fan-out
601
835
  // sub-agents read broadly — their files aren't "what the user was working on").
602
836
  if (!opts.quiet && FILE_TOUCH_TOOLS.has(p.tu.name) && typeof p.tu.input?.path === "string") {
603
837
  recordTouch(resolvePath(ctx.cwd, String(p.tu.input.path)), ctx.todoScope);
604
838
  }
605
- const res = await p.tool.run(p.tu.input, ctx);
839
+ // If a tool completes a side effect and aborts the parent synchronously, preserve that real result.
840
+ // A plain Promise.race can let the abort branch win the same microtask turn and falsely report the
841
+ // completed action as not run. Non-cooperative pending tools still lose to the hard stop immediately.
842
+ let settled;
843
+ const observedTool = p.tool.run(p.tu.input, toolCtx).then((value) => { settled = { ok: true, value }; return value; }, (error) => { settled = { ok: false, error }; throw error; });
844
+ try {
845
+ opts.onToolRun?.(observedTool, { name: p.tool.name, kind: p.tool.kind });
846
+ }
847
+ catch { /* observers cannot affect execution */ }
848
+ const toolResult = await bounded(observedTool);
849
+ if (toolResult === RUN_STOPPED) {
850
+ await Promise.resolve(); // allow an already-completed async tool's fulfillment handler to publish
851
+ if (!settled)
852
+ return;
853
+ if (!settled.ok)
854
+ throw settled.error;
855
+ }
856
+ const res = toolResult === RUN_STOPPED ? settled.value : toolResult;
606
857
  // append any not-yet-seen subdirectory AGENTS.md/CLAUDE.md this call touched (monorepo-local conventions)
607
858
  // + the repeat-guard's anti-spinning note when this exact call keeps failing (repeat-guard.ts)
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) };
859
+ results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) + noteCall(p.tu.name, p.tu.input, res) };
609
860
  // The tool may have completed a side effect and then triggered/observed cancellation. Preserve its
610
861
  // actual result in the closing tool round, but do not run any post hook or later tool afterward.
611
- if (opts.signal?.aborted)
862
+ if (runSignal.aborted)
612
863
  return;
613
864
  if (opts.hooks !== false) {
614
- runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd); // observe-only
865
+ await runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd, 30_000, runSignal); // observe-only
615
866
  }
616
867
  }
617
868
  catch (e) {
618
- const msg = `Error: ${e.message}`;
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)
869
+ if (runSignal.aborted)
621
870
  return;
871
+ const msg = `Error: ${e.message}`;
872
+ results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + noteCall(p.tu.name, p.tu.input, msg, true), isError: true };
622
873
  }
623
874
  finally {
624
875
  activity.dec();
@@ -633,25 +884,29 @@ export async function runAgent(history, opts) {
633
884
  await mapLimit(idx, maxParallel(), (i) => runOne(i, plans[i])); // bounded fan-out (e.g. 20 parallel agents → 8 at a time)
634
885
  };
635
886
  for (let i = 0; i < plans.length; i++) {
636
- if (opts.signal?.aborted)
637
- return finalizeInterruptedToolRound();
887
+ if (runSignal.aborted)
888
+ return finalizeStoppedToolRound();
638
889
  const p = plans[i];
639
- if (p.denied === undefined && p.tool?.kind === "read") {
890
+ // ask_user is interaction-safe but not parallel-safe: the TUI deliberately owns one prompt slot.
891
+ // Flush other reads and ask sequentially so two questions cannot overwrite each other and hang.
892
+ if (p.denied === undefined && p.tool?.kind === "read" && p.tool.name !== "ask_user") {
640
893
  batch.push(i); // safe → accumulate to run concurrently
641
894
  }
642
895
  else {
643
896
  await flush(); // flush pending reads before an edit/exec
644
- if (opts.signal?.aborted)
645
- return finalizeInterruptedToolRound();
897
+ if (runSignal.aborted)
898
+ return finalizeStoppedToolRound();
646
899
  await runOne(i, p);
647
- if (opts.signal?.aborted)
648
- return finalizeInterruptedToolRound();
900
+ if (runSignal.aborted)
901
+ return finalizeStoppedToolRound();
649
902
  }
650
903
  }
651
904
  await flush();
652
- if (opts.signal?.aborted)
653
- return finalizeInterruptedToolRound();
905
+ if (runSignal.aborted)
906
+ return finalizeStoppedToolRound();
654
907
  history.push({ role: "tool", results });
908
+ if (repeatHalt)
909
+ return hardStop(opts, life, "repeat_loop", repeatHalt);
655
910
  // Synthesis nudge (CC's KN5, hara-shaped): a round that fanned out to several parallel agents just
656
911
  // produced N independent reports — remind the model to merge/reconcile them before acting, instead
657
912
  // of anchoring on whichever report happens to sit last in context.