@nanhara/hara 0.125.3 → 0.126.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 (43) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/README.md +10 -4
  3. package/SECURITY.md +7 -3
  4. package/dist/agent/limits.js +2 -2
  5. package/dist/agent/loop.js +270 -98
  6. package/dist/config.js +172 -4
  7. package/dist/gateway/flows-pending.js +14 -36
  8. package/dist/gateway/wecom.js +9 -1
  9. package/dist/index.js +372 -159
  10. package/dist/mcp/client.js +2 -0
  11. package/dist/plugins/manifest.js +317 -0
  12. package/dist/plugins/plugins.js +476 -139
  13. package/dist/providers/bounded-turn.js +6 -0
  14. package/dist/providers/factory.js +54 -0
  15. package/dist/providers/openai.js +6 -1
  16. package/dist/providers/registry.js +1 -0
  17. package/dist/providers/target.js +98 -0
  18. package/dist/security/guardian.js +6 -1
  19. package/dist/security/private-state.js +1 -1
  20. package/dist/security/secrets.js +19 -0
  21. package/dist/serve/protocol.js +7 -2
  22. package/dist/serve/server.js +139 -35
  23. package/dist/serve/sessions.js +11 -2
  24. package/dist/session/operation-drain.js +45 -0
  25. package/dist/tools/agent.js +1 -0
  26. package/dist/tools/all.js +1 -0
  27. package/dist/tools/ask_user.js +8 -3
  28. package/dist/tools/builtin.js +19 -1
  29. package/dist/tools/codebase.js +1 -0
  30. package/dist/tools/computer.js +1 -0
  31. package/dist/tools/cron.js +10 -0
  32. package/dist/tools/external_agent.js +1 -0
  33. package/dist/tools/memory.js +2 -0
  34. package/dist/tools/registry.js +95 -4
  35. package/dist/tools/result-limit.js +172 -2
  36. package/dist/tools/runtime.js +67 -0
  37. package/dist/tools/search.js +3 -0
  38. package/dist/tools/skill.js +1 -0
  39. package/dist/tools/task.js +5 -0
  40. package/dist/tools/todo.js +3 -2
  41. package/dist/tools/web.js +4 -0
  42. package/dist/tui/App.js +5 -1
  43. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
- import { getTool, toolSpecs, missingRequired } from "../tools/registry.js";
1
+ import { approvalKindForOperation, getTool, missingRequired, toolOperationTraits, toolSpecs, } from "../tools/registry.js";
2
+ import { limitToolResultBatch } from "../tools/result-limit.js";
2
3
  import { stdout } from "node:process";
3
4
  import { c, out } from "../ui.js";
4
5
  import { activity } from "../activity.js";
@@ -22,8 +23,12 @@ import { redactToolSubprocessOutput } from "../security/subprocess-env.js";
22
23
  import { prepareHistoryForModel } from "./context-budget.js";
23
24
  import { rolesDigest } from "../org/roles.js";
24
25
  import { applyTaskBrief } from "../session/task.js";
