@nanhara/hara 0.125.1 → 0.126.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +22 -8
  3. package/SECURITY.md +6 -4
  4. package/dist/agent/limits.js +2 -2
  5. package/dist/agent/loop.js +413 -77
  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 +439 -227
  10. package/dist/mcp/client.js +73 -6
  11. package/dist/providers/bounded-turn.js +6 -0
  12. package/dist/providers/factory.js +54 -0
  13. package/dist/providers/models.js +39 -6
  14. package/dist/providers/openai.js +6 -1
  15. package/dist/providers/reasoning.js +12 -0
  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 +157 -40
  23. package/dist/serve/sessions.js +11 -2
  24. package/dist/session/operation-drain.js +45 -0
  25. package/dist/session/task.js +78 -0
  26. package/dist/statusbar.js +15 -2
  27. package/dist/tools/agent.js +1 -0
  28. package/dist/tools/all.js +1 -0
  29. package/dist/tools/ask_user.js +8 -3
  30. package/dist/tools/builtin.js +22 -1
  31. package/dist/tools/codebase.js +1 -0
  32. package/dist/tools/computer.js +1 -0
  33. package/dist/tools/cron.js +10 -0
  34. package/dist/tools/external_agent.js +1 -0
  35. package/dist/tools/memory.js +2 -0
  36. package/dist/tools/registry.js +95 -4
  37. package/dist/tools/result-limit.js +172 -2
  38. package/dist/tools/runtime.js +67 -0
  39. package/dist/tools/search.js +3 -0
  40. package/dist/tools/skill.js +1 -0
  41. package/dist/tools/task.js +5 -0
  42. package/dist/tools/todo.js +3 -2
  43. package/dist/tools/web.js +4 -0
  44. package/dist/tui/App.js +49 -17
  45. package/dist/tui/model-picker.js +11 -8
  46. 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";
@@ -6,7 +7,7 @@ import { makeRenderer } from "../md.js";
6
7
  import { skillsDigest } from "../skills/skills.js";
7
8
  import { runHooks } from "../hooks.js";
8
9
  import { mapLimit, maxParallel } from "../concurrency.js";
9
- import { decideCommand, loadPermissionRules } from "../security/permissions.js";
10
+ import { decideCommand, isReadOnlyCommand, loadPermissionRules, splitCompound, } from "../security/permissions.js";
10
11
  import { classifyRisk, guardianVeto, guardianEnabled, newBreaker, recordBlock } from "../security/guardian.js";
11
12
  import { keyOf, looksFailed, recordCall } from "./repeat-guard.js";
12
13
  import { agentMaxRounds, agentRunTimeoutMs, formatAgentDuration } from "./limits.js";
@@ -21,8 +22,13 @@ import { redactSensitiveText } from "../security/secrets.js";
21
22
  import { redactToolSubprocessOutput } from "../security/subprocess-env.js";
22
23
  import { prepareHistoryForModel } from "./context-budget.js";
23
24
  import { rolesDigest } from "../org/roles.js";
