@oharness/engine 0.9.2 → 0.10.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 (119) hide show
  1. package/dist/agent/agent.d.ts +207 -1
  2. package/dist/agent/agent.js +691 -55
  3. package/dist/agent/agent.js.map +1 -1
  4. package/dist/agent/background.d.ts +135 -0
  5. package/dist/agent/background.js +307 -0
  6. package/dist/agent/background.js.map +1 -0
  7. package/dist/agent/compaction.d.ts +7 -0
  8. package/dist/agent/compaction.js +11 -3
  9. package/dist/agent/compaction.js.map +1 -1
  10. package/dist/agent/goal.d.ts +36 -0
  11. package/dist/agent/goal.js +71 -0
  12. package/dist/agent/goal.js.map +1 -0
  13. package/dist/agent/permissions.d.ts +6 -0
  14. package/dist/agent/permissions.js +2 -1
  15. package/dist/agent/permissions.js.map +1 -1
  16. package/dist/agent/session.d.ts +36 -0
  17. package/dist/agent/session.js +72 -0
  18. package/dist/agent/session.js.map +1 -1
  19. package/dist/agent/store.d.ts +25 -0
  20. package/dist/agent/store.js +21 -0
  21. package/dist/agent/store.js.map +1 -1
  22. package/dist/agent/subagent.d.ts +12 -0
  23. package/dist/agent/subagent.js +11 -1
  24. package/dist/agent/subagent.js.map +1 -1
  25. package/dist/agent/workspace.d.ts +9 -0
  26. package/dist/agent/workspace.js +59 -5
  27. package/dist/agent/workspace.js.map +1 -1
  28. package/dist/auth/manager.d.ts +9 -0
  29. package/dist/auth/manager.js +67 -6
  30. package/dist/auth/manager.js.map +1 -1
  31. package/dist/auth/providers/account.js +5 -4
  32. package/dist/auth/providers/account.js.map +1 -1
  33. package/dist/browser/tools.js +3 -0
  34. package/dist/browser/tools.js.map +1 -1
  35. package/dist/computer/arbiter.d.ts +69 -0
  36. package/dist/computer/arbiter.js +128 -0
  37. package/dist/computer/arbiter.js.map +1 -0
  38. package/dist/computer/cua-driver.d.ts +115 -0
  39. package/dist/computer/cua-driver.js +453 -0
  40. package/dist/computer/cua-driver.js.map +1 -0
  41. package/dist/computer/helper-client.d.ts +78 -0
  42. package/dist/computer/helper-client.js +163 -0
  43. package/dist/computer/helper-client.js.map +1 -0
  44. package/dist/computer/index.d.ts +15 -0
  45. package/dist/computer/index.js +9 -0
  46. package/dist/computer/index.js.map +1 -0
  47. package/dist/computer/mode.d.ts +19 -0
  48. package/dist/computer/mode.js +42 -0
  49. package/dist/computer/mode.js.map +1 -0
  50. package/dist/computer/plugin.d.ts +24 -0
  51. package/dist/computer/plugin.js +17 -0
  52. package/dist/computer/plugin.js.map +1 -0
  53. package/dist/computer/policy.d.ts +29 -0
  54. package/dist/computer/policy.js +127 -0
  55. package/dist/computer/policy.js.map +1 -0
  56. package/dist/computer/session.d.ts +199 -0
  57. package/dist/computer/session.js +392 -0
  58. package/dist/computer/session.js.map +1 -0
  59. package/dist/computer/target.d.ts +187 -0
  60. package/dist/computer/target.js +31 -0
  61. package/dist/computer/target.js.map +1 -0
  62. package/dist/computer/tools.d.ts +8 -0
  63. package/dist/computer/tools.js +537 -0
  64. package/dist/computer/tools.js.map +1 -0
  65. package/dist/core/tokens.js +4 -0
  66. package/dist/core/tokens.js.map +1 -1
  67. package/dist/core/types.d.ts +16 -1
  68. package/dist/core/types.js.map +1 -1
  69. package/dist/harness.d.ts +76 -1
  70. package/dist/harness.js +178 -22
  71. package/dist/harness.js.map +1 -1
  72. package/dist/index.d.ts +3 -0
  73. package/dist/index.js +2 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/mcp/transport.js +3 -0
  76. package/dist/mcp/transport.js.map +1 -1
  77. package/dist/providers/catalog.js +6 -0
  78. package/dist/providers/catalog.js.map +1 -1
  79. package/dist/providers/discovery.js +10 -2
  80. package/dist/providers/discovery.js.map +1 -1
  81. package/dist/providers/models-dev.js +1 -0
  82. package/dist/providers/models-dev.js.map +1 -1
  83. package/dist/providers/models-dev.json +114 -102
  84. package/dist/providers/piai.d.ts +16 -1
  85. package/dist/providers/piai.js +84 -7
  86. package/dist/providers/piai.js.map +1 -1
  87. package/dist/providers/types.d.ts +6 -0
  88. package/dist/providers/types.js.map +1 -1
  89. package/dist/runtime-plugins/cloud-connector.js +5 -0
  90. package/dist/runtime-plugins/cloud-connector.js.map +1 -1
  91. package/dist/schedule/scheduler.d.ts +38 -0
  92. package/dist/schedule/scheduler.js +101 -1
  93. package/dist/schedule/scheduler.js.map +1 -1
  94. package/dist/tools/builtin/bash.d.ts +6 -0
  95. package/dist/tools/builtin/bash.js +2 -1
  96. package/dist/tools/builtin/bash.js.map +1 -1
  97. package/dist/tools/builtin/recall.js +4 -0
  98. package/dist/tools/builtin/recall.js.map +1 -1
  99. package/dist/tools/builtin/routine.d.ts +9 -0
  100. package/dist/tools/builtin/routine.js +23 -2
  101. package/dist/tools/builtin/routine.js.map +1 -1
  102. package/dist/tools/builtin/task-control.d.ts +21 -0
  103. package/dist/tools/builtin/task-control.js +120 -0
  104. package/dist/tools/builtin/task-control.js.map +1 -0
  105. package/dist/tools/builtin/task.js +50 -9
  106. package/dist/tools/builtin/task.js.map +1 -1
  107. package/dist/tools/builtin/todo.d.ts +1 -1
  108. package/dist/tools/builtin/worktree.d.ts +10 -0
  109. package/dist/tools/builtin/worktree.js +52 -0
  110. package/dist/tools/builtin/worktree.js.map +1 -0
  111. package/dist/tools/define.d.ts +4 -1
  112. package/dist/tools/define.js +2 -0
  113. package/dist/tools/define.js.map +1 -1
  114. package/dist/tools/types.d.ts +112 -0
  115. package/dist/tools/types.js.map +1 -1
  116. package/dist/worktree/index.d.ts +109 -0
  117. package/dist/worktree/index.js +463 -0
  118. package/dist/worktree/index.js.map +1 -0
  119. package/package.json +3 -3