26
+ import { askUserTool } from "../tools/ask_user.js";
25
27
  /** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
26
28
  const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
29
+ /** Engine-owned, non-authority helpers. Role filters still govern every deferred target activated by
30
+ * tool_search; these two only reveal an allowed schema or page an already-redacted result. */
31
+ const RUNTIME_HELPER_TOOLS = new Set(["tool_search", "tool_result_read"]);
27
32
  /** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
28
33
  * stalled connection and aborted into the normal error→failover path — instead of hanging on
29
34
  * "working Ns" forever (the "pressed Enter, thought it failed" report). Generous default because
@@ -90,6 +95,9 @@ are disabled without an interactive approval channel unless the user launched wi
90
95
  HARA_ALLOW_TRUSTED_EXTENSIONS=1. Configured MCP servers stay stopped by default; when a task materially needs
91
96
  one, call \`mcp_connect\` for that server only, then use the newly available tools on the next round. Never
92
97
  connect every configured server speculatively.
98
+ Optional web, desktop, scheduler, external-agent, and connected-MCP schemas may be deferred to keep context
99
+ focused. If a needed capability is absent from the current tool list, call \`tool_search\` once with the
100
+ capability/service name; use the activated tool on the next round. Do not search speculatively.
93
101
  For broad,
94
102
  open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
95
103
  independent questions (role "explore") — each returns conclusions, not dumps. When specialist roles are
@@ -181,7 +189,7 @@ function composeSystem(cwd, projectContext, override, memory, continuationSessio
181
189
  const RUN_STOPPED = Symbol("agent-run-stopped");
182
190
  const REPEATED_FAILURE_LIMIT = 3;
183
191
  export function deadlineCheckpointReminder(timeoutMs) {
184
- return (`Turn budget checkpoint: about 20% remains before the ${formatAgentDuration(timeoutMs)} safety pause. ` +
192
+ return (`Turn active-execution budget checkpoint: about 20% remains before the ${formatAgentDuration(timeoutMs)} safety pause. ` +
185
193
  "Stop expanding scope. Finish only the current atomic step, persist any usable artifact, update todo_write, " +
186
194
  "and reply with the completed checkpoint plus the next exact step. Do not start another generation batch, " +
187
195
  "install, full validation suite, preview, render, deployment, or other multi-minute stage in this turn. " +
@@ -206,39 +214,149 @@ function requestRunCheckpoint(opts, life) {
206
214
  if (life.disposed || life.checkpointDue || life.signal.aborted)
207
215
  return;
208
216
  life.checkpointDue = true;
209
- const remainingMs = Math.max(0, life.timeoutMs - (Date.now() - life.startedAt));
210
- showRunNotice(opts, `⚠ agent turn nearing its safety pause: ${formatAgentDuration(remainingMs)} remains. The agent will be told to finish the current atomic step and checkpoint; use \`/continue\` for the next expensive stage.`);
217
+ const remainingMs = Math.max(0, life.timeoutMs - runActiveElapsedMs(life));
218
+ showRunNotice(opts, `⚠ active turn budget is 80% used: about ${formatAgentDuration(remainingMs)} of active execution remains. The agent will be told to finish the current atomic step and checkpoint; use \`/continue\` for the next expensive stage.`);
211
219
  }
212
220
  function warnRun(opts, life) {
213
221
  if (life.disposed || life.warned || life.signal.aborted)
214
222
  return;
215
223
  life.warned = true;
216
- const elapsedMs = Date.now() - life.startedAt;
224
+ const elapsedMs = runActiveElapsedMs(life);
217
225
  const remainingMs = Math.max(0, life.timeoutMs - elapsedMs);
218
- showRunNotice(opts, `⚠ agent still running: ${formatAgentDuration(elapsedMs)} elapsed, round ${life.rounds}/${life.maxRounds}; ${formatAgentDuration(remainingMs)} remains before this turn pauses. Finish the current step or leave a checklist checkpoint; unfinished session work can resume with \`/continue\`.`);
226
+ showRunNotice(opts, `⚠ agent is still actively working: ${formatAgentDuration(elapsedMs)} active execution elapsed, round ${life.rounds}/${life.maxRounds}; ${formatAgentDuration(remainingMs)} remains before this turn pauses. Finish the current step or leave a checklist checkpoint; unfinished session work can resume with \`/continue\`.`);
227
+ }
228
+ function runActiveElapsedMs(life) {
229
+ const current = life.activeStartedAt === null ? 0 : Date.now() - life.activeStartedAt;
230
+ return Math.max(0, life.activeElapsedMs + current);
231
+ }
232
+ function clearRunTimers(life) {
233
+ if (life.timeoutTimer)
234
+ clearTimeout(life.timeoutTimer);
235
+ if (life.warningTimer)
236
+ clearTimeout(life.warningTimer);
237
+ if (life.checkpointTimer)
238
+ clearTimeout(life.checkpointTimer);
239
+ life.timeoutTimer = null;
240
+ life.warningTimer = null;
241
+ life.checkpointTimer = null;
242
+ }
243
+ /** Timer callbacks are macrotasks. A synchronous provider can overrun the budget and return a tool request
244
+ * before that callback gets CPU, so every authority boundary also performs this synchronous check. */
245
+ function expireRunBudgetIfNeeded(life) {
246
+ if (life.signal.aborted)
247
+ return true;
248
+ if (life.disposed || life.pauseDepth > 0)
249
+ return false;
250
+ const elapsedMs = runActiveElapsedMs(life);
251
+ if (elapsedMs < life.timeoutMs)
252
+ return false;
253
+ life.activeElapsedMs = elapsedMs;
254
+ life.activeStartedAt = null;
255
+ clearRunTimers(life);
256
+ life.timedOut = true;
257
+ life.timeoutController.abort(new Error("agent active-execution deadline reached"));
258
+ return true;
259
+ }
260
+ /** Re-arm every threshold from the active clock. Human wait time is excluded, but active provider/tool
261
+ * promises remain bounded even when they ignore AbortSignal or own no event-loop handles. */
262
+ function armRunTimers(opts, life) {
263
+ clearRunTimers(life);
264
+ if (life.disposed || life.signal.aborted || life.pauseDepth > 0 || life.activeStartedAt === null)
265
+ return;
266
+ if (expireRunBudgetIfNeeded(life))
267
+ return;
268
+ const elapsedMs = runActiveElapsedMs(life);
269
+ const timeoutDelay = life.timeoutMs - elapsedMs;
270
+ life.timeoutTimer = setTimeout(() => {
271
+ if (life.disposed || life.signal.aborted || life.pauseDepth > 0)
272
+ return;
273
+ if (!expireRunBudgetIfNeeded(life))
274
+ armRunTimers(opts, life);
275
+ }, timeoutDelay);
276
+ // The hard timer stays referenced while the agent is actively executing. Human prompts have their own
277
+ // visible UI plus Esc/shutdown cancellation and deliberately do not keep this active budget running.
278
+ const warningAt = Math.min(5 * 60_000, Math.max(250, Math.floor(life.timeoutMs * 0.8)));
279
+ if (!life.warned) {
280
+ life.warningTimer = setTimeout(() => warnRun(opts, life), Math.max(0, warningAt - elapsedMs));
281
+ life.warningTimer.unref?.();
282
+ }
283
+ const checkpointAt = Math.max(250, Math.floor(life.timeoutMs * 0.8));
284
+ if (!life.checkpointDue) {
285
+ life.checkpointTimer = setTimeout(() => requestRunCheckpoint(opts, life), Math.max(0, checkpointAt - elapsedMs));
286
+ life.checkpointTimer.unref?.();
287
+ }
288
+ }
289
+ function pauseRunBudget(life) {
290
+ if (life.disposed || life.signal.aborted || expireRunBudgetIfNeeded(life))
291
+ return false;
292
+ life.pauseDepth += 1;
293
+ if (life.pauseDepth > 1)
294
+ return true;
295
+ life.activeElapsedMs = runActiveElapsedMs(life);
296
+ life.activeStartedAt = null;
297
+ clearRunTimers(life);
298
+ return true;
299
+ }
300
+ function resumeRunBudget(opts, life, paused) {
301
+ if (!paused || life.pauseDepth <= 0)
302
+ return;
303
+ life.pauseDepth -= 1;
304
+ if (life.pauseDepth > 0 || life.disposed || life.signal.aborted)
305
+ return;
306
+ life.activeStartedAt = Date.now();
307
+ if (expireRunBudgetIfNeeded(life))
308
+ return;
309
+ armRunTimers(opts, life);
310
+ }
311
+ /** Only engine-owned human interaction may suspend the active clock. A plugin cannot acquire this authority
312
+ * by labelling an arbitrary long-running operation "interactive". Parent Esc/shutdown cancellation remains
313
+ * connected through life.signal and dismisses the prompt immediately. */
314
+ async function withAbortSignal(signal, action) {
315
+ let removeAbort = () => { };
316
+ const aborted = new Promise((_resolve, reject) => {
317
+ const stop = () => reject(signal.reason ?? new Error("agent run interrupted"));
318
+ removeAbort = () => signal.removeEventListener("abort", stop);
319
+ if (signal.aborted)
320
+ stop();
321
+ else
322
+ signal.addEventListener("abort", stop, { once: true });
323
+ });
324
+ try {
325
+ const guardedAction = Promise.resolve().then(() => {
326
+ // Promise.race does not cancel its losing branch. Re-check inside the queued microtask so an Esc
327
+ // that lands after waitForHuman() is entered but before the UI callback runs cannot open a stale prompt.
328
+ if (signal.aborted)
329
+ throw signal.reason ?? new Error("agent run interrupted");
330
+ return action();
331
+ });
332
+ return await Promise.race([guardedAction, aborted]);
333
+ }
334
+ finally {
335
+ removeAbort();
336
+ }
337
+ }
338
+ async function waitForHuman(opts, life, action) {
339
+ const outermost = life.pauseDepth === 0;
340
+ const paused = pauseRunBudget(life);
341
+ if (!paused)
342
+ throw life.signal.reason ?? new Error("agent run ended before human interaction");
343
+ if (outermost && !opts.quiet)
344
+ setTurnPhase("awaiting_user");
345
+ try {
346
+ return await withAbortSignal(life.signal, action);
347
+ }
348
+ finally {
349
+ resumeRunBudget(opts, life, paused);
350
+ if (outermost && !opts.quiet)
351
+ setTurnPhase("streaming");
352
+ }
219
353
  }