25
+ import { applyTaskBrief } from "../session/task.js";
26
+ import { askUserTool } from "../tools/ask_user.js";
24
27
  /** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
25
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"]);
26
32
  /** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
27
33
  * stalled connection and aborted into the normal error→failover path — instead of hanging on
28
34
  * "working Ns" forever (the "pressed Enter, thought it failed" report). Generous default because
@@ -86,7 +92,12 @@ mask to existing protected paths; Linux/Windows shell checks are
86
92
  static guardrails, not a kernel sandbox. MCP and external coding agents run outside this boundary: use them
87
93
  only as reviewed trusted extensions. Their tool calls require confirmation every time in interactive use and
88
94
  are disabled without an interactive approval channel unless the user launched with
89
- HARA_ALLOW_TRUSTED_EXTENSIONS=1.
95
+ HARA_ALLOW_TRUSTED_EXTENSIONS=1. Configured MCP servers stay stopped by default; when a task materially needs
96
+ one, call \`mcp_connect\` for that server only, then use the newly available tools on the next round. Never
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.
90
101
  For broad,
91
102
  open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
92
103
  independent questions (role "explore") — each returns conclusions, not dumps. When specialist roles are
@@ -142,14 +153,34 @@ const CONTINUATION_SYSTEM = "# Existing-session continuity\n" +
142
153
  "re-inventory the workspace, or summarize files merely to understand what happened before. Follow the latest user request " +
143
154
  "and reuse prior conclusions and tool results. Inspect files only when the latest request requires it. If the working " +
144
155
  "directory is Home, ask the user to start Hara from a concrete project instead of enumerating Home or its children.";
145
- function composeSystem(cwd, projectContext, override, memory, continuationSession = false, executionContext) {
156
+ function composeSystem(cwd, projectContext, override, memory, continuationSession = false, executionContext, intake) {
146
157
  const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
147
158
  const skills = skillsDigest(cwd);
148
159
  const roles = override ? "" : rolesDigest(cwd);
160
+ const intakeContext = !intake?.enabled
161
+ ? ""
162
+ : intake.brief
163
+ ? ("\n\n# Understanding → execution boundary\n" +
164
+ "The task brief below is the accepted interpretation for this run. Keep actions inside it. If new " +
165
+ "user input materially changes the goal, constraints, acceptance checks, or intended side effects, " +
166
+ "call `task_intake` again before further side effects.\n" +
167
+ `Intent: ${intake.brief.intent}\n` +
168
+ `Goal: ${intake.brief.goal}\n` +
169
+ `Constraints:\n${intake.brief.constraints.map((item) => `- ${item}`).join("\n")}\n` +
170
+ `Acceptance:\n${intake.brief.acceptance.map((item) => `- ${item}`).join("\n")}\n` +
171
+ `Steps:\n${intake.brief.steps.map((item, index) => `${index + 1}. ${item}`).join("\n")}`)
172
+ : ("\n\n# Understanding → execution boundary\n" +
173
+ "Do not jump from a raw request straight into side effects. You may answer, inspect files, search, " +
174
+ "ask a necessary question, or build a todo list first. BEFORE the first edit, non-read-only command, " +
175
+ "background-process start/stop, computer action, external agent, or MCP connection, call `task_intake` in its OWN tool round with " +
176
+ "the interpreted goal, intent, constraints, acceptance checks, and short steps. Use intent `answer` " +
177
+ "for a direct answer, `investigate` for evidence gathering/diagnosis, and `change` when the user asked " +
178
+ "you to modify or deliver something. Do not claim completion until the acceptance checks are verified.");
149
179
  return (head +
150
180
  gatewayNote() +
151
181
  (continuationSession ? `\n\n${CONTINUATION_SYSTEM}` : "") +
152
182
  (executionContext ? `\n\n${executionContext}` : "") +
183
+ intakeContext +
153
184
  (projectContext ? `\n\n# Project context (AGENTS.md)\n${projectContext}` : "") +
154
185
  (memory ? `\n\n# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "") +
155
186
  (roles ? `\n\n# Specialist roles (metadata only — use \`agent\` with a role id for bounded read-only expertise)\n${roles}` : "") +
@@ -158,7 +189,7 @@ function composeSystem(cwd, projectContext, override, memory, continuationSessio
158
189
  const RUN_STOPPED = Symbol("agent-run-stopped");
159
190
  const REPEATED_FAILURE_LIMIT = 3;
160
191
  export function deadlineCheckpointReminder(timeoutMs) {
161
- 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. ` +
162
193
  "Stop expanding scope. Finish only the current atomic step, persist any usable artifact, update todo_write, " +
163
194
  "and reply with the completed checkpoint plus the next exact step. Do not start another generation batch, " +
164
195
  "install, full validation suite, preview, render, deployment, or other multi-minute stage in this turn. " +
@@ -183,39 +214,149 @@ function requestRunCheckpoint(opts, life) {
183
214
  if (life.disposed || life.checkpointDue || life.signal.aborted)
184
215
  return;
185
216
  life.checkpointDue = true;
186
- const remainingMs = Math.max(0, life.timeoutMs - (Date.now() - life.startedAt));
187
- 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.`);
188
219
  }
189
220
  function warnRun(opts, life) {
190
221
  if (life.disposed || life.warned || life.signal.aborted)
191
222
  return;
192
223
  life.warned = true;
193
- const elapsedMs = Date.now() - life.startedAt;
224
+ const elapsedMs = runActiveElapsedMs(life);
194
225
  const remainingMs = Math.max(0, life.timeoutMs - elapsedMs);
195
- 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
+ }
196
353
  }
197
354
  function createRunLifecycle(opts) {
198
355
  const timeoutMs = agentRunTimeoutMs(opts.timeoutMs);
199
356
  const maxRounds = agentMaxRounds(opts.maxRounds);
200
357
  const timeoutController = new AbortController();
201
358
  const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutController.signal]) : timeoutController.signal;
202
- const startedAt = Date.now();
203
- const life = {};
204
- const timeoutTimer = setTimeout(() => {
205
- if (life.disposed || signal.aborted)
206
- return;
207
- life.timedOut = true;
208
- timeoutController.abort(new Error("agent run deadline reached"));
209
- }, timeoutMs);
210
- // This timer is the hard boundary for a provider/tool Promise that owns no event-loop handles. Keep it
211
- // referenced while the run is active; unref would let headless Node exit with the run still unresolved.
212
- // Long legitimate work gets an in-band heads-up; a fast active loop gets the same warning at 75% rounds.
213
- const warningDelay = Math.min(5 * 60_000, Math.max(250, Math.floor(timeoutMs * 0.8)));
214
- const warningTimer = setTimeout(() => warnRun(opts, life), warningDelay);
215
- warningTimer.unref?.();
216
- const checkpointDelay = Math.max(250, Math.floor(timeoutMs * 0.8));
217
- const checkpointTimer = setTimeout(() => requestRunCheckpoint(opts, life), checkpointDelay);
218
- checkpointTimer.unref?.();
359
+ const activeStartedAt = Date.now();
219
360
  let removeStopListener = () => { };
220
361
  const stopPromise = new Promise((resolveStopped) => {
221
362
  const stopped = () => resolveStopped(RUN_STOPPED);
@@ -225,15 +366,17 @@ function createRunLifecycle(opts) {
225
366
  else
226
367
  signal.addEventListener("abort", stopped, { once: true });
227
368
  });
228
- Object.assign(life, {
369
+ const life = {
229
370
  signal,
230
371
  timeoutController,
231
- timeoutTimer,
232
- warningTimer,
233
- checkpointTimer,
372
+ timeoutTimer: null,
373
+ warningTimer: null,
374
+ checkpointTimer: null,
234
375
  stopPromise,
235
376
  removeStopListener,
236
- startedAt,
377
+ activeStartedAt,
378
+ activeElapsedMs: 0,
379
+ pauseDepth: 0,
237
380
  timeoutMs,
238
381
  maxRounds,
239
382
  rounds: 0,
@@ -244,20 +387,19 @@ function createRunLifecycle(opts) {
244
387
  limitAnnounced: false,
245
388
  disposed: false,
246
389
  failedCalls: new Map(),
247
- });
390
+ };
391
+ armRunTimers(opts, life);
248
392
  return life;
249
393
  }
250
394
  function disposeRunLifecycle(life) {
251
395
  life.disposed = true;
252
- clearTimeout(life.timeoutTimer);
253
- clearTimeout(life.warningTimer);
254
- clearTimeout(life.checkpointTimer);
396
+ clearRunTimers(life);
255
397
  life.removeStopListener();
256
398
  }
257
399
  function hardStop(opts, life, kind, detail) {
258
- const elapsedMs = Date.now() - life.startedAt;
400
+ const elapsedMs = runActiveElapsedMs(life);
259
401
  const message = kind === "deadline"
260
- ? `⏸ 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).`
261
403
  : kind === "max_rounds"
262
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.`
263
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.`;
@@ -285,7 +427,82 @@ export async function runAgent(history, opts) {
285
427
  async function runAgentInner(history, opts, life) {
286
428
  const { provider, ctx } = opts;
287
429
  const runSignal = life.signal;
288
- 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
+ };
457
+ let intakeTask = opts.taskIntake?.task;
458
+ let intakeDirty = false;
459
+ const syncIntakeTask = () => {
460
+ const current = opts.taskIntake?.current?.();
461
+ if (current && (!intakeTask || current.id === intakeTask.id))
462
+ intakeTask = current;
463
+ };
464
+ const taskIntakeTool = opts.taskIntake
465
+ ? {
466
+ name: "task_intake",
467
+ description: "Record or revise your explicit understanding of the active task before side effects. Call this " +
468
+ "in its own tool round only after using the conversation and any needed read-only evidence to identify " +
469
+ "the real goal. Required before edits, non-read-only commands, background-process start/stop, computer actions, external agents, or MCP.",
470
+ input_schema: {
471
+ type: "object",
472
+ properties: {
473
+ intent: {
474
+ type: "string",
475
+ enum: ["answer", "investigate", "change"],
476
+ description: "answer = direct response, investigate = evidence/diagnosis, change = modify or deliver",
477
+ },
478
+ goal: { type: "string", description: "One concrete interpreted outcome; include the actual target, not generic 'help the user'." },
479
+ constraints: { type: "array", items: { type: "string" }, description: "User/project boundaries that must remain true." },
480
+ acceptance: { type: "array", items: { type: "string" }, description: "Observable checks that prove the task is done." },
481
+ steps: { type: "array", items: { type: "string" }, description: "Short ordered approach, normally 2–6 steps." },
482
+ },
483
+ required: ["intent", "goal", "constraints", "acceptance", "steps"],
484
+ },
485
+ kind: "read",
486
+ classify: () => ({ effect: "state", concurrencySafe: false }),
487
+ run: async (input) => {
488
+ syncIntakeTask();
489
+ const applied = applyTaskBrief(intakeTask, input);
490
+ if (!applied.ok)
491
+ return `Error: task brief rejected — ${applied.reason}`;
492
+ intakeTask = applied.task;
493
+ intakeDirty = true;
494
+ return (`Task brief accepted (${applied.brief.intent}).\n` +
495
+ `Goal: ${applied.brief.goal}\n` +
496
+ `Acceptance:\n${applied.brief.acceptance.map((item) => `- ${item}`).join("\n")}\n` +
497
+ "Proceed within this brief; revise task_intake first if the user's intent materially changes.");
498
+ },
499
+ }
500
+ : undefined;
501
+ // `task_intake` is engine-owned and cannot be shadowed by an ad-hoc tool with the same name.
502
+ const runExtraTools = [
503
+ ...(opts.extraTools ?? []).filter((tool) => tool.name !== "task_intake"),
504
+ ...(taskIntakeTool ? [taskIntakeTool] : []),
505
+ ];
289
506
  const permRules = loadPermissionRules(ctx.cwd); // command-level allow/ask/deny policy for the bash tool
290
507
  let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
291
508
  let triedFallback = false;
@@ -347,14 +564,14 @@ async function runAgentInner(history, opts, life) {
347
564
  for (;;) {
348
565
  // A cancellation that already happened is authoritative: do not start pending-input work, a provider
349
566
  // request, or any later tool round merely to give it an already-aborted signal.
350
- if (runSignal.aborted)
567
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
351
568
  return stoppedOutcome();
352
569
  if (life.rounds >= life.maxRounds)
353
570
  return hardStop(opts, life, "max_rounds");
354
571
  life.rounds += 1;
355
572
  if (life.rounds >= Math.ceil(life.maxRounds * 0.75))
356
573
  warnRun(opts, life);
357
- if (Date.now() - life.startedAt >= Math.floor(life.timeoutMs * 0.8))
574
+ if (runActiveElapsedMs(life) >= Math.floor(life.timeoutMs * 0.8))
358
575
  requestRunCheckpoint(opts, life);
359
576
  if (!opts.quiet && life.checkpointDue && !life.checkpointInjected) {
360
577
  life.checkpointInjected = true;
@@ -381,6 +598,9 @@ async function runAgentInner(history, opts, life) {
381
598
  return stoppedOutcome();
382
599
  for (const m of pending)
383
600
  history.push(m);
601
+ // pendingInput may have durably accepted steering into the owner's immutable task snapshot. Refresh
602
+ // before composing the system or applying a later brief so that state is never overwritten.
603
+ syncIntakeTask();
384
604
  }
385
605
  // system-reminder injection: event-driven context queued since the last call (todo staleness today)
386
606
  // lands as ONE wrapped user message the UI never renders. Quiet runs don't drain — a parallel
@@ -390,12 +610,15 @@ async function runAgentInner(history, opts, life) {
390
610
  if (reminders.length)
391
611
  history.push({ role: "user", content: wrapReminders(reminders) });
392
612
  }
393
- const baseSpecs = opts.toolFilter ? toolSpecs().filter((t) => opts.toolFilter(t.name)) : toolSpecs();
394
- const specs = opts.extraTools?.length
395
- ? [...baseSpecs, ...opts.extraTools.map((t) => ({ name: t.name, description: t.description, input_schema: t.input_schema }))]
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;
617
+ const specs = runExtraTools.length
618
+ ? [...baseSpecs, ...runExtraTools.map((t) => ({ name: t.name, description: t.description, input_schema: t.input_schema }))]
396
619
  : baseSpecs;
397
620
  const sink = ctx.ui; // TUI mode: route output to ink instead of stdout
398
- const system = composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory, opts.continuationSession, opts.executionContext);
621
+ const system = composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory, opts.continuationSession, opts.executionContext, { enabled: !!opts.taskIntake, brief: intakeTask?.brief });
399
622
  const prepared = prepareHistoryForModel(history, {
400
623
  model: activeProvider.model,
401
624
  system,
@@ -486,7 +709,7 @@ async function runAgentInner(history, opts, life) {
486
709
  // immediately before the provider side effect starts. Promise.resolve(activeProvider.turn(...)) is
487
710
  // insufficient here: a custom provider can throw synchronously before Promise.resolve ever sees it.
488
711
  const providerTurn = Promise.resolve().then(() => {
489
- if (attempt.signal.aborted || runSignal.aborted) {
712
+ if (expireRunBudgetIfNeeded(life) || attempt.signal.aborted || runSignal.aborted) {
490
713
  return { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
491
714
  }
492
715
  return activeProvider.turn({
@@ -592,7 +815,7 @@ async function runAgentInner(history, opts, life) {
592
815
  }
593
816
  // A provider may ignore AbortSignal and return a perfectly valid-looking tool_use after cancellation.
594
817
  // The original run signal is authoritative: do not append/approve/execute any late response.
595
- if (runSignal.aborted)
818
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
596
819
  return stoppedOutcome();
597
820
  history.push({ role: "assistant", text: r.text, toolUses: r.toolUses });
598
821
  if (r.stop === "error") {
@@ -684,7 +907,7 @@ async function runAgentInner(history, opts, life) {
684
907
  const results = new Array(r.toolUses.length);
685
908
  const finalizeStoppedToolRound = () => {
686
909
  const pendingMessage = life.timedOut
687
- ? `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.`
688
911
  : "Error: interrupted before this tool call completed.";
689
912
  history.push({
690
913
  role: "tool",
@@ -711,7 +934,7 @@ async function runAgentInner(history, opts, life) {
711
934
  });
712
935
  return outcome;
713
936
  };
714
- if (runSignal.aborted)
937
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
715
938
  return finalizeStoppedToolRound();
716
939
  let repeatHalt = null;
717
940
  const noteCall = (name, input, content, isError = false) => {
@@ -736,9 +959,22 @@ async function runAgentInner(history, opts, life) {
736
959
  };
737
960
  const plans = [];
738
961
  // Extra (per-run) tools win over the registry so a run-scoped tool can't be shadowed by a global one.
739
- const resolveTool = (name) => opts.extraTools?.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
+ };
971
+ // Planning happens before dispatch, so a previously accepted `change` brief must not let the model
972
+ // revise that brief and perform a side effect in the same response. Treat every intake call as a
973
+ // transaction boundary for the whole response: accept/checkpoint the interpretation first, then let
974
+ // the next model round act against the newly authoritative brief.
975
+ const taskBriefTransitionInRound = r.toolUses.some((tu) => tu.name === "task_intake");
740
976
  for (const tu of r.toolUses) {
741
- if (runSignal.aborted)
977
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
742
978
  return finalizeStoppedToolRound();
743
979
  if (breakerHalt) {
744
980
  // Circuit-breaker halted the run: refuse every remaining call in this round with a clear message
@@ -752,12 +988,74 @@ async function runAgentInner(history, opts, life) {
752
988
  continue;
753
989
  }
754
990
  const input = tu.input;
755
- const preview = redactToolSubprocessOutput(String(input.path ?? input.command ?? input.pattern ?? input.url ?? input.task ?? "")
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);
1013
+ const preview = redactToolSubprocessOutput(String(input.path ?? input.command ?? input.pattern ?? input.url ?? input.task ?? input.server ?? "")
756
1014
  .replace(/\s+/g, " ")
757
1015
  .trim());
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.
1018
+ const cmdDecision = command !== null ? decideCommand(command, permRules) : null;
1019
+ if (opts.taskIntake && tool.name !== "task_intake") {
1020
+ const operationReadOnly = operation.effect === "read"
1021
+ || operation.effect === "state"
1022
+ || operation.effect === "interactive";
1023
+ const needsBrief = tool.trustBoundary === "external" || !operationReadOnly;
1024
+ const requiresChange = tool.trustBoundary !== "external" &&
1025
+ !operationReadOnly &&
1026
+ (operation.effect === "edit" || operation.effect === "exec" || operation.effect === "computer");
1027
+ if (taskBriefTransitionInRound && (requiresChange || tool.trustBoundary === "external")) {
1028
+ plans.push({
1029
+ tu,
1030
+ tool,
1031
+ denied: "Understanding gate: task_intake establishes or revises the brief in this tool round, so this " +
1032
+ "side effect was NOT executed. Wait for the next model round, then act against the checkpointed brief.",
1033
+ });
1034
+ continue;
1035
+ }
1036
+ if (needsBrief && !intakeTask?.brief) {
1037
+ plans.push({
1038
+ tu,
1039
+ tool,
1040
+ denied: "Understanding gate: this action was NOT executed. First inspect/ask what is needed, then call " +
1041
+ "task_intake in its own tool round with goal, intent, constraints, acceptance, and steps.",
1042
+ });
1043
+ continue;
1044
+ }
1045
+ if (requiresChange && intakeTask?.brief?.intent !== "change") {
1046
+ plans.push({
1047
+ tu,
1048
+ tool,
1049
+ denied: `Understanding gate: task brief intent is '${intakeTask?.brief?.intent ?? "unset"}', so this ` +
1050
+ "side effect was NOT executed. Revise task_intake to intent 'change' with the user's authorized " +
1051
+ "goal and acceptance checks before trying again.",
1052
+ });
1053
+ continue;
1054
+ }
1055
+ }
758
1056
  // Screen control and opaque host extensions are gated on EVERY action — a prior "don't ask again"
759
1057
  // and even full-auto must never silently turn them into a side channel.
760
- const alwaysGate = tool.kind === "computer" || tool.trustBoundary === "external";
1058
+ const alwaysGate = approvalKind === "computer" || tool.trustBoundary === "external";
761
1059
  if (tool.trustBoundary === "external" && !ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
762
1060
  plans.push({
763
1061
  tu,
@@ -769,7 +1067,6 @@ async function runAgentInner(history, opts, life) {
769
1067
  }
770
1068
  // Command-level policy for shell commands: a deny rule blocks even in full-auto; an allow rule (or a
771
1069
  // read-only command) auto-runs even in suggest mode. Composes with, doesn't replace, the approval mode.
772
- const cmdDecision = tool.kind === "exec" && typeof input.command === "string" ? decideCommand(input.command, permRules) : null;
773
1070
  if (cmdDecision === "deny") {
774
1071
  plans.push({ tu, tool, denied: "Denied by a permission rule (~/.hara/permissions.json). Loosen the rule or run it yourself." });
775
1072
  continue;
@@ -779,13 +1076,16 @@ async function runAgentInner(history, opts, life) {
779
1076
  // commands classify `low` (pure Node, no LLM) and skip everything below — zero added latency. Only a
780
1077
  // genuinely HIGH-RISK action pays for a cheap-model veto, and that veto fails OPEN on any glitch.
781
1078
  if (guardianOn && !breakerHalt) {
782
- 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();
783
1082
  if (risk.level === "high") {
784
1083
  const safeRiskReason = redactToolSubprocessOutput(risk.reason);
785
1084
  const detail = redactToolSubprocessOutput(String(input.command ?? input.path ?? "").replace(/\s+/g, " ").trim().slice(0, 400));
786
1085
  let verdictResult;
787
1086
  try {
788
- 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);
789
1089
  }
790
1090
  catch (error) {
791
1091
  if (runSignal.aborted)
@@ -794,6 +1094,8 @@ async function runAgentInner(history, opts, life) {
794
1094
  }
795
1095
  if (verdictResult === RUN_STOPPED)
796
1096
  return finalizeStoppedToolRound();
1097
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
1098
+ return finalizeStoppedToolRound();
797
1099
  const verdict = verdictResult;
798
1100
  if (verdict.decision === "block") {
799
1101
  const tripped = recordBlock(breaker); // deterministic circuit-breaker: N blocks → hard stop
@@ -818,7 +1120,7 @@ async function runAgentInner(history, opts, life) {
818
1120
  let contResult;
819
1121
  try {
820
1122
  contResult = interactive
821
- ? 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))))
822
1124
  : false;
823
1125
  }
824
1126
  catch (error) {
@@ -841,11 +1143,11 @@ async function runAgentInner(history, opts, life) {
841
1143
  }
842
1144
  }
843
1145
  }
844
- 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));
845
1147
  if (shouldConfirm) {
846
1148
  let replyResult;
847
1149
  try {
848
- 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))));
849
1151
  }
850
1152
  catch (error) {
851
1153
  if (runSignal.aborted)
@@ -862,7 +1164,7 @@ async function runAgentInner(history, opts, life) {
862
1164
  if (reply === "always" && !alwaysGate)
863
1165
  opts.autoApprove?.add(tu.name); // computer: treat "always" as one-time yes
864
1166
  }
865
- plans.push({ tu, tool });
1167
+ plans.push({ tu, tool, operation, approvalKind });
866
1168
  if (!opts.quiet) {
867
1169
  const pv = preview ? preview.slice(0, 80) : "";
868
1170
  if (sink)
@@ -872,10 +1174,10 @@ async function runAgentInner(history, opts, life) {
872
1174
  }
873
1175
  }
874
1176
  // Execute: read-only tools run concurrently; edit/exec run alone, in order.
875
- if (runSignal.aborted)
1177
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
876
1178
  return finalizeStoppedToolRound();
877
1179
  const runOne = async (idx, p) => {
878
- if (runSignal.aborted)
1180
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
879
1181
  return;
880
1182
  if (repeatHalt) {
881
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 };
@@ -902,7 +1204,7 @@ async function runAgentInner(history, opts, life) {
902
1204
  results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + noteCall(p.tu.name, p.tu.input, msg, true), isError: true };
903
1205
  return;
904
1206
  }
905
- if (runSignal.aborted)
1207
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
906
1208
  return;
907
1209
  const pre = opts.hooks === false
908
1210
  ? { block: false, message: "" }
@@ -911,7 +1213,7 @@ async function runAgentInner(history, opts, life) {
911
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 };
912
1214
  return;
913
1215
  }
914
- if (runSignal.aborted)
1216
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
915
1217
  return;
916
1218
  // Track the MAIN conversation's working files for post-compaction restore (quiet fan-out
917
1219
  // sub-agents read broadly — their files aren't "what the user was working on").
@@ -922,9 +1224,17 @@ async function runAgentInner(history, opts, life) {
922
1224
  // A plain Promise.race can let the abort branch win the same microtask turn and falsely report the
923
1225
  // completed action as not run. Non-cooperative pending tools still lose to the hard stop immediately.
924
1226
  let settled;
925
- 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; });
926
1236
  try {
927
- opts.onToolRun?.(observedTool, { name: p.tool.name, kind: p.tool.kind });
1237
+ opts.onToolRun?.(observedTool, { name: p.tool.name, kind: p.approvalKind });
928
1238
  }
929
1239
  catch { /* observers cannot affect execution */ }