@@ -1,17 +1,24 @@
1
+ import { GOAL_MAX_TURNS, GOAL_TOOL, MAX_GOAL_NUDGES, activeGoal, goalNudge, goalPromptSection, goalTool } from "./goal.js";
2
+ import { COMPUTER_KEY, computerOn, computerPromptSection, isComputerTool } from "../computer/mode.js";
1
3
  import { randomUUID } from "node:crypto";
2
4
  import { errorMessage } from "../core/errors.js";
3
- import { estimateStringTokens } from "../core/tokens.js";
5
+ import { estimateMessagesTokens, estimateStringTokens, estimateToolsTokens } from "../core/tokens.js";
4
6
  import { assistantMessage, emptyUsage, messageText, userMessage } from "../core/types.js";
5
7
  import { thinkingFor } from "../providers/types.js";
8
+ import { WORKTREE_START_TOOL, worktreeStartTool } from "../tools/builtin/worktree.js";
9
+ import { BASH_CWD_KEY } from "../tools/builtin/bash.js";
10
+ import { createWorktree, finishWorktree, worktreeStatus } from "../worktree/index.js";
6
11
  import { createTaskTool } from "../tools/builtin/task.js";
12
+ import { TASK_MESSAGE_TOOL, TASK_OUTPUT_TOOL, TASK_STOP_TOOL, taskMessageTool, taskOutputTool, taskStopTool, } from "../tools/builtin/task-control.js";
13
+ import { BackgroundTasks, formatNotification } from "./background.js";
7
14
  import { ARCHIVE_READER } from "../tools/builtin/recall.js";
8
15
  import { ARTIFACT_METADATA_KEY } from "../tools/builtin/artifact.js";
9
16
  import { previewJson } from "../util/json.js";
10
17
  import { AsyncQueue, mergeStreams } from "../util/queue.js";
11
18
  import { createLogger } from "../util/logger.js";
12
- import { compact, shouldCompact } from "./compaction.js";
19
+ import { compact, compactionPoint, shouldCompact } from "./compaction.js";
13
20
  import { capToolResult } from "./overflow.js";
14
- import { checkWrite, resolveWithin } from "./workspace.js";
21
+ import { checkWrite, resolveWithin, retargetPolicy } from "./workspace.js";
15
22
  import { SkillRegistry } from "./skills.js";
16
23
  import { MemoryStore, recallFromStores } from "./memory.js";
17
24
  import { DEFAULT_MEMORY_POLICY } from "./memory-limits.js";
@@ -23,7 +30,18 @@ import { HookRegistry } from "./hooks.js";
23
30
  import { buildSystemPrompt } from "./prompt.js";
24
31
  import { ASK_USER_TOOL } from "../tools/questions.js";
25
32
  import { SubagentRegistry } from "./subagent.js";
33
+ function withoutSignal(opts) {
34
+ const { signal: _signal, ...rest } = opts;
35
+ return rest;
36
+ }
26
37
  const DEFAULT_MAX_TURNS = 60;
38
+ /**
39
+ * How long an idle agent lets background results gather before asking for a
40
+ * turn to read them, so tasks ending together are answered together.
41
+ */
42
+ const TASK_WAKE_DELAY_MS = 500;
43
+ /** How long task changes gather before they are written down. */
44
+ const TASK_SAVE_DELAY_MS = 1000;
27
45
  /** Session state key: ids of notes attached since the last compaction. */
28
46
  const RECALLED = "memory:recalled";
29
47
  /** Session state key: how much of the transcript an end-of-run extraction has covered. */
@@ -57,14 +75,6 @@ function recalledIds(state, messages) {
57
75
  }
58
76
  return ids;
59
77
  }