220
354
  function createRunLifecycle(opts) {
221
355
  const timeoutMs = agentRunTimeoutMs(opts.timeoutMs);
222
356
  const maxRounds = agentMaxRounds(opts.maxRounds);
223
357
  const timeoutController = new AbortController();
224
358
  const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutController.signal]) : timeoutController.signal;
225
- const startedAt = Date.now();
226
- const life = {};
227
- const timeoutTimer = setTimeout(() => {
228
- if (life.disposed || signal.aborted)
229
- return;
230
- life.timedOut = true;
231
- timeoutController.abort(new Error("agent run deadline reached"));
232
- }, timeoutMs);
233
- // This timer is the hard boundary for a provider/tool Promise that owns no event-loop handles. Keep it
234
- // referenced while the run is active; unref would let headless Node exit with the run still unresolved.
235
- // Long legitimate work gets an in-band heads-up; a fast active loop gets the same warning at 75% rounds.
236
- const warningDelay = Math.min(5 * 60_000, Math.max(250, Math.floor(timeoutMs * 0.8)));
237
- const warningTimer = setTimeout(() => warnRun(opts, life), warningDelay);
238
- warningTimer.unref?.();
239
- const checkpointDelay = Math.max(250, Math.floor(timeoutMs * 0.8));
240
- const checkpointTimer = setTimeout(() => requestRunCheckpoint(opts, life), checkpointDelay);
241
- checkpointTimer.unref?.();
359
+ const activeStartedAt = Date.now();
242
360
  let removeStopListener = () => { };