930
1240
  const toolResult = await bounded(observedTool);
@@ -941,7 +1251,7 @@ async function runAgentInner(history, opts, life) {
941
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) };
942
1252
  // The tool may have completed a side effect and then triggered/observed cancellation. Preserve its
943
1253
  // actual result in the closing tool round, but do not run any post hook or later tool afterward.
944
- if (runSignal.aborted)
1254
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
945
1255
  return;
946
1256
  if (opts.hooks !== false) {
947
1257
  await runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd, 30_000, runSignal); // observe-only
@@ -957,7 +1267,7 @@ async function runAgentInner(history, opts, life) {
957
1267
  activity.dec();
958
1268
  }
959
1269
  };
960
- let batch = []; // indices of pending read-kind tools (run concurrently, capped)
1270
+ let batch = []; // indices of input-classified concurrency-safe operations
961
1271
  const flush = async () => {
962
1272
  if (!batch.length)
963
1273
  return;
@@ -966,27 +1276,51 @@ async function runAgentInner(history, opts, life) {
966
1276
  await mapLimit(idx, maxParallel(), (i) => runOne(i, plans[i])); // bounded fan-out (e.g. 20 parallel agents → 8 at a time)
967
1277
  };
968
1278
  for (let i = 0; i < plans.length; i++) {
969
- if (runSignal.aborted)
1279
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
970
1280
  return finalizeStoppedToolRound();
971
1281
  const p = plans[i];
972
- // ask_user is interaction-safe but not parallel-safe: the TUI deliberately owns one prompt slot.
973
- // Flush other reads and ask sequentially so two questions cannot overwrite each other and hang.
974
- if (p.denied === undefined && p.tool?.kind === "read" && p.tool.name !== "ask_user") {
1282
+ if (p.denied === undefined && p.operation?.concurrencySafe === true) {
975
1283
  batch.push(i); // safe → accumulate to run concurrently
976
1284
  }
977
1285
  else {
978
- await flush(); // flush pending reads before an edit/exec
979
- if (runSignal.aborted)
1286
+ await flush(); // flush safe operations before a serial/shared-state operation
1287
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
980
1288
  return finalizeStoppedToolRound();
981
1289
  await runOne(i, p);
982
- if (runSignal.aborted)
1290
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
983
1291
  return finalizeStoppedToolRound();
984
1292
  }
985
1293
  }
986
1294
  await flush();
987
- if (runSignal.aborted)
1295
+ if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
988
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];
989
1300
  history.push({ role: "tool", results });
1301
+ if (intakeDirty && intakeTask) {
1302
+ try {
1303
+ // The tool-use/result pair is now protocol-complete. Persist here—never inside the tool—so a crash
1304
+ // cannot leave a session ending in an orphaned tool_use, and no later side effect starts before the
1305
+ // accepted understanding has a durable checkpoint. A steer can be acknowledged while another read
1306
+ // from this same tool round is still settling, so merge from the authoritative owner once more at
1307
+ // this exact boundary instead of overwriting that acknowledged input with the earlier snapshot.
1308
+ const acceptedBrief = intakeTask.brief;
1309
+ syncIntakeTask();
1310
+ // The current owner may still carry the previous brief when this call is a revision. The accepted
1311
+ // brief is the state transition from this round; authoritative refresh contributes newer steering,
1312
+ // while this assignment contributes the new interpretation.
1313
+ if (acceptedBrief && intakeTask) {
1314
+ intakeTask = { ...intakeTask, brief: acceptedBrief };
1315
+ }
1316
+ opts.taskIntake?.onUpdate?.(intakeTask);
1317
+ opts.taskIntake?.onCheckpoint?.(intakeTask);
1318
+ intakeDirty = false;
1319
+ }
1320
+ catch (error) {
1321
+ return interactionFailure("task-intake checkpoint", error);
1322
+ }
1323
+ }
990
1324
  if (repeatHalt)
991
1325
  return hardStop(opts, life, "repeat_loop", repeatHalt);
992
1326
  // Synthesis nudge (CC's KN5, hara-shaped): a round that fanned out to several parallel agents just
@@ -1025,9 +1359,11 @@ async function runAgentInner(history, opts, life) {
1025
1359
  return { status: "halted" };
1026
1360
  }
1027
1361
  if (guard && !nudged) {
1028
- for (const p of plans)
1029
- 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") {
1030
1364
  toolCounts.set(p.tu.name, (toolCounts.get(p.tu.name) ?? 0) + 1);
1365
+ }
1366
+ }
1031
1367
  for (const res of results)
1032
1368
  if (typeof res.content === "string" && /Configure a vision model/.test(res.content))
1033
1369
  blindShots++;