60
- /**
61
- * The agentic loop: prompt the model, run whatever tools it asks for, feed the
62
- * results back, repeat until it stops calling tools.
63
- *
64
- * Everything provider-specific lives behind `Provider`, so this loop is the
65
- * same regardless of which model is selected — including when the model
66
- * changes partway through a session.
67
- */
68
78
  export class Agent {
69
79
  session;
70
80
  tools;
@@ -95,6 +105,18 @@ export class Agent {
95
105
  pluginSections = [];
96
106
  /** Recall over `memory` for agents embedded without a plugin host. */
97
107
  legacyMemory;
108
+ /** Subagents running in the background; made on first use. */
109
+ background;
110
+ wakeTimer;
111
+ /** Frontends watching background tasks, kept until the tasks exist. */
112
+ changeListeners = new Set();
113
+ /** What each background task was asked, and its subagent once it has one. */
114
+ taskStates = new Map();
115
+ restoring;
116
+ saveTimer;
117
+ /** Closed or deleted: nothing more is saved, and nothing more starts. */
118
+ tasksClosed = false;
119
+ saveChain = Promise.resolve();
98
120
  constructor(opts) {
99
121
  this.opts = opts;
100
122
  this.session = opts.session;
@@ -128,11 +150,25 @@ export class Agent {
128
150
  if (!this.tools.has("skill") && this.skills.size > 0) {
129
151
  this.tools.register(createSkillTool(this.skills));
130
152
  }
153
+ if (!this.tools.has(GOAL_TOOL))
154
+ this.tools.register(goalTool);
155
+ // Shown only where a frontend can host the session it starts; see visibleTools.
156
+ if (!this.tools.has(WORKTREE_START_TOOL))
157
+ this.tools.register(worktreeStartTool);
131
158
  // The task tool has to be built here: its description enumerates the
132
159
  // subagent types that actually exist in this configuration.
133
160
  if (!this.tools.has("task") && this.subagents.list().length > 0) {
134
161
  this.tools.register(createTaskTool(this));
135
162
  }
163
+ // Shown only where background tasks can be offered; see visibleTools.
164
+ if (this.tools.has("task")) {
165
+ if (!this.tools.has(TASK_OUTPUT_TOOL))
166
+ this.tools.register(taskOutputTool);
167
+ if (!this.tools.has(TASK_STOP_TOOL))
168
+ this.tools.register(taskStopTool);
169
+ if (!this.tools.has(TASK_MESSAGE_TOOL))
170
+ this.tools.register(taskMessageTool);
171
+ }
136
172
  }
137
173
  /**
138
174
  * Announce a model switch so a UI can render it and hooks can react.
@@ -238,35 +274,387 @@ export class Agent {
238
274
  resolvePath(input, access) {
239
275
  return resolveWithin(this.opts.workspace, this.session.cwd, input, access);
240
276
  }
277
+ async startWorktree(opts) {
278
+ const launch = this.opts.worktreeLauncher?.();
279
+ if (!launch)
280
+ throw new Error("this frontend cannot start a session in a worktree");
281
+ return launch({ ...opts, from: { cwd: this.session.cwd, sessionId: this.session.id } });
282
+ }
241
283
  listSubagentTypes() {
242
284
  return this.subagents.summaries();
243
285
  }
244
286
  async runSubagent(opts) {
287
+ const child = await this.prepareSubagent(opts);
288
+ try {
289
+ const result = await this.runChild(child, opts.prompt, opts.signal);
290
+ // Propagate state the subagent built up (e.g. files it read). Not from a
291
+ // worktree: what it read there are paths in another tree.
292
+ if (!child.worktree)
293
+ for (const [key, value] of child.agent.session.state)
294
+ this.session.state.set(key, value);
295
+ // A worktree it left changes in is named in the result: the parent has
296
+ // to decide what happens to that work.
297
+ const kept = await this.worktreeNote(child);
298
+ const text = kept
299
+ ? `${result.text}\n\n[worktree] ${kept} It is kept: review it, then merge, push or apply it, or discard it from the sidebar.`
300
+ : result.text;
301
+ return { text, turns: result.turns, usage: result.usage };
302
+ }
303
+ finally {
304
+ await this.disposeChild(child);
305
+ }
306
+ }
307
+ // --- Background tasks ----------------------------------------------------
308
+ /**
309
+ * Subagents running behind this conversation, offered only while someone
310
+ * can start a turn to read their results in. Once any exist they stay
311
+ * reachable, so they can still be listed, messaged and stopped.
312
+ */
313
+ get backgroundTasks() {
314
+ if (!this.backgroundAvailable())
315
+ return undefined;
316
+ // Read without making the table: every open conversation is asked for
317
+ // its list, and most never start a task.
318
+ return {
319
+ start: (opts) => {
320
+ const state = { opts: withoutSignal(opts) };
321
+ const view = this.tasks().start(this.backgroundSpec(state));
322
+ this.taskStates.set(view.id, state);
323
+ return view;
324
+ },
325
+ get: (id) => this.background?.get(id),
326
+ list: () => this.background?.list() ?? [],
327
+ stop: (id) => this.background?.stop(id) ?? false,
328
+ send: (id, message) => this.background?.send(id, message) ?? "unknown",
329
+ dismiss: (id) => this.background?.dismiss(id) ?? false,
330
+ };
331
+ }
332
+ /** A task's conversation so far, for a frontend to show. */
333
+ backgroundTranscript(id) {
334
+ return this.background?.transcript(id);
335
+ }
336
+ /** Every change to a background task, for a frontend to redraw from. */
337
+ onBackgroundChange(listener) {
338
+ this.changeListeners.add(listener);
339
+ const off = this.background?.onChange(listener);
340
+ return () => {
341
+ this.changeListeners.delete(listener);
342
+ off?.();
343
+ };
344
+ }
345
+ /**
346
+ * The conversation is being closed, not ended: write its tasks down as they
347
+ * stand, then stop them. Opened again, the ones that were running come back
348
+ * interrupted and can be followed up — as after a restart.
349
+ */
350
+ async closeBackgroundTasks() {
351
+ if (this.tasksClosed)
352
+ return;
353
+ if (this.saveTimer)
354
+ clearTimeout(this.saveTimer);
355
+ this.saveTimer = undefined;
356
+ await this.saveTasks();
357
+ this.endBackgroundTasks();
358
+ }
359
+ /**
360
+ * The conversation is being deleted: stop its tasks and write nothing more.
361
+ * Resolves once a write already under way has landed, so deleting the
362
+ * store afterwards cannot be undone by it.
363
+ */
364
+ async discardBackgroundTasks() {
365
+ this.endBackgroundTasks();
366
+ await this.saveChain;
367
+ }
368
+ endBackgroundTasks() {
369
+ this.tasksClosed = true;
370
+ if (this.wakeTimer)
371
+ clearTimeout(this.wakeTimer);
372
+ if (this.saveTimer)
373
+ clearTimeout(this.saveTimer);
374
+ this.wakeTimer = undefined;
375
+ this.saveTimer = undefined;
376
+ this.background?.stopAll();
377
+ this.background?.detach();
378
+ }
379
+ backgroundAvailable() {
380
+ return Boolean(this.background) || Boolean(this.opts.taskWaker?.());
381
+ }
382
+ tasks() {
383
+ if (!this.background) {
384
+ this.background = new BackgroundTasks({
385
+ ...(this.opts.maxBackgroundTasks ? { maxConcurrent: this.opts.maxBackgroundTasks } : {}),
386
+ ...(this.opts.lifecycleSignal ? { signal: this.opts.lifecycleSignal } : {}),
387
+ });
388
+ // While a run is going its own loop reads results in at the next gap;
389
+ // only an idle agent needs someone to start a turn for them. And an
390
+ // ended task is written down at once rather than after the usual pause:
391
+ // its result is the one thing a restart must not lose.
392
+ this.background.onSettled(() => {
393
+ if (!this.running)
394
+ this.scheduleWake();
395
+ void this.saveTasks();
396
+ });
397
+ for (const listener of this.changeListeners)
398
+ this.background.onChange(listener);
399
+ this.background.onChange(() => this.scheduleSave());
400
+ }
401
+ return this.background;
402
+ }
403
+ /**
404
+ * A background task's subagent, made for each run and let go after it.
405
+ *
406
+ * Between runs a task holds only its conversation and, if it changed
407
+ * anything, its worktree: no browser, no claim on the computer, no agent.
408
+ * A follow-up — or the first one after a restart — builds the subagent
409
+ * again from those, so both paths are the same path.
410
+ */
411
+ backgroundSpec(state) {
412
+ const once = async (input, signal, report) => {
413
+ const child = await this.prepareSubagent(state.opts, {
414
+ messages: state.messages ?? [],
415
+ ...(state.worktree ? { worktree: state.worktree } : {}),
416
+ ...(state.equipment ? { equipment: state.equipment } : {}),
417
+ });
418
+ // Its tree is gone — removed by hand, or after a restart — and it now
419
+ // works in a new one: said up front, since its conversation names the
420
+ // old paths.
421
+ const moved = state.worktree && child.worktree && state.worktree.path !== child.worktree.path
422
+ ? `[Note: your earlier worktree at ${state.worktree.path} no longer exists. You are now working in ${child.worktree.path}; files you changed there are not here.]\n\n`
423
+ : "";
424
+ state.child = child;
425
+ state.equipment = child.equipment;
426
+ if (child.worktree)
427
+ state.worktree = child.worktree;
428
+ try {
429
+ const result = await this.runChild(child, moved + input, signal, report);
430
+ const kept = await this.worktreeNote(child);
431
+ return { ...result, ...(kept ? { worktree: kept } : {}) };
432
+ }
433
+ finally {
434
+ // The agent goes; its conversation, tree and browser stay with the
435
+ // task until it is stopped, or its conversation closed.
436
+ state.messages = [...child.agent.session.messages];
437
+ state.child = undefined;
438
+ }
439
+ };
440
+ return {
441
+ agentType: state.opts.agentType,
442
+ description: state.opts.description || state.opts.agentType,
443
+ run: (signal, report) => once(state.opts.prompt, signal, report),
444
+ // A task that failed before its subagent ever ran has no conversation to
445
+ // continue; the follow-up then goes with the task it was given.
446
+ resume: (message, signal, report) => once(state.messages?.length ? message : `${state.opts.prompt}\n\n${message}`, signal, report),
447
+ interject: (message) => Boolean(state.child?.agent.interject(message)),
448
+ transcript: () => state.child?.agent.session.messages ?? state.messages ?? [],
449
+ dispose: async () => {
450
+ await state.equipment?.release().catch(() => { });
451
+ state.equipment = undefined;
452
+ if (state.worktree)
453
+ await this.dropIfUntouched(state.worktree);
454
+ },
455
+ };
456
+ }
457
+ /**
458
+ * Put back the background tasks this conversation had before a restart.
459
+ *
460
+ * Called by a frontend when it opens the conversation; safe to call again.
461
+ * A task that was still running when the app closed comes back as failed
462
+ * and says why — its subagent's process is gone — but keeps its
463
+ * conversation, so a follow-up carries on from where it stopped. A result
464
+ * nobody had been told about is still read in with the next turn.
465
+ */
466
+ async restoreBackgroundTasks() {
467
+ if (this.restoring)
468
+ return this.restoring;
469
+ this.restoring = (async () => {
470
+ if (this.tasksClosed)
471
+ return 0;
472
+ const saved = await this.session.loadTasks().catch(() => []);
473
+ // Closed while it was reading: bringing them back now would make a
474
+ // table nothing will ever close.
475
+ if (this.tasksClosed)
476
+ return 0;
477
+ let restored = 0;
478
+ for (const raw of saved) {
479
+ const item = raw;
480
+ if (!item?.view?.id || !item.options?.agentType || !this.subagents.get(item.options.agentType))
481
+ continue;
482
+ const view = { ...item.view };
483
+ let delivered = item.delivered === true;
484
+ if (view.status === "running" || view.status === "queued") {
485
+ view.status = "failed";
486
+ view.error = "Interrupted: OHarness closed while this task was running. Send it a message to carry on from where it stopped.";
487
+ view.endedAt = Date.now();
488
+ delete view.activity;
489
+ delivered = true;
490
+ }
491
+ const state = {
492
+ opts: item.options,
493
+ messages: Array.isArray(item.messages) ? item.messages : [],
494
+ ...(item.worktree ? { worktree: item.worktree } : {}),
495
+ };
496
+ this.taskStates.set(view.id, state);
497
+ this.tasks().restore(view, this.backgroundSpec(state), delivered);
498
+ restored++;
499
+ }
500
+ return restored;
501
+ })();
502
+ return this.restoring;
503
+ }
504
+ /** Write the tasks down a moment after they change, so a burst is one write. */
505
+ scheduleSave() {
506
+ if (this.saveTimer || this.tasksClosed)
507
+ return;
508
+ this.saveTimer = setTimeout(() => {
509
+ this.saveTimer = undefined;
510
+ void this.saveTasks();
511
+ }, TASK_SAVE_DELAY_MS);
512
+ }
513
+ /**
514
+ * One write at a time, in order. Each takes its snapshot when its turn
515
+ * comes, so a later state is never overwritten by an earlier one landing
516
+ * last — a task that ended would come back as running.
517
+ */
518
+ saveTasks() {
519
+ const next = this.saveChain.then(() => this.writeTasks());
520
+ this.saveChain = next.catch(() => { });
521
+ return next;
522
+ }
523
+ async writeTasks() {
524
+ // Shutting down stops every task; writing that down would bring them back
525
+ // as stopped for good, when they were only interrupted. The last save from
526
+ // while they ran is the truer record. A closed conversation has already
527
+ // written its own.
528
+ if (!this.background || this.tasksClosed || this.opts.lifecycleSignal?.aborted)
529
+ return;
530
+ const tasks = this.background.saved().flatMap(({ view, delivered }) => {
531
+ const state = this.taskStates.get(view.id);
532
+ if (!state)
533
+ return [];
534
+ const worktree = state.child?.worktree ?? state.worktree;
535
+ return [{
536
+ view,
537
+ delivered,
538
+ options: state.opts,
539
+ messages: [...(state.child?.agent.session.messages ?? state.messages ?? [])],
540
+ ...(worktree ? { worktree } : {}),
541
+ }];
542
+ });
543
+ await this.session.saveTasks(tasks).catch((err) => {
544
+ this.logger.warn("could not save background tasks", { error: errorMessage(err) });
545
+ });
546
+ }
547
+ scheduleWake() {
548
+ if (this.wakeTimer || this.tasksClosed)
549
+ return;
550
+ this.wakeTimer = setTimeout(() => {
551
+ this.wakeTimer = undefined;
552
+ if (this.running || !this.background?.hasUndelivered())
553
+ return;
554
+ this.opts.taskWaker?.()?.(this.session.id);
555
+ }, TASK_WAKE_DELAY_MS);
556
+ }
557
+ /**
558
+ * Read the results of background tasks that have ended into the
559
+ * conversation, as one message saying what is still running.
560
+ *
561
+ * Synthetic, like a goal nudge: nobody typed it, so it skips the prompt hook
562
+ * and memory recall, and never titles the session.
563
+ */
564
+ async *takeNotices() {
565
+ const ended = this.background?.takeUndelivered() ?? [];
566
+ if (!ended.length)
567
+ return false;
568
+ const text = formatNotification(ended, this.background?.active() ?? []);
569
+ await this.session.addMessage({
570
+ role: "user",
571
+ content: [{ type: "text", text }],
572
+ // What a transcript needs to draw it as a card rather than as text
573
+ // somebody typed: which tasks, and how each ended.
574
+ meta: {
575
+ timestamp: Date.now(),
576
+ synthetic: true,
577
+ taskNotification: ended.map((task) => ({ id: task.id, description: task.description, status: task.status })),
578
+ },
579
+ });
580
+ yield { type: "task_notification", tasks: ended, text };
581
+ return true;
582
+ }
583
+ /** Where this agent's permission cards go, and who they say is asking. */
584
+ interactionPlace() {
585
+ return {
586
+ sessionId: this.opts.interactionSessionId ?? this.session.id,
587
+ ...(this.opts.interactionLabel ? { from: this.opts.interactionLabel } : {}),
588
+ };
589
+ }
590
+ // --- Subagents -----------------------------------------------------------
591
+ /** A subagent ready to run: its agent, the tree it works in, what it holds. */
592
+ async prepareSubagent(opts, seed) {
245
593
  const definition = this.subagents.get(opts.agentType);
246
594
  if (!definition)
247
595
  throw new Error(`unknown agent type: ${opts.agentType}`);
248
596
  // A subagent gets its own transcript and a filtered tool set, but shares
249
597
  // the parent's scratch state so file-read tracking stays coherent.
250
598
  const { Session: SessionClass } = await import("./session.js");
599
+ // Its own tree when asked for, or when the agent type always wants one:
600
+ // several editing subagents in one checkout would trip over each other.
601
+ // Branched from this checkout's HEAD rather than the remote, so it starts
602
+ // from the commits the parent is looking at.
603
+ const isolated = opts.isolation === "worktree" || definition.isolation === "worktree";
604
+ // A restored task goes back to its own tree while that still exists.
605
+ const kept = seed?.worktree && (await worktreeStatus(seed.worktree).catch(() => undefined))?.exists ? seed.worktree : undefined;
606
+ const worktree = kept ?? (isolated
607
+ ? await createWorktree(this.session.cwd, {
608
+ task: opts.description || definition.name,
609
+ slug: opts.description ? undefined : `${definition.name}-agent`,
610
+ base: "HEAD",
611
+ fetch: false,
612
+ })
613
+ : undefined);
614
+ const childCwd = worktree?.cwd ?? this.session.cwd;
251
615
  const child = new SessionClass({
252
- cwd: this.session.cwd,
616
+ cwd: childCwd,
253
617
  model: opts.model ?? definition.model ?? this.session.model.id,
254
618
  providers: this.session.providers,
255
619
  permissionMode: this.session.permissions.getMode(),
256
620
  store: false,
621
+ // Through the constructor rather than added one by one: it drops a
622
+ // tail a run was cut off in — a tool call with no result — which no
623
+ // provider accepts a following message after.
624
+ ...(seed?.messages.length ? { messages: [...seed.messages] } : {}),
257
625
  });
258
626
  for (const [key, value] of this.session.state)
259
627
  child.state.set(key, value);
628
+ // A shell that had cd'd somewhere in the parent's tree must start in its own.
629
+ if (worktree)
630
+ child.state.delete(BASH_CWD_KEY);
631
+ // Control of the screen is never inherited by default: a subagent works
632
+ // unattended, and drives the computer only when asked to and only while
633
+ // the conversation that asked has it switched on.
634
+ if (!(opts.computer && computerOn(this.session.state)))
635
+ child.state.delete(COMPUTER_KEY);
260
636
  child.permissions.onDecision((entry) => {
261
637
  this.audit({ type: "permission", timestamp: Date.now(), sessionId: child.id, entry });
262
638
  });
263
639
  // A subagent does not ask the person questions. Its events never reach the
264
640
  // parent's stream, so a card it raised would have no call to belong to —
265
641
  // and needing to ask usually means the delegation was underspecified,
266
- // which is the parent's to resolve.
642
+ // which is the parent's to resolve. Nor does it see the parent's
643
+ // background tasks: it has none of its own.
267
644
  const childTools = definition.tools ? this.tools.filtered(definition.tools) : this.tools.clone();
268
645
  childTools.unregister(ASK_USER_TOOL);
269
- const childAgent = new Agent({
646
+ childTools.unregister(TASK_OUTPUT_TOOL);
647
+ childTools.unregister(TASK_STOP_TOOL);
648
+ childTools.unregister(TASK_MESSAGE_TOOL);
649
+ const label = opts.description ? `${definition.name}: ${opts.description}` : definition.name;
650
+ // Its own browser tabs and its own place in the computer's leases, so two
651
+ // subagents — or a subagent and its parent — never act on one page.
652
+ let equipment = seed?.equipment;
653
+ if (equipment?.bind)
654
+ equipment.bind(childTools);
655
+ else
656
+ equipment = this.opts.equipSubagent?.(child.id, label, childTools);
657
+ const agent = new Agent({
270
658
  ...this.opts,
271
659
  questionPrompt: undefined,
272
660
  session: child,
@@ -275,40 +663,96 @@ export class Agent {
275
663
  appendSystemPrompt: undefined,
276
664
  maxTurns: definition.maxTurns ?? this.opts.maxTurns,
277
665
  agentId: `${this.agentId}/${definition.name}`,
666
+ ...(worktree && this.opts.workspace
667
+ ? { workspace: retargetPolicy(this.opts.workspace, this.session.cwd, worktree.cwd) }
668
+ : {}),
669
+ // A subagent hands work back to its parent; it does not start sessions
670
+ // or background tasks.
671
+ worktreeLauncher: undefined,
672
+ taskWaker: undefined,
673
+ equipSubagent: undefined,
674
+ ...(equipment?.onRunStart ? { onRunStart: equipment.onRunStart } : {}),
675
+ interactionSessionId: this.opts.interactionSessionId ?? this.session.id,
676
+ interactionLabel: label,
278
677
  // One level of delegation: a subagent may not spawn further subagents.
279
678
  subagents: new SubagentRegistry([]),
280
679
  // The parent already attached what matched the user's message, and a
281
680
  // subagent's prompt is the parent's wording rather than the user's.
282
681
  memoryProvider: undefined,
283
682
  });
683
+ return { agent, definition, ...(worktree ? { worktree } : {}), ...(equipment ? { equipment } : {}) };
684
+ }
685
+ /**
686
+ * One run of a subagent, to its end: its first prompt, or a later message.
687
+ *
688
+ * `report` is there only for a background task: it is what lets a look at
689
+ * the task say what the subagent is doing now.
690
+ */
691
+ async runChild(child, input, signal, report) {
284
692
  let text = "";
285
693
  let turns = 0;
286
694
  let usage = emptyUsage();
287
- for await (const event of childAgent.run(opts.prompt, { signal: opts.signal })) {
288
- if (event.type === "assistant_message") {
289
- const messageBody = messageText(event.message);
290
- if (messageBody.trim())
291
- text = messageBody;
292
- }
293
- else if (event.type === "done") {
294
- turns = event.turns;
295
- usage = event.usage;
695
+ // Said to it after its last look at its inbox would otherwise sit there
696
+ // until some later run, out of context: answered now, as one more run.
697
+ for (let next = input; next !== undefined;) {
698
+ for await (const event of child.agent.run(next, { ...(signal ? { signal } : {}) })) {
699
+ if (event.type === "assistant_message") {
700
+ const messageBody = messageText(event.message);
701
+ if (messageBody.trim())
702
+ text = messageBody;
703
+ }
704
+ else if (event.type === "turn_start") {
705
+ report?.turn();
706
+ }
707
+ else if (event.type === "tool_start") {
708
+ report?.activity(event.title);
709
+ }
710
+ else if (event.type === "done") {
711
+ turns += event.turns;
712
+ usage = event.usage;
713
+ }
296
714
  }
715
+ const unread = signal?.aborted ? [] : child.agent.takeUnread();
716
+ next = unread.length ? unread.map((entry) => entry.text).join("\n\n") : undefined;
297
717
  }
298
- // Propagate state the subagent built up (e.g. files it read).
299
- for (const [key, value] of child.state)
300
- this.session.state.set(key, value);
301
718
  await this.hooks.run({
302
719
  event: "SubagentStop",
303
720
  sessionId: this.session.id,
304
- agentId: `${this.agentId}/${definition.name}`,
721
+ agentId: `${this.agentId}/${child.definition.name}`,
305
722
  cwd: this.session.cwd,
306
723
  });
307
- return {
308
- text,
309
- turns,
310
- usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens },
311
- };
724
+ return { text, turns, usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens } };
725
+ }
726
+ /** What a subagent left in its worktree, when it left anything. */
727
+ async worktreeNote(child) {
728
+ if (!child.worktree)
729
+ return undefined;
730
+ const status = await worktreeStatus(child.worktree).catch(() => undefined);
731
+ if (status?.clean)
732
+ return undefined;
733
+ const what = status
734
+ ? [status.commits ? `${status.commits} commit(s)` : "", status.changes.length ? `${status.changes.length} changed file(s)` : ""]
735
+ .filter(Boolean)
736
+ .join(", ")
737
+ : "changes";
738
+ const worktree = child.worktree;
739
+ const where = worktree.branch ? `${worktree.path} on branch ${worktree.branch}` : `${worktree.path} (a copy of the folder)`;
740
+ return `The subagent worked in ${where} and left ${what} there.`;
741
+ }
742
+ /**
743
+ * A subagent is finished with: its browser and computer go, and a worktree
744
+ * it changed nothing in is removed. One that it changed is kept — the
745
+ * parent has to decide what happens to that work.
746
+ */
747
+ async disposeChild(child) {
748
+ await child.equipment?.release().catch(() => { });
749
+ if (child.worktree)
750
+ await this.dropIfUntouched(child.worktree);
751
+ }
752
+ async dropIfUntouched(worktree) {
753
+ const status = await worktreeStatus(worktree).catch(() => undefined);
754
+ if (status?.clean)
755
+ await finishWorktree(worktree, "remove").catch(() => { });
312
756
  }
313
757
  // --- Main loop ----------------------------------------------------------
314
758
  async *run(input, runOpts = {}) {
@@ -338,7 +782,10 @@ export class Agent {
338
782
  yield { type: "notice", message: start.additionalContext };
339
783
  }
340
784
  }
341
- const maxTurns = this.opts.maxTurns ?? DEFAULT_MAX_TURNS;
785
+ const baseMaxTurns = this.opts.maxTurns ?? DEFAULT_MAX_TURNS;
786
+ // Read per turn rather than once: a goal can be reached, or reported
787
+ // blocked, partway through the run, and the ordinary ceiling returns then.
788
+ const turnLimit = () => (activeGoal(this.state) ? Math.max(baseMaxTurns, GOAL_MAX_TURNS) : baseMaxTurns);
342
789
  let stopReason = "end_turn";
343
790
  let turn = 0;
344
791
  const finishRun = this.opts.onRunStart?.();
@@ -359,7 +806,13 @@ export class Agent {
359
806
  yield { type: "memory_extracted", notes: extracted };
360
807
  }
361
808
  yield* this.ingestInput(input);
362
- while (turn < maxTurns) {
809
+ // Background results that ended since the last run ride along with
810
+ // whatever started this one. A run started only to read them in is
811
+ // given no input of its own, and has nothing to answer if another path
812
+ // got to them first.
813
+ const noticed = yield* this.takeNotices();
814
+ const nothingToAnswer = Array.isArray(input) && input.length === 0 && !noticed;
815
+ while (!nothingToAnswer && turn < turnLimit()) {
363
816
  if (signal.aborted) {
364
817
  stopReason = "aborted";
365
818
  break;
@@ -428,15 +881,28 @@ export class Agent {
428
881
  // rather than ending and starting a new run keeps one turn's worth
429
882
  // of context, and matches what the user saw: they typed it before
430
883
  // the reply landed.
431
- if (!signal.aborted && (yield* this.takeInbox()))
432
- continue;
884
+ if (!signal.aborted) {
885
+ const said = yield* this.takeInbox();
886
+ const noticed = yield* this.takeNotices();
887
+ if (said || noticed)
888
+ continue;
889
+ }
433
890
  stopReason = assembled.stopReason === "tool_use" ? "end_turn" : assembled.stopReason;
891
+ if (!signal.aborted && (yield* this.pushTowardGoal()))
892
+ continue;
434
893
  break;
435
894
  }
895
+ const goalBefore = activeGoal(this.state);
436
896
  const results = [];
437
897
  for await (const event of this.executeCalls(calls, signal, results)) {
438
898
  yield event;
439
899
  }
900
+ // The goal tool settles the goal in place; saying so here is what lets
901
+ // a UI take down its "working toward" line the moment it is done.
902
+ if (goalBefore && goalBefore.status !== "active") {
903
+ yield { type: "goal", goal: { ...goalBefore } };
904
+ void this.session.persist().catch(() => { });
905
+ }
440
906
  // All tool results for a turn must go back in one user message —
441
907
  // splitting them teaches the model to stop calling tools in parallel.
442
908
  await this.session.addMessage({
@@ -452,8 +918,10 @@ export class Agent {
452
918
  // reaches here before it notices and would read a message it is about
453
919
  // to stop answering, leaving it in the transcript with no reply. Left
454
920
  // in the inbox it goes back to the composer instead.
455
- if (!signal.aborted)
921
+ if (!signal.aborted) {
456
922
  yield* this.takeInbox();
923
+ yield* this.takeNotices();
924
+ }
457
925
  if (this.stopRequested) {
458
926
  yield { type: "notice", message: this.stopRequested };
459
927
  this.stopRequested = undefined;
@@ -465,10 +933,10 @@ export class Agent {
465
933
  break;
466
934
  }
467
935
  }
468
- if (turn >= maxTurns && stopReason === "end_turn") {
936
+ if (turn >= turnLimit() && stopReason === "end_turn") {
469
937
  yield {
470
938
  type: "notice",
471
- message: `Stopped after ${maxTurns} turns without finishing. Send another message to continue.`,
939
+ message: `Stopped after ${turn} turns without finishing. Send another message to continue.`,
472
940
  };
473
941
  }
474
942
  await this.hooks.run({
@@ -491,6 +959,12 @@ export class Agent {
491
959
  this.running = false;
492
960
  await this.session.persist().catch(() => { });
493
961
  finishRun?.();
962
+ // Results that ended after the loop's last look. Not after an interrupt:
963
+ // the person just said stop, and a turn starting by itself half a second
964
+ // later would be the agent ignoring them. They ride along with the next
965
+ // run instead, or wake one when another task ends.
966
+ if (stopReason !== "aborted" && this.background?.hasUndelivered())
967
+ this.scheduleWake();
494
968
  }
495
969
  yield {
496
970
  type: "done",
@@ -501,6 +975,32 @@ export class Agent {
501
975
  };
502
976
  }
503
977
  // --- Steps --------------------------------------------------------------
978
+ /**
979
+ * Where a reply ends with a goal still active: ask for more work.
980
+ *
981
+ * Returns whether the loop should go on. A model that keeps stopping short
982
+ * of its goal gets a bounded number of reminders, not an endless supply.
983
+ */
984
+ async *pushTowardGoal() {
985
+ const goal = activeGoal(this.state);
986
+ if (!goal)
987
+ return false;
988
+ if (goal.nudges >= MAX_GOAL_NUDGES) {
989
+ yield {
990
+ type: "notice",
991
+ message: `Goal still open after ${MAX_GOAL_NUDGES} reminders; stopping here. Send a message to keep going.`,
992
+ };
993
+ return false;
994
+ }
995
+ goal.nudges++;
996
+ await this.session.addMessage({
997
+ role: "user",
998
+ content: [{ type: "text", text: goalNudge(goal) }],
999
+ meta: { timestamp: Date.now(), synthetic: true, goalNudge: true },
1000
+ });
1001
+ yield { type: "goal", goal: { ...goal } };
1002
+ return true;
1003
+ }
504
1004
  async *ingestInput(input) {
505
1005
  if (Array.isArray(input)) {
506
1006
  for (const message of input)
@@ -675,11 +1175,75 @@ export class Agent {
675
1175
  const length = this.session.messages.length;
676
1176
  return Number.isSafeInteger(value) && value >= 0 ? Math.min(value, length) : length;
677
1177
  }
678
- async *maybeCompact() {
679
- if (this.opts.compaction === false)
1178
+ /**
1179
+ * How full the model's window is, in tokens: what the last request measured,
1180
+ * or, before one has (a new or reopened conversation), an estimate of the
1181
+ * next request — system prompt and tools included, which are a large share
1182
+ * of a short conversation.
1183
+ */
1184
+ contextTokens() {
1185
+ return this.contextBreakdown().total;
1186
+ }
1187
+ /**
1188
+ * The window, split the way the context panel draws it. Providers report one
1189
+ * number for the prompt, so the system prompt and the tool definitions are
1190
+ * estimated and the messages are what is left of the measured total; until
1191
+ * something is measured all three are estimates.
1192
+ */
1193
+ contextBreakdown() {
1194
+ const window = this.session.model.contextWindow;
1195
+ // Kept per transcript: the terminal asks on every frame it draws, and the
1196
+ // prompt is rebuilt from skills, memory and plugins each time it is made.
1197
+ const transcript = this.session.messages;
1198
+ let estimate = this.contextEstimate;
1199
+ if (!estimate || estimate.messages !== transcript || estimate.length !== transcript.length) {
1200
+ const request = this.buildRequest(new AbortController().signal);
1201
+ estimate = {
1202
+ messages: transcript,
1203
+ length: transcript.length,
1204
+ system: estimateStringTokens(request.system ?? ""),
1205
+ tools: request.tools ? estimateToolsTokens(request.tools) : 0,
1206
+ transcript: estimateMessagesTokens(transcript),
1207
+ };
1208
+ this.contextEstimate = estimate;
1209
+ }
1210
+ const measured = this.session.measuredContextTokens();
1211
+ const fixed = estimate.system + estimate.tools;
1212
+ const total = measured ?? fixed + estimate.transcript;
1213
+ const compaction = this.opts.compaction;
1214
+ return {
1215
+ total,
1216
+ window,
1217
+ system: estimate.system,
1218
+ tools: estimate.tools,
1219
+ messages: Math.max(0, total - fixed),
1220
+ measured: measured !== undefined,
1221
+ // Where the automatic kind fires: a share of what is left once the
1222
+ // reply has been given room (see shouldCompact).
1223
+ ...(compaction === false
1224
+ ? {}
1225
+ : { compactAt: compactionPoint(this.session.model, compaction?.threshold) }),
1226
+ };
1227
+ }
1228
+ contextEstimate;
1229
+ /**
1230
+ * Compact now, whatever the window says: the /compact command. Goes through
1231
+ * the same path as the automatic kind, so what it drops is archived for
1232
+ * recall and handed to memory extraction rather than simply discarded.
1233
+ * Returns what happened, or undefined when there was nothing safe to cut.
1234
+ */
1235
+ async compactNow() {
1236
+ let last;
1237
+ for await (const event of this.maybeCompact(true))
1238
+ last = event;
1239
+ return last;
1240
+ }
1241
+ async *maybeCompact(manual = false) {
1242
+ if (!manual && this.opts.compaction === false)
680
1243
  return;
681
1244
  const systemTokens = estimateStringTokens(this.systemPrompt());
682
- if (!shouldCompact(this.session.messages, this.session.model, systemTokens, this.opts.compaction)) {
1245
+ const options = this.opts.compaction || {};
1246
+ if (!manual && !shouldCompact(this.session.messages, this.session.model, systemTokens, options)) {
683
1247
  return;
684
1248
  }
685
1249
  await this.hooks.run({
@@ -695,7 +1259,9 @@ export class Agent {
695
1259
  // the session happens to be using.
696
1260
  const summarizer = this.summarizerModel();
697
1261
  const provider = this.session.providers.getProvider(summarizer.provider);
698
- const result = await compact(this.session.messages, provider, summarizer, this.opts.compaction ?? {});
1262
+ const result = await compact(this.session.messages, provider, summarizer,
1263
+ // Asked for by name, so keep less of the tail: the person wants room.
1264
+ manual ? { ...options, keepTailFraction: 0.15 } : options);
699
1265
  if (result.droppedMessages === 0)
700
1266
  return;
701
1267
  // Read before the transcript changes shape: the marker is a position in it.
@@ -723,7 +1289,23 @@ export class Agent {
723
1289
  yield { type: "notice", message: `Compaction failed: ${errorMessage(err)}` };
724
1290
  }
725
1291
  }
1292
+ /**
1293
+ * The tools offered to the model: the goal tool only while there is a goal,
1294
+ * and the computer tools only while this conversation has Computer Use on.
1295
+ */
1296
+ visibleTools() {
1297
+ const goal = activeGoal(this.state);
1298
+ const computer = computerOn(this.state);
1299
+ const worktrees = Boolean(this.opts.worktreeLauncher?.());
1300
+ const background = this.backgroundAvailable();
1301
+ return this.tools
1302
+ .visible(this.opts.toolFilter)
1303
+ .filter((tool) => (goal || tool.name !== GOAL_TOOL) && (computer || !isComputerTool(tool.name)))
1304
+ .filter((tool) => worktrees || tool.name !== WORKTREE_START_TOOL)
1305
+ .filter((tool) => background || ![TASK_OUTPUT_TOOL, TASK_STOP_TOOL, TASK_MESSAGE_TOOL].includes(tool.name));
1306
+ }
726
1307
  systemPrompt() {
1308
+ const goal = activeGoal(this.state);
727
1309
  return buildSystemPrompt({
728
1310
  systemPrompt: this.opts.systemPrompt,
729
1311
  appendSystemPrompt: this.opts.appendSystemPrompt,
@@ -732,10 +1314,16 @@ export class Agent {
732
1314
  permissionMode: this.session.permissions.getMode(),
733
1315
  projectInstructions: this.opts.projectInstructions,
734
1316
  mcpInstructions: this.opts.mcp?.serverInstructions(),
735
- toolNames: this.tools.visible(this.opts.toolFilter).map((t) => t.name),
1317
+ toolNames: this.visibleTools().map((t) => t.name),
736
1318
  // Only the descriptions — loading every skill body up front would
737
1319
  // defeat the point of having them be loadable.
738
- extraSections: [this.skills.promptSection(), this.memoryIndex, ...this.pluginSections].filter((section) => section !== undefined),
1320
+ extraSections: [
1321
+ this.skills.promptSection(),
1322
+ this.memoryIndex,
1323
+ ...this.pluginSections,
1324
+ ...(goal ? [goalPromptSection(goal)] : []),
1325
+ ...(computerOn(this.state) && this.tools.has("computer_list_apps") ? [computerPromptSection()] : []),
1326
+ ].filter((section) => section !== undefined),
739
1327
  });
740
1328
  }
741
1329
  buildRequest(signal) {
@@ -744,7 +1332,13 @@ export class Agent {
744
1332
  model,
745
1333
  system: this.systemPrompt(),
746
1334
  messages: this.session.messages,
747
- tools: model.supportsTools ? this.tools.schemas(this.opts.toolFilter) : undefined,
1335
+ tools: model.supportsTools
1336
+ ? this.visibleTools().map((tool) => ({
1337
+ name: tool.name,
1338
+ description: tool.description,
1339
+ inputSchema: tool.inputSchema,
1340
+ }))
1341
+ : undefined,
748
1342
  toolChoice: { type: "auto" },
749
1343
  thinking: thinkingFor(this.opts.thinking, this.session.effort),
750
1344
  cache: this.opts.cache ?? true,
@@ -814,7 +1408,10 @@ export class Agent {
814
1408
  // concurrently — one unsafe call forces the whole batch to serialize,
815
1409
  // because we cannot know how they interact.
816
1410
  const parallel = calls.length > 1 &&
817
- calls.every((call) => this.tools.get(call.name)?.concurrencySafe === true);
1411
+ calls.every((call) => {
1412
+ const tool = this.tools.get(call.name);
1413
+ return tool?.concurrencySafe === true || tool?.concurrencySafeFor?.(call.input, this) === true;
1414
+ });
818
1415
  if (!parallel) {
819
1416
  for (const call of calls) {
820
1417
  yield* this.executeOne(call, signal, results);
@@ -883,18 +1480,24 @@ export class Agent {
883
1480
  this.stopRequested = reason;
884
1481
  return;
885
1482
  }
886
- // The workspace policy runs before the approval prompt: a write the
887
- // policy forbids should never be offered for approval at all.
1483
+ // The workspace policy runs before the approval prompt. A write outside
1484
+ // the roots is a question for the person, not a refusal: they are the one
1485
+ // who knows whether that folder is fair game, and the answer to it stands
1486
+ // in for the ordinary approval. A read-only workspace, plan mode, or no
1487
+ // one to ask still refuses outright.
1488
+ let approved = false;
888
1489
  if (this.opts.workspace && !tool.readOnly) {
889
1490
  const check = checkWrite(this.opts.workspace, call.name, input, this.session.cwd);
890
1491
  if (!check.allowed) {
891
- const reason = check.reason ?? "blocked by the workspace policy";
892
- yield { type: "permission_result", toolCallId: call.id, allowed: false, reason };
893
- results.push(errorResult(call, reason));
894
- return;
1492
+ const outside = yield* this.askOutsideRoots(call, input, title, check, signal);
1493
+ if (!outside) {
1494
+ results.push(errorResult(call, check.reason ?? "blocked by the workspace policy"));
1495
+ return;
1496
+ }
1497
+ approved = true;
895
1498
  }
896
1499
  }
897
- const allowed = yield* this.checkPermission(call, tool, input, title, signal);
1500
+ const allowed = approved || (yield* this.checkPermission(call, tool, input, title, signal));
898
1501
  if (!allowed)
899
1502
  return void results.push(errorResult(call, permissionDenialText(title)));
900
1503
  const ctx = {
@@ -1021,6 +1624,38 @@ export class Agent {
1021
1624
  isError: result.isError,
1022
1625
  });
1023
1626
  }
1627
+ /**
1628
+ * Ask about a write the workspace policy would refuse.
1629
+ *
1630
+ * Bypass mode approves it as it approves everything else. Read-only and plan
1631
+ * mode refuse it, as does a frontend with no way to ask.
1632
+ */
1633
+ async *askOutsideRoots(call, input, title, check, signal) {
1634
+ const mode = this.session.permissions.getMode();
1635
+ const refuse = function* (reason) {
1636
+ yield { type: "permission_result", toolCallId: call.id, allowed: false, reason };
1637
+ return false;
1638
+ };
1639
+ if (this.opts.workspace?.mode === "read-only" || mode === "plan" || !this.opts.permissionPrompt) {
1640
+ return yield* refuse(check.reason ?? "blocked by the workspace policy");
1641
+ }
1642
+ if (mode === "bypassPermissions")
1643
+ return true;
1644
+ const request = {
1645
+ toolName: call.name,
1646
+ input,
1647
+ title,
1648
+ reason: `Writes outside the project: ${check.offending ?? ""}`.trim(),
1649
+ ...this.interactionPlace(),
1650
+ toolCallId: call.id,
1651
+ };
1652
+ yield { type: "permission_request", request };
1653
+ const prompt = this.opts.permissionPrompt;
1654
+ const answer = await untilAborted(signal, () => prompt(request, signal), false, false);
1655
+ const allowed = typeof answer === "boolean" ? answer : answer.allow;
1656
+ yield { type: "permission_result", toolCallId: call.id, allowed };
1657
+ return allowed;
1658
+ }
1024
1659
  async *checkPermission(call, tool, input, title, signal) {
1025
1660
  const decision = this.session.permissions.evaluate(tool, input);
1026
1661
  if (decision.behavior === "allow")
@@ -1031,6 +1666,7 @@ export class Agent {
1031
1666
  toolCallId: call.id,
1032
1667
  allowed: false,
1033
1668
  reason: decision.message,
1669
+ ...(decision.rule === "plan" ? { plan: true } : {}),
1034
1670
  };
1035
1671
  return false;
1036
1672
  }
@@ -1040,7 +1676,7 @@ export class Agent {
1040
1676
  title,
1041
1677
  reason: decision.rule ? `matched rule ${decision.rule}` : undefined,
1042
1678
  ...(decision.rule ? { rule: decision.rule } : {}),
1043
- sessionId: this.session.id,
1679
+ ...this.interactionPlace(),
1044
1680
  toolCallId: call.id,
1045
1681
  };
1046
1682
  yield { type: "permission_request", request };