243
361
  const stopPromise = new Promise((resolveStopped) => {
244
362
  const stopped = () => resolveStopped(RUN_STOPPED);
@@ -248,15 +366,17 @@ function createRunLifecycle(opts) {
248
366
  else
249
367
  signal.addEventListener("abort", stopped, { once: true });
250
368
  });
251
- Object.assign(life, {
369
+ const life = {
252
370
  signal,
253
371
  timeoutController,
254
- timeoutTimer,
255
- warningTimer,
256
- checkpointTimer,
372
+ timeoutTimer: null,
373
+ warningTimer: null,
374
+ checkpointTimer: null,
257
375
  stopPromise,
258
376
  removeStopListener,
259
- startedAt,
377
+ activeStartedAt,
378
+ activeElapsedMs: 0,
379
+ pauseDepth: 0,
260
380
  timeoutMs,
261
381
  maxRounds,
262
382
  rounds: 0,
@@ -267,20 +387,19 @@ function createRunLifecycle(opts) {
267
387
  limitAnnounced: false,
268
388
  disposed: false,
269
389
  failedCalls: new Map(),
270
- });
390
+ };
391
+ armRunTimers(opts, life);
271
392
  return life;
272
393
  }
273
394
  function disposeRunLifecycle(life) {
274
395
  life.disposed = true;
275
- clearTimeout(life.timeoutTimer);
276
- clearTimeout(life.warningTimer);
277
- clearTimeout(life.checkpointTimer);
396
+ clearRunTimers(life);
278
397
  life.removeStopListener();
279
398
  }
280
399
  function hardStop(opts, life, kind, detail) {
281
- const elapsedMs = Date.now() - life.startedAt;
400
+ const elapsedMs = runActiveElapsedMs(life);
282
401
  const message = kind === "deadline"
283
- ? `⏸ agent run paused: total deadline ${formatAgentDuration(life.timeoutMs)} reached after ${life.rounds} round(s). No further model or tool calls will start in this turn. Session-backed work keeps its task and checklist checkpoint; type \`/continue\` to resume in a fresh bounded turn. Only for intentionally long single turns, use \`hara config set runTimeoutMs 45m\` (maximum 2h).`
402
+ ? `⏸ agent run paused: active-execution deadline ${formatAgentDuration(life.timeoutMs)} reached after ${life.rounds} round(s). Waiting for your answers did not consume this budget. No further model or tool calls will start in this turn. Session-backed work keeps its task and checklist checkpoint; type \`/continue\` to resume in a fresh bounded turn. Only for intentionally long single turns, use \`hara config set runTimeoutMs 45m\` (maximum 2h).`
284
403
  : kind === "max_rounds"
285
404
  ? `⛔ 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.`
286
405
  : `⛔ 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.`;
@@ -308,7 +427,33 @@ export async function runAgent(history, opts) {
308
427
  async function runAgentInner(history, opts, life) {
309
428
  const { provider, ctx } = opts;
310
429
  const runSignal = life.signal;
311
- const toolCtx = { ...ctx, signal: runSignal };
430
+ const activatedDeferredTools = new Set();
431
+ const askWithRunCancellation = ctx.ask
432
+ ? (question, options, signal) => {
433
+ const combined = signal && signal !== runSignal
434
+ ? AbortSignal.any([runSignal, signal])
435
+ : runSignal;
436
+ return withAbortSignal(combined, () => ctx.ask(question, options, combined));
437
+ }
438
+ : undefined;
439
+ const toolCtx = {
440
+ ...ctx,
441
+ signal: runSignal,
442
+ ...(askWithRunCancellation ? { ask: askWithRunCancellation } : {}),
443
+ activateTools(names) {
444
+ const accepted = [];
445
+ for (const name of names) {
446
+ const tool = getTool(name);
447
+ if (!tool || tool.visibility !== "deferred")
448
+ continue;
449
+ if (opts.toolFilter && !opts.toolFilter(name))
450
+ continue;
451
+ activatedDeferredTools.add(name);
452
+ accepted.push(name);
453
+ }
454
+ return accepted;
455
+ },
456
+ };
312
457
  let intakeTask = opts.taskIntake?.task;
313
458
  let intakeDirty = false;
314
459
  const syncIntakeTask = () => {
@@ -338,6 +483,7 @@ async function runAgentInner(history, opts, life) {
338
483
  required: ["intent", "goal", "constraints", "acceptance", "steps"],
339
484
  },
340
485
  kind: "read",
486
+ classify: () => ({ effect: "state", concurrencySafe: false }),
341
487
  run: async (input) => {
342
488
  syncIntakeTask();
343
489
  const applied = applyTaskBrief(intakeTask, input);
@@ -418,14 +564,14 @@ async function runAgentInner(history, opts, life) {
418
564
  for (;;) {
419
565
  // A cancellation that already happened is authoritative: do not start pending-input work, a provider
420
566
  // request, or any later tool round merely to give it an already-aborted signal.
421
- if (runSignal.aborted)
567
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
422
568
  return stoppedOutcome();
423
569
  if (life.rounds >= life.maxRounds)
424
570
  return hardStop(opts, life, "max_rounds");
425
571
  life.rounds += 1;
426
572
  if (life.rounds >= Math.ceil(life.maxRounds * 0.75))
427
573
  warnRun(opts, life);
428
- if (Date.now() - life.startedAt >= Math.floor(life.timeoutMs * 0.8))
574
+ if (runActiveElapsedMs(life) >= Math.floor(life.timeoutMs * 0.8))
429
575
  requestRunCheckpoint(opts, life);
430
576
  if (!opts.quiet && life.checkpointDue && !life.checkpointInjected) {
431
577
  life.checkpointInjected = true;
@@ -464,7 +610,10 @@ async function runAgentInner(history, opts, life) {
464
610
  if (reminders.length)
465
611
  history.push({ role: "user", content: wrapReminders(reminders) });
466
612
  }
467
- const baseSpecs = opts.toolFilter ? toolSpecs().filter((t) => opts.toolFilter(t.name)) : toolSpecs();
613
+ const visibleSpecs = toolSpecs({ activatedDeferred: activatedDeferredTools });
614
+ const baseSpecs = opts.toolFilter
615
+ ? visibleSpecs.filter((t) => RUNTIME_HELPER_TOOLS.has(t.name) || opts.toolFilter(t.name))
616
+ : visibleSpecs;
468
617
  const specs = runExtraTools.length
469
618
  ? [...baseSpecs, ...runExtraTools.map((t) => ({ name: t.name, description: t.description, input_schema: t.input_schema }))]
470
619
  : baseSpecs;
@@ -560,7 +709,7 @@ async function runAgentInner(history, opts, life) {
560
709
  // immediately before the provider side effect starts. Promise.resolve(activeProvider.turn(...)) is
561
710
  // insufficient here: a custom provider can throw synchronously before Promise.resolve ever sees it.
562
711
  const providerTurn = Promise.resolve().then(() => {
563
- if (attempt.signal.aborted || runSignal.aborted) {
712
+ if (expireRunBudgetIfNeeded(life) || attempt.signal.aborted || runSignal.aborted) {
564
713
  return { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
565
714
  }
566
715
  return activeProvider.turn({
@@ -666,7 +815,7 @@ async function runAgentInner(history, opts, life) {
666
815
  }
667
816
  // A provider may ignore AbortSignal and return a perfectly valid-looking tool_use after cancellation.
668
817
  // The original run signal is authoritative: do not append/approve/execute any late response.
669
- if (runSignal.aborted)
818
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
670
819
  return stoppedOutcome();
671
820
  history.push({ role: "assistant", text: r.text, toolUses: r.toolUses });
672
821
  if (r.stop === "error") {
@@ -758,7 +907,7 @@ async function runAgentInner(history, opts, life) {
758
907
  const results = new Array(r.toolUses.length);
759
908
  const finalizeStoppedToolRound = () => {
760
909
  const pendingMessage = life.timedOut
761
- ? `Error: agent run deadline ${formatAgentDuration(life.timeoutMs)} reached before this tool call completed.`
910
+ ? `Error: agent active-execution deadline ${formatAgentDuration(life.timeoutMs)} reached before this tool call completed.`
762
911
  : "Error: interrupted before this tool call completed.";
763
912
  history.push({
764
913
  role: "tool",
@@ -785,7 +934,7 @@ async function runAgentInner(history, opts, life) {
785
934
  });
786
935
  return outcome;
787
936
  };
788
- if (runSignal.aborted)
937
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
789
938
  return finalizeStoppedToolRound();
790
939
  let repeatHalt = null;
791
940
  const noteCall = (name, input, content, isError = false) => {
@@ -810,14 +959,22 @@ async function runAgentInner(history, opts, life) {
810
959
  };
811
960
  const plans = [];
812
961
  // Extra (per-run) tools win over the registry so a run-scoped tool can't be shadowed by a global one.
813
- const resolveTool = (name) => runExtraTools.find((t) => t.name === name) ?? getTool(name);
962
+ const resolveTool = (name) => {
963
+ const extra = runExtraTools.find((tool) => tool.name === name);
964
+ if (extra)
965
+ return extra;
966
+ const registered = getTool(name);
967
+ if (registered?.visibility === "deferred" && !activatedDeferredTools.has(name))
968
+ return undefined;
969
+ return registered;
970
+ };
814
971
  // Planning happens before dispatch, so a previously accepted `change` brief must not let the model
815
972
  // revise that brief and perform a side effect in the same response. Treat every intake call as a
816
973
  // transaction boundary for the whole response: accept/checkpoint the interpretation first, then let
817
974
  // the next model round act against the newly authoritative brief.
818
975
  const taskBriefTransitionInRound = r.toolUses.some((tu) => tu.name === "task_intake");
819
976
  for (const tu of r.toolUses) {
820
- if (runSignal.aborted)
977
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
821
978
  return finalizeStoppedToolRound();
822
979
  if (breakerHalt) {
823
980
  // Circuit-breaker halted the run: refuse every remaining call in this round with a clear message
@@ -831,42 +988,42 @@ async function runAgentInner(history, opts, life) {
831
988
  continue;
832
989
  }
833
990
  const input = tu.input;
991
+ const command = tool.kind === "exec" && typeof input.command === "string" ? input.command : null;
992
+ let operation = toolOperationTraits(tool, input, toolCtx);
993
+ // One-release compatibility for embedders/older plugins that only implement static kind. New built-ins
994
+ // declare classify(); legacy command/action tools retain the established safe semantics until migrated.
995
+ if (!tool.classify) {
996
+ if (command !== null) {
997
+ const parts = splitCompound(command);
998
+ const readOnly = !Boolean(input.background)
999
+ && !!parts?.length
1000
+ && parts.every(isReadOnlyCommand);
1001
+ operation = readOnly
1002
+ ? { effect: "read", concurrencySafe: true }
1003
+ : { effect: "exec", concurrencySafe: false };
1004
+ }
1005
+ else if ((tool.name === "task" || tool.name === "cronjob") && input.action === "list") {
1006
+ operation = { effect: "read", concurrencySafe: true };
1007
+ }
1008
+ else if (tool.name === "job" && input.action === "kill") {
1009
+ operation = { effect: "exec", concurrencySafe: false, destructive: true };
1010
+ }
1011
+ }
1012
+ const approvalKind = approvalKindForOperation(operation);
834
1013
  const preview = redactToolSubprocessOutput(String(input.path ?? input.command ?? input.pattern ?? input.url ?? input.task ?? input.server ?? "")
835
1014
  .replace(/\s+/g, " ")
836
1015
  .trim());
837
- // Resolve shell read-only policy before the understanding gate: an `investigate` brief may run a
838
- // command already classified safe/read-only, while edits and unknown/non-read-only commands require
839
- // an explicit `change` brief. Opaque external tools always require a brief and retain their separate
840
- // per-action approval boundary.
841
- const command = tool.kind === "exec" && typeof input.command === "string" ? input.command : null;
1016
+ // Command rules apply only to the concrete shell tool. Input-level traits independently answer whether
1017
+ // this exact operation mutates state; an allow rule must never turn a mutation into an investigation.
842
1018
  const cmdDecision = command !== null ? decideCommand(command, permRules) : null;
843
- // Permission rules answer whether a command may auto-run; they do not answer whether it mutates state.
844
- // In particular, an explicit `allow: ["git commit"]` must not let an `investigate` brief perform a
845
- // commit. Classify read-only semantics independently for the understanding boundary.
846
- const commandParts = command !== null ? splitCompound(command) : null;
847
- const commandReadOnly = !!commandParts?.length && commandParts.every(isReadOnlyCommand);
848
- // Bash intentionally treats `background` as a boolean. Be conservative at the policy boundary even
849
- // for a malformed model call: any truthy value would otherwise reach a truthiness-based/custom exec
850
- // implementation as a process start and bypass an investigate brief.
851
- const startsBackgroundProcess = tool.kind === "exec" && Boolean(input.background);
852
- const stopsBackgroundProcess = tool.name === "job" && input.action === "kill";
853
1019
  if (opts.taskIntake && tool.name !== "task_intake") {
854
- // Some action-style tools contain both inspection and mutation behind one registry kind. Classify
855
- // the concrete operation, not only the approval class: listing tasks/cron jobs is evidence gathering,
856
- // while their other actions remain state changes.
857
- const readOnlyAction = (tool.name === "task" || tool.name === "cronjob") &&
858
- input.action === "list";
859
- const operationReadOnly = readOnlyAction ||
860
- (tool.kind === "read" && !stopsBackgroundProcess) ||
861
- (tool.kind === "exec" && commandReadOnly && !startsBackgroundProcess);
1020
+ const operationReadOnly = operation.effect === "read"
1021
+ || operation.effect === "state"
1022
+ || operation.effect === "interactive";
862
1023
  const needsBrief = tool.trustBoundary === "external" || !operationReadOnly;
863
1024
  const requiresChange = tool.trustBoundary !== "external" &&
864
1025
  !operationReadOnly &&
865
- (tool.kind === "edit" ||
866
- tool.kind === "computer" ||
867
- stopsBackgroundProcess ||
868
- startsBackgroundProcess ||
869
- (tool.kind === "exec" && !commandReadOnly));
1026
+ (operation.effect === "edit" || operation.effect === "exec" || operation.effect === "computer");
870
1027
  if (taskBriefTransitionInRound && (requiresChange || tool.trustBoundary === "external")) {
871
1028
  plans.push({
872
1029
  tu,
@@ -898,7 +1055,7 @@ async function runAgentInner(history, opts, life) {
898
1055
  }
899
1056
  // Screen control and opaque host extensions are gated on EVERY action — a prior "don't ask again"
900
1057
  // and even full-auto must never silently turn them into a side channel.
901
- const alwaysGate = tool.kind === "computer" || tool.trustBoundary === "external";
1058
+ const alwaysGate = approvalKind === "computer" || tool.trustBoundary === "external";
902
1059
  if (tool.trustBoundary === "external" && !ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
903
1060
  plans.push({
904
1061
  tu,
@@ -919,13 +1076,16 @@ async function runAgentInner(history, opts, life) {
919
1076
  // commands classify `low` (pure Node, no LLM) and skip everything below — zero added latency. Only a
920
1077
  // genuinely HIGH-RISK action pays for a cheap-model veto, and that veto fails OPEN on any glitch.
921
1078
  if (guardianOn && !breakerHalt) {
922
- const risk = classifyRisk(tu.name, tool.kind, input, ctx.cwd);
1079
+ const risk = classifyRisk(tu.name, approvalKind, input, ctx.cwd);
1080
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
1081
+ return finalizeStoppedToolRound();
923
1082
  if (risk.level === "high") {
924
1083
  const safeRiskReason = redactToolSubprocessOutput(risk.reason);
925
1084
  const detail = redactToolSubprocessOutput(String(input.command ?? input.path ?? "").replace(/\s+/g, " ").trim().slice(0, 400));
926
1085
  let verdictResult;
927
1086
  try {
928
- verdictResult = await bounded(Promise.resolve().then(() => guardianVeto(opts.guardian.provider, { tool: tu.name, detail, classifierReason: safeRiskReason }, history, { signal: runSignal })));
1087
+ const guardianTurn = Promise.resolve().then(() => guardianVeto(opts.guardian.provider, { tool: tu.name, detail, classifierReason: safeRiskReason }, history, { signal: runSignal, onProviderTurn: opts.onProviderTurn }));
1088
+ verdictResult = await bounded(guardianTurn);
929
1089
  }
930
1090
  catch (error) {
931
1091
  if (runSignal.aborted)
@@ -934,6 +1094,8 @@ async function runAgentInner(history, opts, life) {
934
1094
  }
935
1095
  if (verdictResult === RUN_STOPPED)
936
1096
  return finalizeStoppedToolRound();
1097
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
1098
+ return finalizeStoppedToolRound();
937
1099
  const verdict = verdictResult;
938
1100
  if (verdict.decision === "block") {
939
1101
  const tripped = recordBlock(breaker); // deterministic circuit-breaker: N blocks → hard stop
@@ -958,7 +1120,7 @@ async function runAgentInner(history, opts, life) {
958
1120
  let contResult;
959
1121
  try {
960
1122
  contResult = interactive
961
- ? await bounded(Promise.resolve().then(() => opts.confirm(`${c.red("⛔ guardian circuit-breaker")} — ${breaker.blocks} high-risk actions blocked this turn. Continue anyway?`, runSignal)))
1123
+ ? await bounded(waitForHuman(opts, life, () => Promise.resolve().then(() => opts.confirm(`${c.red("⛔ guardian circuit-breaker")} — ${breaker.blocks} high-risk actions blocked this turn. Continue anyway?`, runSignal))))
962
1124
  : false;
963
1125
  }
964
1126
  catch (error) {
@@ -981,11 +1143,11 @@ async function runAgentInner(history, opts, life) {
981
1143
  }
982
1144
  }
983
1145
  }
984
- const shouldConfirm = alwaysGate || (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && !opts.autoApprove?.has(tu.name));
1146
+ const shouldConfirm = alwaysGate || (cmdDecision !== "allow" && needsConfirm(approvalKind, opts.approval) && !opts.autoApprove?.has(tu.name));
985
1147
  if (shouldConfirm) {
986
1148
  let replyResult;
987
1149
  try {
988
- replyResult = await bounded(Promise.resolve().then(() => opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`, runSignal)));
1150
+ replyResult = await bounded(waitForHuman(opts, life, () => Promise.resolve().then(() => opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`, runSignal))));
989
1151
  }
990
1152
  catch (error) {
991
1153
  if (runSignal.aborted)
@@ -1002,7 +1164,7 @@ async function runAgentInner(history, opts, life) {
1002
1164
  if (reply === "always" && !alwaysGate)
1003
1165
  opts.autoApprove?.add(tu.name); // computer: treat "always" as one-time yes
1004
1166
  }
1005
- plans.push({ tu, tool });
1167
+ plans.push({ tu, tool, operation, approvalKind });
1006
1168
  if (!opts.quiet) {
1007
1169
  const pv = preview ? preview.slice(0, 80) : "";
1008
1170
  if (sink)
@@ -1012,10 +1174,10 @@ async function runAgentInner(history, opts, life) {
1012
1174
  }
1013
1175
  }
1014
1176
  // Execute: read-only tools run concurrently; edit/exec run alone, in order.
1015
- if (runSignal.aborted)
1177
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
1016
1178
  return finalizeStoppedToolRound();
1017
1179
  const runOne = async (idx, p) => {
1018
- if (runSignal.aborted)
1180
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
1019
1181
  return;
1020
1182
  if (repeatHalt) {
1021
1183
  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 };
@@ -1042,7 +1204,7 @@ async function runAgentInner(history, opts, life) {
1042
1204
  results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + noteCall(p.tu.name, p.tu.input, msg, true), isError: true };
1043
1205
  return;
1044
1206
  }
1045
- if (runSignal.aborted)
1207
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
1046
1208
  return;
1047
1209
  const pre = opts.hooks === false
1048
1210
  ? { block: false, message: "" }
@@ -1051,7 +1213,7 @@ async function runAgentInner(history, opts, life) {
1051
1213
  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 };
1052
1214
  return;
1053
1215
  }
1054
- if (runSignal.aborted)
1216
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
1055
1217
  return;
1056
1218
  // Track the MAIN conversation's working files for post-compaction restore (quiet fan-out
1057
1219
  // sub-agents read broadly — their files aren't "what the user was working on").
@@ -1062,9 +1224,17 @@ async function runAgentInner(history, opts, life) {
1062
1224
  // A plain Promise.race can let the abort branch win the same microtask turn and falsely report the
1063
1225
  // completed action as not run. Non-cooperative pending tools still lose to the hard stop immediately.
1064
1226
  let settled;
1065
- const observedTool = p.tool.run(p.tu.input, toolCtx).then((value) => { settled = { ok: true, value }; return value; }, (error) => { settled = { ok: false, error }; throw error; });
1227
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
1228
+ return;
1229
+ const executionToolCtx = p.tool === askUserTool && askWithRunCancellation
1230
+ ? {
1231
+ ...toolCtx,
1232
+ ask: (question, options, signal) => waitForHuman(opts, life, () => askWithRunCancellation(question, options, signal)),
1233
+ }
1234
+ : toolCtx;
1235
+ const observedTool = p.tool.run(p.tu.input, executionToolCtx).then((value) => { settled = { ok: true, value }; return value; }, (error) => { settled = { ok: false, error }; throw error; });
1066
1236
  try {
1067
- opts.onToolRun?.(observedTool, { name: p.tool.name, kind: p.tool.kind });
1237
+ opts.onToolRun?.(observedTool, { name: p.tool.name, kind: p.approvalKind });
1068
1238
  }
1069
1239
  catch { /* observers cannot affect execution */ }
1070
1240
  const toolResult = await bounded(observedTool);
@@ -1081,7 +1251,7 @@ async function runAgentInner(history, opts, life) {
1081
1251
  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) };
1082
1252
  // The tool may have completed a side effect and then triggered/observed cancellation. Preserve its
1083
1253
  // actual result in the closing tool round, but do not run any post hook or later tool afterward.
1084
- if (runSignal.aborted)
1254
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
1085
1255
  return;
1086
1256
  if (opts.hooks !== false) {
1087
1257
  await runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd, 30_000, runSignal); // observe-only
@@ -1097,7 +1267,7 @@ async function runAgentInner(history, opts, life) {
1097
1267
  activity.dec();
1098
1268
  }
1099
1269
  };
1100
- let batch = []; // indices of pending read-kind tools (run concurrently, capped)
1270
+ let batch = []; // indices of input-classified concurrency-safe operations
1101
1271
  const flush = async () => {
1102
1272
  if (!batch.length)
1103
1273
  return;
@@ -1106,27 +1276,27 @@ async function runAgentInner(history, opts, life) {
1106
1276
  await mapLimit(idx, maxParallel(), (i) => runOne(i, plans[i])); // bounded fan-out (e.g. 20 parallel agents → 8 at a time)
1107
1277
  };
1108
1278
  for (let i = 0; i < plans.length; i++) {
1109
- if (runSignal.aborted)
1279
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
1110
1280
  return finalizeStoppedToolRound();
1111
1281
  const p = plans[i];
1112
- // ask_user is interaction-safe but not parallel-safe: the TUI deliberately owns one prompt slot.
1113
- // task_intake is also a state transition: run it alone so its persisted brief has a deterministic
1114
- // boundary before the next model round. Flush other reads before either.
1115
- if (p.denied === undefined && p.tool?.kind === "read" && p.tool.name !== "ask_user" && p.tool.name !== "task_intake") {
1282
+ if (p.denied === undefined && p.operation?.concurrencySafe === true) {
1116
1283
  batch.push(i); // safe → accumulate to run concurrently
1117
1284
  }
1118
1285
  else {
1119
- await flush(); // flush pending reads before an edit/exec
1120
- if (runSignal.aborted)
1286
+ await flush(); // flush safe operations before a serial/shared-state operation
1287
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
1121
1288
  return finalizeStoppedToolRound();
1122
1289
  await runOne(i, p);
1123
- if (runSignal.aborted)
1290
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
1124
1291
  return finalizeStoppedToolRound();
1125
1292
  }
1126
1293
  }
1127
1294
  await flush();
1128
- if (runSignal.aborted)
1295
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
1129
1296
  return finalizeStoppedToolRound();
1297
+ const boundedContents = limitToolResultBatch(results.map((result) => result.content));
1298
+ for (let i = 0; i < results.length; i++)
1299
+ results[i].content = boundedContents[i];
1130
1300
  history.push({ role: "tool", results });
1131
1301
  if (intakeDirty && intakeTask) {
1132
1302
  try {
@@ -1189,9 +1359,11 @@ async function runAgentInner(history, opts, life) {
1189
1359
  return { status: "halted" };
1190
1360
  }
1191
1361
  if (guard && !nudged) {
1192
- for (const p of plans)
1193
- if (p.tool && p.tool.kind !== "read")
1362
+ for (const p of plans) {
1363
+ if (p.tool && p.operation && p.operation.effect !== "read" && p.operation.effect !== "state" && p.operation.effect !== "interactive") {
1194
1364
  toolCounts.set(p.tu.name, (toolCounts.get(p.tu.name) ?? 0) + 1);
1365
+ }
1366
+ }
1195
1367
  for (const res of results)
1196
1368
  if (typeof res.content === "string" && /Configure a vision model/.test(res.content))
1197
1369
  blindShots++;