@oharness/engine 0.9.3 → 0.11.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 (170) hide show
  1. package/dist/acp/claude.d.ts +34 -0
  2. package/dist/acp/claude.js +122 -0
  3. package/dist/acp/claude.js.map +1 -0
  4. package/dist/acp/client.d.ts +193 -0
  5. package/dist/acp/client.js +279 -0
  6. package/dist/acp/client.js.map +1 -0
  7. package/dist/acp/runner.d.ts +80 -0
  8. package/dist/acp/runner.js +159 -0
  9. package/dist/acp/runner.js.map +1 -0
  10. package/dist/agent/agent.d.ts +258 -2
  11. package/dist/agent/agent.js +1010 -53
  12. package/dist/agent/agent.js.map +1 -1
  13. package/dist/agent/background.d.ts +135 -0
  14. package/dist/agent/background.js +307 -0
  15. package/dist/agent/background.js.map +1 -0
  16. package/dist/agent/compaction.d.ts +7 -0
  17. package/dist/agent/compaction.js +11 -3
  18. package/dist/agent/compaction.js.map +1 -1
  19. package/dist/agent/goal.d.ts +36 -0
  20. package/dist/agent/goal.js +71 -0
  21. package/dist/agent/goal.js.map +1 -0
  22. package/dist/agent/hooks.js +6 -6
  23. package/dist/agent/hooks.js.map +1 -1
  24. package/dist/agent/permissions.d.ts +6 -0
  25. package/dist/agent/permissions.js +2 -1
  26. package/dist/agent/permissions.js.map +1 -1
  27. package/dist/agent/session.d.ts +38 -0
  28. package/dist/agent/session.js +91 -2
  29. package/dist/agent/session.js.map +1 -1
  30. package/dist/agent/store.d.ts +25 -0
  31. package/dist/agent/store.js +21 -0
  32. package/dist/agent/store.js.map +1 -1
  33. package/dist/agent/subagent.d.ts +21 -0
  34. package/dist/agent/subagent.js +17 -1
  35. package/dist/agent/subagent.js.map +1 -1
  36. package/dist/agent/workspace.d.ts +9 -0
  37. package/dist/agent/workspace.js +59 -5
  38. package/dist/agent/workspace.js.map +1 -1
  39. package/dist/auth/index.d.ts +3 -1
  40. package/dist/auth/index.js +3 -1
  41. package/dist/auth/index.js.map +1 -1
  42. package/dist/auth/manager.d.ts +9 -0
  43. package/dist/auth/manager.js +71 -6
  44. package/dist/auth/manager.js.map +1 -1
  45. package/dist/auth/oauth.d.ts +33 -0
  46. package/dist/auth/oauth.js +59 -8
  47. package/dist/auth/oauth.js.map +1 -1
  48. package/dist/auth/providers/account.js +5 -4
  49. package/dist/auth/providers/account.js.map +1 -1
  50. package/dist/auth/providers/anthropic.d.ts +25 -5
  51. package/dist/auth/providers/anthropic.js +47 -6
  52. package/dist/auth/providers/anthropic.js.map +1 -1
  53. package/dist/auth/providers/chatgpt.d.ts +8 -0
  54. package/dist/auth/providers/chatgpt.js +180 -0
  55. package/dist/auth/providers/chatgpt.js.map +1 -0
  56. package/dist/auth/providers/openrouter.d.ts +2 -0
  57. package/dist/auth/providers/openrouter.js +101 -0
  58. package/dist/auth/providers/openrouter.js.map +1 -0
  59. package/dist/auth/types.d.ts +15 -0
  60. package/dist/auth/types.js.map +1 -1
  61. package/dist/browser/index.d.ts +1 -1
  62. package/dist/browser/plugin.d.ts +6 -1
  63. package/dist/browser/plugin.js.map +1 -1
  64. package/dist/browser/session.d.ts +47 -0
  65. package/dist/browser/session.js +177 -2
  66. package/dist/browser/session.js.map +1 -1
  67. package/dist/browser/tools.js +142 -18
  68. package/dist/browser/tools.js.map +1 -1
  69. package/dist/computer/arbiter.d.ts +69 -0
  70. package/dist/computer/arbiter.js +128 -0
  71. package/dist/computer/arbiter.js.map +1 -0
  72. package/dist/computer/cua-driver.d.ts +174 -0
  73. package/dist/computer/cua-driver.js +597 -0
  74. package/dist/computer/cua-driver.js.map +1 -0
  75. package/dist/computer/helper-client.d.ts +80 -0
  76. package/dist/computer/helper-client.js +166 -0
  77. package/dist/computer/helper-client.js.map +1 -0
  78. package/dist/computer/index.d.ts +16 -0
  79. package/dist/computer/index.js +9 -0
  80. package/dist/computer/index.js.map +1 -0
  81. package/dist/computer/mode.d.ts +34 -0
  82. package/dist/computer/mode.js +52 -0
  83. package/dist/computer/mode.js.map +1 -0
  84. package/dist/computer/plugin.d.ts +24 -0
  85. package/dist/computer/plugin.js +17 -0
  86. package/dist/computer/plugin.js.map +1 -0
  87. package/dist/computer/policy.d.ts +29 -0
  88. package/dist/computer/policy.js +127 -0
  89. package/dist/computer/policy.js.map +1 -0
  90. package/dist/computer/session.d.ts +228 -0
  91. package/dist/computer/session.js +493 -0
  92. package/dist/computer/session.js.map +1 -0
  93. package/dist/computer/target.d.ts +206 -0
  94. package/dist/computer/target.js +31 -0
  95. package/dist/computer/target.js.map +1 -0
  96. package/dist/computer/tools.d.ts +8 -0
  97. package/dist/computer/tools.js +664 -0
  98. package/dist/computer/tools.js.map +1 -0
  99. package/dist/config.d.ts +7 -0
  100. package/dist/config.js.map +1 -1
  101. package/dist/core/tokens.js +4 -0
  102. package/dist/core/tokens.js.map +1 -1
  103. package/dist/core/types.d.ts +16 -1
  104. package/dist/core/types.js.map +1 -1
  105. package/dist/harness.d.ts +128 -1
  106. package/dist/harness.js +287 -30
  107. package/dist/harness.js.map +1 -1
  108. package/dist/index.d.ts +3 -0
  109. package/dist/index.js +2 -0
  110. package/dist/index.js.map +1 -1
  111. package/dist/mcp/manager.d.ts +15 -0
  112. package/dist/mcp/manager.js +28 -1
  113. package/dist/mcp/manager.js.map +1 -1
  114. package/dist/mcp/transport.js +3 -0
  115. package/dist/mcp/transport.js.map +1 -1
  116. package/dist/providers/catalog.js +6 -0
  117. package/dist/providers/catalog.js.map +1 -1
  118. package/dist/providers/catalog.json +14 -3
  119. package/dist/providers/discovery.js +10 -2
  120. package/dist/providers/discovery.js.map +1 -1
  121. package/dist/providers/models-dev.d.ts +15 -0
  122. package/dist/providers/models-dev.js +15 -0
  123. package/dist/providers/models-dev.js.map +1 -1
  124. package/dist/providers/models-dev.json +114 -102
  125. package/dist/providers/piai.d.ts +54 -2
  126. package/dist/providers/piai.js +154 -9
  127. package/dist/providers/piai.js.map +1 -1
  128. package/dist/providers/registry.d.ts +16 -1
  129. package/dist/providers/registry.js +42 -11
  130. package/dist/providers/registry.js.map +1 -1
  131. package/dist/providers/types.d.ts +6 -0
  132. package/dist/providers/types.js.map +1 -1
  133. package/dist/runtime-plugins/builtin/studio.d.ts +7 -0
  134. package/dist/runtime-plugins/builtin/studio.js +69 -4
  135. package/dist/runtime-plugins/builtin/studio.js.map +1 -1
  136. package/dist/runtime-plugins/cloud-connector.js +5 -0
  137. package/dist/runtime-plugins/cloud-connector.js.map +1 -1
  138. package/dist/schedule/scheduler.d.ts +38 -0
  139. package/dist/schedule/scheduler.js +101 -1
  140. package/dist/schedule/scheduler.js.map +1 -1
  141. package/dist/tools/builtin/bash.d.ts +6 -0
  142. package/dist/tools/builtin/bash.js +8 -11
  143. package/dist/tools/builtin/bash.js.map +1 -1
  144. package/dist/tools/builtin/recall.js +4 -0
  145. package/dist/tools/builtin/recall.js.map +1 -1
  146. package/dist/tools/builtin/routine.d.ts +9 -0
  147. package/dist/tools/builtin/routine.js +23 -2
  148. package/dist/tools/builtin/routine.js.map +1 -1
  149. package/dist/tools/builtin/task-control.d.ts +21 -0
  150. package/dist/tools/builtin/task-control.js +120 -0
  151. package/dist/tools/builtin/task-control.js.map +1 -0
  152. package/dist/tools/builtin/task.js +50 -9
  153. package/dist/tools/builtin/task.js.map +1 -1
  154. package/dist/tools/builtin/todo.d.ts +1 -1
  155. package/dist/tools/builtin/worktree.d.ts +10 -0
  156. package/dist/tools/builtin/worktree.js +52 -0
  157. package/dist/tools/builtin/worktree.js.map +1 -0
  158. package/dist/tools/define.d.ts +4 -1
  159. package/dist/tools/define.js +2 -0
  160. package/dist/tools/define.js.map +1 -1
  161. package/dist/tools/types.d.ts +112 -0
  162. package/dist/tools/types.js.map +1 -1
  163. package/dist/util/shell.d.ts +30 -0
  164. package/dist/util/shell.js +70 -0
  165. package/dist/util/shell.js.map +1 -1
  166. package/dist/worktree/index.d.ts +109 -0
  167. package/dist/worktree/index.js +463 -0
  168. package/dist/worktree/index.js.map +1 -0
  169. package/package.json +3 -3
  170. package/skills/media-generation/SKILL.md +22 -1
@@ -1,17 +1,26 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { GOAL_MAX_TURNS, GOAL_TOOL, MAX_GOAL_NUDGES, activeGoal, goalNudge, goalPromptSection, goalTool } from "./goal.js";
4
+ import { COMPUTER_ENABLE_TOOL, COMPUTER_KEY, computerOn, computerPromptSection, isComputerTool } from "../computer/mode.js";
1
5
  import { randomUUID } from "node:crypto";
2
6
  import { errorMessage } from "../core/errors.js";
3
- import { estimateStringTokens } from "../core/tokens.js";
7
+ import { estimateMessagesTokens, estimateStringTokens, estimateToolsTokens } from "../core/tokens.js";
4
8
  import { assistantMessage, emptyUsage, messageText, userMessage } from "../core/types.js";
5
9
  import { thinkingFor } from "../providers/types.js";
10
+ import { WORKTREE_START_TOOL, worktreeStartTool } from "../tools/builtin/worktree.js";
11
+ import { BASH_CWD_KEY } from "../tools/builtin/bash.js";
12
+ import { createWorktree, finishWorktree, worktreeStatus } from "../worktree/index.js";
6
13
  import { createTaskTool } from "../tools/builtin/task.js";
14
+ import { TASK_MESSAGE_TOOL, TASK_OUTPUT_TOOL, TASK_STOP_TOOL, taskMessageTool, taskOutputTool, taskStopTool, } from "../tools/builtin/task-control.js";
15
+ import { BackgroundTasks, formatNotification } from "./background.js";
7
16
  import { ARCHIVE_READER } from "../tools/builtin/recall.js";
8
17
  import { ARTIFACT_METADATA_KEY } from "../tools/builtin/artifact.js";
9
18
  import { previewJson } from "../util/json.js";
10
19
  import { AsyncQueue, mergeStreams } from "../util/queue.js";
11
20
  import { createLogger } from "../util/logger.js";
12
- import { compact, shouldCompact } from "./compaction.js";
21
+ import { compact, compactionPoint, shouldCompact } from "./compaction.js";
13
22
  import { capToolResult } from "./overflow.js";
14
- import { checkWrite, resolveWithin } from "./workspace.js";
23
+ import { checkWrite, resolveWithin, retargetPolicy } from "./workspace.js";
15
24
  import { SkillRegistry } from "./skills.js";
16
25
  import { MemoryStore, recallFromStores } from "./memory.js";
17
26
  import { DEFAULT_MEMORY_POLICY } from "./memory-limits.js";
@@ -21,9 +30,21 @@ import { createSkillTool } from "../tools/builtin/skill.js";
21
30
  import { createMcpResourceTools } from "../tools/builtin/mcp-resource.js";
22
31
  import { HookRegistry } from "./hooks.js";
23
32
  import { buildSystemPrompt } from "./prompt.js";
33
+ import { AcpSignInRequired, AcpUnavailable, runAcpTurn } from "../acp/runner.js";
24
34
  import { ASK_USER_TOOL } from "../tools/questions.js";
25
35
  import { SubagentRegistry } from "./subagent.js";
36
+ function withoutSignal(opts) {
37
+ const { signal: _signal, ...rest } = opts;
38
+ return rest;
39
+ }
26
40
  const DEFAULT_MAX_TURNS = 60;
41
+ /**
42
+ * How long an idle agent lets background results gather before asking for a
43
+ * turn to read them, so tasks ending together are answered together.
44
+ */
45
+ const TASK_WAKE_DELAY_MS = 500;
46
+ /** How long task changes gather before they are written down. */
47
+ const TASK_SAVE_DELAY_MS = 1000;
27
48
  /** Session state key: ids of notes attached since the last compaction. */
28
49
  const RECALLED = "memory:recalled";
29
50
  /** Session state key: how much of the transcript an end-of-run extraction has covered. */
@@ -57,14 +78,6 @@ function recalledIds(state, messages) {
57
78
  }
58
79
  return ids;
59
80
  }
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
81
  export class Agent {
69
82
  session;
70
83
  tools;
@@ -95,6 +108,18 @@ export class Agent {
95
108
  pluginSections = [];
96
109
  /** Recall over `memory` for agents embedded without a plugin host. */
97
110
  legacyMemory;
111
+ /** Subagents running in the background; made on first use. */
112
+ background;
113
+ wakeTimer;
114
+ /** Frontends watching background tasks, kept until the tasks exist. */
115
+ changeListeners = new Set();
116
+ /** What each background task was asked, and its subagent once it has one. */
117
+ taskStates = new Map();
118
+ restoring;
119
+ saveTimer;
120
+ /** Closed or deleted: nothing more is saved, and nothing more starts. */
121
+ tasksClosed = false;
122
+ saveChain = Promise.resolve();
98
123
  constructor(opts) {
99
124
  this.opts = opts;
100
125
  this.session = opts.session;
@@ -128,11 +153,25 @@ export class Agent {
128
153
  if (!this.tools.has("skill") && this.skills.size > 0) {
129
154
  this.tools.register(createSkillTool(this.skills));
130
155
  }
156
+ if (!this.tools.has(GOAL_TOOL))
157
+ this.tools.register(goalTool);
158
+ // Shown only where a frontend can host the session it starts; see visibleTools.
159
+ if (!this.tools.has(WORKTREE_START_TOOL))
160
+ this.tools.register(worktreeStartTool);
131
161
  // The task tool has to be built here: its description enumerates the
132
162
  // subagent types that actually exist in this configuration.
133
163
  if (!this.tools.has("task") && this.subagents.list().length > 0) {
134
164
  this.tools.register(createTaskTool(this));
135
165
  }
166
+ // Shown only where background tasks can be offered; see visibleTools.
167
+ if (this.tools.has("task")) {
168
+ if (!this.tools.has(TASK_OUTPUT_TOOL))
169
+ this.tools.register(taskOutputTool);
170
+ if (!this.tools.has(TASK_STOP_TOOL))
171
+ this.tools.register(taskStopTool);
172
+ if (!this.tools.has(TASK_MESSAGE_TOOL))
173
+ this.tools.register(taskMessageTool);
174
+ }
136
175
  }
137
176
  /**
138
177
  * Announce a model switch so a UI can render it and hooks can react.
@@ -238,35 +277,389 @@ export class Agent {
238
277
  resolvePath(input, access) {
239
278
  return resolveWithin(this.opts.workspace, this.session.cwd, input, access);
240
279
  }
280
+ async startWorktree(opts) {
281
+ const launch = this.opts.worktreeLauncher?.();
282
+ if (!launch)
283
+ throw new Error("this frontend cannot start a session in a worktree");
284
+ return launch({ ...opts, from: { cwd: this.session.cwd, sessionId: this.session.id } });
285
+ }
241
286
  listSubagentTypes() {
242
287
  return this.subagents.summaries();
243
288
  }
244
289
  async runSubagent(opts) {
290
+ const child = await this.prepareSubagent(opts);
291
+ try {
292
+ const result = await this.runChild(child, opts.prompt, opts.signal);
293
+ // Propagate state the subagent built up (e.g. files it read). Not from a
294
+ // worktree: what it read there are paths in another tree.
295
+ if (!child.worktree)
296
+ for (const [key, value] of child.agent.session.state)
297
+ this.session.state.set(key, value);
298
+ // A worktree it left changes in is named in the result: the parent has
299
+ // to decide what happens to that work.
300
+ const kept = await this.worktreeNote(child);
301
+ const text = kept
302
+ ? `${result.text}\n\n[worktree] ${kept} It is kept: review it, then merge, push or apply it, or discard it from the sidebar.`
303
+ : result.text;
304
+ return { text, turns: result.turns, usage: result.usage };
305
+ }
306
+ finally {
307
+ await this.disposeChild(child);
308
+ }
309
+ }
310
+ // --- Background tasks ----------------------------------------------------
311
+ /**
312
+ * Subagents running behind this conversation, offered only while someone
313
+ * can start a turn to read their results in. Once any exist they stay
314
+ * reachable, so they can still be listed, messaged and stopped.
315
+ */
316
+ get backgroundTasks() {
317
+ if (!this.backgroundAvailable())
318
+ return undefined;
319
+ // Read without making the table: every open conversation is asked for
320
+ // its list, and most never start a task.
321
+ return {
322
+ start: (opts) => {
323
+ const state = { opts: withoutSignal(opts) };
324
+ const view = this.tasks().start(this.backgroundSpec(state));
325
+ this.taskStates.set(view.id, state);
326
+ return view;
327
+ },
328
+ get: (id) => this.background?.get(id),
329
+ list: () => this.background?.list() ?? [],
330
+ stop: (id) => this.background?.stop(id) ?? false,
331
+ send: (id, message) => this.background?.send(id, message) ?? "unknown",
332
+ dismiss: (id) => this.background?.dismiss(id) ?? false,
333
+ };
334
+ }
335
+ /** A task's conversation so far, for a frontend to show. */
336
+ backgroundTranscript(id) {
337
+ return this.background?.transcript(id);
338
+ }
339
+ /** Every change to a background task, for a frontend to redraw from. */
340
+ onBackgroundChange(listener) {
341
+ this.changeListeners.add(listener);
342
+ const off = this.background?.onChange(listener);
343
+ return () => {
344
+ this.changeListeners.delete(listener);
345
+ off?.();
346
+ };
347
+ }
348
+ /**
349
+ * The conversation is being closed, not ended: write its tasks down as they
350
+ * stand, then stop them. Opened again, the ones that were running come back
351
+ * interrupted and can be followed up — as after a restart.
352
+ */
353
+ async closeBackgroundTasks() {
354
+ if (this.tasksClosed)
355
+ return;
356
+ if (this.saveTimer)
357
+ clearTimeout(this.saveTimer);
358
+ this.saveTimer = undefined;
359
+ await this.saveTasks();
360
+ this.endBackgroundTasks();
361
+ }
362
+ /**
363
+ * The conversation is being deleted: stop its tasks and write nothing more.
364
+ * Resolves once a write already under way has landed, so deleting the
365
+ * store afterwards cannot be undone by it.
366
+ */
367
+ async discardBackgroundTasks() {
368
+ this.endBackgroundTasks();
369
+ await this.saveChain;
370
+ }
371
+ endBackgroundTasks() {
372
+ this.tasksClosed = true;
373
+ if (this.wakeTimer)
374
+ clearTimeout(this.wakeTimer);
375
+ if (this.saveTimer)
376
+ clearTimeout(this.saveTimer);
377
+ this.wakeTimer = undefined;
378
+ this.saveTimer = undefined;
379
+ this.background?.stopAll();
380
+ this.background?.detach();
381
+ }
382
+ backgroundAvailable() {
383
+ return Boolean(this.background) || Boolean(this.opts.taskWaker?.());
384
+ }
385
+ tasks() {
386
+ if (!this.background) {
387
+ this.background = new BackgroundTasks({
388
+ ...(this.opts.maxBackgroundTasks ? { maxConcurrent: this.opts.maxBackgroundTasks } : {}),
389
+ ...(this.opts.lifecycleSignal ? { signal: this.opts.lifecycleSignal } : {}),
390
+ });
391
+ // While a run is going its own loop reads results in at the next gap;
392
+ // only an idle agent needs someone to start a turn for them. And an
393
+ // ended task is written down at once rather than after the usual pause:
394
+ // its result is the one thing a restart must not lose.
395
+ this.background.onSettled(() => {
396
+ if (!this.running)
397
+ this.scheduleWake();
398
+ void this.saveTasks();
399
+ });
400
+ for (const listener of this.changeListeners)
401
+ this.background.onChange(listener);
402
+ this.background.onChange(() => this.scheduleSave());
403
+ }
404
+ return this.background;
405
+ }
406
+ /**
407
+ * A background task's subagent, made for each run and let go after it.
408
+ *
409
+ * Between runs a task holds only its conversation and, if it changed
410
+ * anything, its worktree: no browser, no claim on the computer, no agent.
411
+ * A follow-up — or the first one after a restart — builds the subagent
412
+ * again from those, so both paths are the same path.
413
+ */
414
+ backgroundSpec(state) {
415
+ const once = async (input, signal, report) => {
416
+ const child = await this.prepareSubagent(state.opts, {
417
+ messages: state.messages ?? [],
418
+ ...(state.worktree ? { worktree: state.worktree } : {}),
419
+ ...(state.equipment ? { equipment: state.equipment } : {}),
420
+ });
421
+ // Its tree is gone — removed by hand, or after a restart — and it now
422
+ // works in a new one: said up front, since its conversation names the
423
+ // old paths.
424
+ const moved = state.worktree && child.worktree && state.worktree.path !== child.worktree.path
425
+ ? `[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`
426
+ : "";
427
+ state.child = child;
428
+ state.equipment = child.equipment;
429
+ if (child.worktree)
430
+ state.worktree = child.worktree;
431
+ try {
432
+ const result = await this.runChild(child, moved + input, signal, report);
433
+ const kept = await this.worktreeNote(child);
434
+ return { ...result, ...(kept ? { worktree: kept } : {}) };
435
+ }
436
+ finally {
437
+ // The agent goes; its conversation, tree and browser stay with the
438
+ // task until it is stopped, or its conversation closed.
439
+ state.messages = [...child.agent.session.messages];
440
+ state.child = undefined;
441
+ }
442
+ };
443
+ return {
444
+ agentType: state.opts.agentType,
445
+ description: state.opts.description || state.opts.agentType,
446
+ run: (signal, report) => once(state.opts.prompt, signal, report),
447
+ // A task that failed before its subagent ever ran has no conversation to
448
+ // continue; the follow-up then goes with the task it was given.
449
+ resume: (message, signal, report) => once(state.messages?.length ? message : `${state.opts.prompt}\n\n${message}`, signal, report),
450
+ interject: (message) => Boolean(state.child?.agent.interject(message)),
451
+ transcript: () => state.child?.agent.session.messages ?? state.messages ?? [],
452
+ dispose: async () => {
453
+ await state.equipment?.release().catch(() => { });
454
+ state.equipment = undefined;
455
+ if (state.worktree)
456
+ await this.dropIfUntouched(state.worktree);
457
+ },
458
+ };
459
+ }
460
+ /**
461
+ * Put back the background tasks this conversation had before a restart.
462
+ *
463
+ * Called by a frontend when it opens the conversation; safe to call again.
464
+ * A task that was still running when the app closed comes back as failed
465
+ * and says why — its subagent's process is gone — but keeps its
466
+ * conversation, so a follow-up carries on from where it stopped. A result
467
+ * nobody had been told about is still read in with the next turn.
468
+ */
469
+ async restoreBackgroundTasks() {
470
+ if (this.restoring)
471
+ return this.restoring;
472
+ this.restoring = (async () => {
473
+ if (this.tasksClosed)
474
+ return 0;
475
+ const saved = await this.session.loadTasks().catch(() => []);
476
+ // Closed while it was reading: bringing them back now would make a
477
+ // table nothing will ever close.
478
+ if (this.tasksClosed)
479
+ return 0;
480
+ let restored = 0;
481
+ for (const raw of saved) {
482
+ const item = raw;
483
+ if (!item?.view?.id || !item.options?.agentType || !this.subagents.get(item.options.agentType))
484
+ continue;
485
+ const view = { ...item.view };
486
+ let delivered = item.delivered === true;
487
+ if (view.status === "running" || view.status === "queued") {
488
+ view.status = "failed";
489
+ view.error = "Interrupted: OHarness closed while this task was running. Send it a message to carry on from where it stopped.";
490
+ view.endedAt = Date.now();
491
+ delete view.activity;
492
+ delivered = true;
493
+ }
494
+ const state = {
495
+ opts: item.options,
496
+ messages: Array.isArray(item.messages) ? item.messages : [],
497
+ ...(item.worktree ? { worktree: item.worktree } : {}),
498
+ };
499
+ this.taskStates.set(view.id, state);
500
+ this.tasks().restore(view, this.backgroundSpec(state), delivered);
501
+ restored++;
502
+ }
503
+ return restored;
504
+ })();
505
+ return this.restoring;
506
+ }
507
+ /** Write the tasks down a moment after they change, so a burst is one write. */
508
+ scheduleSave() {
509
+ if (this.saveTimer || this.tasksClosed)
510
+ return;
511
+ this.saveTimer = setTimeout(() => {
512
+ this.saveTimer = undefined;
513
+ void this.saveTasks();
514
+ }, TASK_SAVE_DELAY_MS);
515
+ }
516
+ /**
517
+ * One write at a time, in order. Each takes its snapshot when its turn
518
+ * comes, so a later state is never overwritten by an earlier one landing
519
+ * last — a task that ended would come back as running.
520
+ */
521
+ saveTasks() {
522
+ const next = this.saveChain.then(() => this.writeTasks());
523
+ this.saveChain = next.catch(() => { });
524
+ return next;
525
+ }
526
+ async writeTasks() {
527
+ // Shutting down stops every task; writing that down would bring them back
528
+ // as stopped for good, when they were only interrupted. The last save from
529
+ // while they ran is the truer record. A closed conversation has already
530
+ // written its own.
531
+ if (!this.background || this.tasksClosed || this.opts.lifecycleSignal?.aborted)
532
+ return;
533
+ const tasks = this.background.saved().flatMap(({ view, delivered }) => {
534
+ const state = this.taskStates.get(view.id);
535
+ if (!state)
536
+ return [];
537
+ const worktree = state.child?.worktree ?? state.worktree;
538
+ return [{
539
+ view,
540
+ delivered,
541
+ options: state.opts,
542
+ messages: [...(state.child?.agent.session.messages ?? state.messages ?? [])],
543
+ ...(worktree ? { worktree } : {}),
544
+ }];
545
+ });
546
+ await this.session.saveTasks(tasks).catch((err) => {
547
+ this.logger.warn("could not save background tasks", { error: errorMessage(err) });
548
+ });
549
+ }
550
+ scheduleWake() {
551
+ if (this.wakeTimer || this.tasksClosed)
552
+ return;
553
+ this.wakeTimer = setTimeout(() => {
554
+ this.wakeTimer = undefined;
555
+ if (this.running || !this.background?.hasUndelivered())
556
+ return;
557
+ this.opts.taskWaker?.()?.(this.session.id);
558
+ }, TASK_WAKE_DELAY_MS);
559
+ }
560
+ /**
561
+ * Read the results of background tasks that have ended into the
562
+ * conversation, as one message saying what is still running.
563
+ *
564
+ * Synthetic, like a goal nudge: nobody typed it, so it skips the prompt hook
565
+ * and memory recall, and never titles the session.
566
+ */
567
+ async *takeNotices() {
568
+ const ended = this.background?.takeUndelivered() ?? [];
569
+ if (!ended.length)
570
+ return false;
571
+ const text = formatNotification(ended, this.background?.active() ?? []);
572
+ await this.session.addMessage({
573
+ role: "user",
574
+ content: [{ type: "text", text }],
575
+ // What a transcript needs to draw it as a card rather than as text
576
+ // somebody typed: which tasks, and how each ended.
577
+ meta: {
578
+ timestamp: Date.now(),
579
+ synthetic: true,
580
+ taskNotification: ended.map((task) => ({ id: task.id, description: task.description, status: task.status })),
581
+ },
582
+ });
583
+ yield { type: "task_notification", tasks: ended, text };
584
+ return true;
585
+ }
586
+ /** Where this agent's permission cards go, and who they say is asking. */
587
+ interactionPlace() {
588
+ return {
589
+ sessionId: this.opts.interactionSessionId ?? this.session.id,
590
+ ...(this.opts.interactionLabel ? { from: this.opts.interactionLabel } : {}),
591
+ };
592
+ }
593
+ // --- Subagents -----------------------------------------------------------
594
+ /** A subagent ready to run: its agent, the tree it works in, what it holds. */
595
+ async prepareSubagent(opts, seed) {
245
596
  const definition = this.subagents.get(opts.agentType);
246
597
  if (!definition)
247
598
  throw new Error(`unknown agent type: ${opts.agentType}`);
248
599
  // A subagent gets its own transcript and a filtered tool set, but shares
249
600
  // the parent's scratch state so file-read tracking stays coherent.
250
601
  const { Session: SessionClass } = await import("./session.js");
602
+ // Its own tree when asked for, or when the agent type always wants one:
603
+ // several editing subagents in one checkout would trip over each other.
604
+ // Branched from this checkout's HEAD rather than the remote, so it starts
605
+ // from the commits the parent is looking at.
606
+ const isolated = opts.isolation === "worktree" || definition.isolation === "worktree";
607
+ // A restored task goes back to its own tree while that still exists.
608
+ const kept = seed?.worktree && (await worktreeStatus(seed.worktree).catch(() => undefined))?.exists ? seed.worktree : undefined;
609
+ const worktree = kept ?? (isolated
610
+ ? await createWorktree(this.session.cwd, {
611
+ task: opts.description || definition.name,
612
+ slug: opts.description ? undefined : `${definition.name}-agent`,
613
+ base: "HEAD",
614
+ fetch: false,
615
+ })
616
+ : undefined);
617
+ const childCwd = worktree?.cwd ?? this.session.cwd;
251
618
  const child = new SessionClass({
252
- cwd: this.session.cwd,
619
+ cwd: childCwd,
253
620
  model: opts.model ?? definition.model ?? this.session.model.id,
254
621
  providers: this.session.providers,
255
622
  permissionMode: this.session.permissions.getMode(),
256
623
  store: false,
624
+ // Through the constructor rather than added one by one: it drops a
625
+ // tail a run was cut off in — a tool call with no result — which no
626
+ // provider accepts a following message after.
627
+ ...(seed?.messages.length ? { messages: [...seed.messages] } : {}),
257
628
  });
258
629
  for (const [key, value] of this.session.state)
259
630
  child.state.set(key, value);
631
+ // A shell that had cd'd somewhere in the parent's tree must start in its own.
632
+ if (worktree)
633
+ child.state.delete(BASH_CWD_KEY);
634
+ // Control of the screen is never inherited by default: a subagent works
635
+ // unattended, and drives the computer only when asked to and only while
636
+ // the conversation that asked has it switched on.
637
+ if (!(opts.computer && computerOn(this.session.state)))
638
+ child.state.delete(COMPUTER_KEY);
260
639
  child.permissions.onDecision((entry) => {
261
640
  this.audit({ type: "permission", timestamp: Date.now(), sessionId: child.id, entry });
262
641
  });
263
642
  // A subagent does not ask the person questions. Its events never reach the
264
643
  // parent's stream, so a card it raised would have no call to belong to —
265
644
  // and needing to ask usually means the delegation was underspecified,
266
- // which is the parent's to resolve.
645
+ // which is the parent's to resolve. Nor does it see the parent's
646
+ // background tasks: it has none of its own.
267
647
  const childTools = definition.tools ? this.tools.filtered(definition.tools) : this.tools.clone();
268
648
  childTools.unregister(ASK_USER_TOOL);
269
- const childAgent = new Agent({
649
+ // Nor may it switch the screen on for itself; see the COMPUTER_KEY rule above.
650
+ childTools.unregister(COMPUTER_ENABLE_TOOL);
651
+ childTools.unregister(TASK_OUTPUT_TOOL);
652
+ childTools.unregister(TASK_STOP_TOOL);
653
+ childTools.unregister(TASK_MESSAGE_TOOL);
654
+ const label = opts.description ? `${definition.name}: ${opts.description}` : definition.name;
655
+ // Its own browser tabs and its own place in the computer's leases, so two
656
+ // subagents — or a subagent and its parent — never act on one page.
657
+ let equipment = seed?.equipment;
658
+ if (equipment?.bind)
659
+ equipment.bind(childTools);
660
+ else
661
+ equipment = this.opts.equipSubagent?.(child.id, label, childTools);
662
+ const agent = new Agent({
270
663
  ...this.opts,
271
664
  questionPrompt: undefined,
272
665
  session: child,
@@ -275,41 +668,372 @@ export class Agent {
275
668
  appendSystemPrompt: undefined,
276
669
  maxTurns: definition.maxTurns ?? this.opts.maxTurns,
277
670
  agentId: `${this.agentId}/${definition.name}`,
671
+ ...(worktree && this.opts.workspace
672
+ ? { workspace: retargetPolicy(this.opts.workspace, this.session.cwd, worktree.cwd) }
673
+ : {}),
674
+ // A subagent hands work back to its parent; it does not start sessions
675
+ // or background tasks.
676
+ worktreeLauncher: undefined,
677
+ taskWaker: undefined,
678
+ equipSubagent: undefined,
679
+ ...(equipment?.onRunStart ? { onRunStart: equipment.onRunStart } : {}),
680
+ interactionSessionId: this.opts.interactionSessionId ?? this.session.id,
681
+ interactionLabel: label,
278
682
  // One level of delegation: a subagent may not spawn further subagents.
279
683
  subagents: new SubagentRegistry([]),
280
684
  // The parent already attached what matched the user's message, and a
281
685
  // subagent's prompt is the parent's wording rather than the user's.
282
686
  memoryProvider: undefined,
283
687
  });
688
+ return { agent, definition, ...(worktree ? { worktree } : {}), ...(equipment ? { equipment } : {}) };
689
+ }
690
+ /**
691
+ * One run of a subagent, to its end: its first prompt, or a later message.
692
+ *
693
+ * `report` is there only for a background task: it is what lets a look at
694
+ * the task say what the subagent is doing now.
695
+ */
696
+ async runChild(child, input, signal, report) {
697
+ if (child.definition.runtime?.kind === "acp")
698
+ return this.runExternalChild(child, input, signal, report);
284
699
  let text = "";
285
700
  let turns = 0;
286
701
  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;
702
+ // Said to it after its last look at its inbox would otherwise sit there
703
+ // until some later run, out of context: answered now, as one more run.
704
+ for (let next = input; next !== undefined;) {
705
+ for await (const event of child.agent.run(next, { ...(signal ? { signal } : {}) })) {
706
+ if (event.type === "assistant_message") {
707
+ const messageBody = messageText(event.message);
708
+ if (messageBody.trim())
709
+ text = messageBody;
710
+ }
711
+ else if (event.type === "turn_start") {
712
+ report?.turn();
713
+ }
714
+ else if (event.type === "tool_start") {
715
+ report?.activity(event.title);
716
+ }
717
+ else if (event.type === "done") {
718
+ turns += event.turns;
719
+ usage = event.usage;
720
+ }
296
721
  }
722
+ const unread = signal?.aborted ? [] : child.agent.takeUnread();
723
+ next = unread.length ? unread.map((entry) => entry.text).join("\n\n") : undefined;
297
724
  }
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
725
  await this.hooks.run({
302
726
  event: "SubagentStop",
303
727
  sessionId: this.session.id,
304
- agentId: `${this.agentId}/${definition.name}`,
728
+ agentId: `${this.agentId}/${child.definition.name}`,
729
+ cwd: this.session.cwd,
730
+ });
731
+ return { text, turns, usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens } };
732
+ }
733
+ /**
734
+ * One run of a subagent whose loop is an external agent's (ACP).
735
+ *
736
+ * The child session is still kept, as the record of the task: the prompt
737
+ * and the agent's answer go into it, so a background task's transcript and
738
+ * a follow-up after a restart read the same as any other subagent's. The
739
+ * agent itself starts fresh each run, so what came before rides in the
740
+ * prompt.
741
+ */
742
+ async runExternalChild(child, input, signal, report) {
743
+ const runtime = child.definition.runtime;
744
+ const session = child.agent.session;
745
+ const earlier = session.messages
746
+ .map((message) => `${message.role === "user" ? "Asked" : "You answered"}: ${messageText(message).trim()}`)
747
+ .filter((line) => !line.endsWith(": "));
748
+ const prompt = earlier.length
749
+ ? `Earlier in this task:\n\n${earlier.join("\n\n")}\n\nNow: ${input}`
750
+ : input;
751
+ // The agent's own session from the last run, when it ran in this folder:
752
+ // a follow-up resumes it, so the agent keeps what it learned, and only
753
+ // the new message is sent. Another folder (a replaced worktree) is not
754
+ // the same session, whatever its id.
755
+ const last = [...session.messages].reverse().find((message) => message.role === "assistant");
756
+ const previous = last?.meta?.["acpSession"];
757
+ const resume = typeof previous?.id === "string" && previous.cwd === session.cwd
758
+ ? { sessionId: previous.id, prompt: input }
759
+ : undefined;
760
+ const servers = await this.forwardedMcpServers();
761
+ await session.addMessage({ role: "user", content: [{ type: "text", text: input }], meta: { timestamp: Date.now() } });
762
+ report?.turn();
763
+ let text;
764
+ let acpSession;
765
+ try {
766
+ const result = await runAcpTurn({
767
+ runtime,
768
+ cwd: session.cwd,
769
+ prompt,
770
+ ...(resume ? { resume } : {}),
771
+ ...(servers.length ? { mcpServers: servers } : {}),
772
+ // Its file reads and writes through the same policy as ours: in the
773
+ // project (or its worktree), and nowhere it was not allowed to go.
774
+ fs: child.agent.externalFs(),
775
+ ...(signal ? { signal } : {}),
776
+ askPermission: (title, toolInput, toolCallId) => child.agent.askForExternal(title, toolInput, toolCallId, signal),
777
+ onActivity: (title) => report?.activity(title),
778
+ });
779
+ text = result.text || (result.stopReason === "cancelled" ? "Stopped." : "The agent finished without saying anything.");
780
+ acpSession = { id: result.sessionId, cwd: session.cwd };
781
+ }
782
+ catch (err) {
783
+ // Said as the answer rather than thrown: "sign in to Claude Code" is
784
+ // what the parent has to pass on, not a crash to report.
785
+ if (err instanceof AcpSignInRequired || err instanceof AcpUnavailable)
786
+ text = err.message;
787
+ else
788
+ throw err;
789
+ }
790
+ await session.addMessage({
791
+ role: "assistant",
792
+ content: [{ type: "text", text }],
793
+ meta: { timestamp: Date.now(), ...(acpSession ? { acpSession } : {}) },
794
+ });
795
+ await this.hooks.run({
796
+ event: "SubagentStop",
797
+ sessionId: this.session.id,
798
+ agentId: `${this.agentId}/${child.definition.name}`,
305
799
  cwd: this.session.cwd,
306
800
  });
801
+ // No usage: the external agent bills its own account and reports none.
802
+ return { text, turns: 1, usage: { inputTokens: 0, outputTokens: 0 } };
803
+ }
804
+ /**
805
+ * File access for an external agent, through this agent's workspace policy:
806
+ * reads where ours may read, writes where ours may write, nowhere else.
807
+ */
808
+ externalFs() {
307
809
  return {
308
- text,
309
- turns,
310
- usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens },
810
+ read: async (file, line, limit) => {
811
+ const text = await readFile(this.resolvePath(file, "read"), "utf8");
812
+ if (line === undefined && limit === undefined)
813
+ return text;
814
+ const lines = text.split("\n");
815
+ const start = Math.max(0, (line ?? 1) - 1);
816
+ return lines.slice(start, limit === undefined ? undefined : start + limit).join("\n");
817
+ },
818
+ write: async (file, content) => {
819
+ const target = this.resolvePath(file, "write");
820
+ await mkdir(path.dirname(target), { recursive: true });
821
+ await writeFile(target, content);
822
+ },
311
823
  };
312
824
  }
825
+ /**
826
+ * `@claude …`: a message addressed to an external agent by name.
827
+ *
828
+ * Only an agent with an ACP runtime answers to this, and only at the start
829
+ * of what was typed — or of the paragraph after a reference note, which the
830
+ * page puts above the prompt. The note stays with the message; the name
831
+ * does not.
832
+ */
833
+ directTarget(input) {
834
+ if (typeof input !== "string")
835
+ return undefined;
836
+ const match = /(^|\n\n)@([\w./-]+)[ \t]+(\S[\s\S]*)$/.exec(input);
837
+ if (!match)
838
+ return undefined;
839
+ const definition = this.subagents.get(match[2]);
840
+ if (definition?.runtime?.kind !== "acp")
841
+ return undefined;
842
+ const note = input.slice(0, match.index).trim();
843
+ return { definition, text: note ? `${note}\n\n${match[3]}` : match[3] };
844
+ }
845
+ /**
846
+ * A turn answered by an external agent instead of the model.
847
+ *
848
+ * What makes `@claude` worth having: the message goes straight to the
849
+ * user's own Claude Code, so it is paid for by their Claude account and
850
+ * nothing is spent on this harness's model in between. The agent's reply
851
+ * streams into the conversation as text, its tool calls as tool cards, its
852
+ * questions as the usual permission card; the exchange is kept as an
853
+ * ordinary user/assistant pair, and the agent's session id rides on the
854
+ * reply so the next `@claude` picks up where this one left off.
855
+ */
856
+ async *runDirect(target, raw, signal) {
857
+ const { definition, text } = target;
858
+ const runtime = definition.runtime;
859
+ const finishRun = this.opts.onRunStart?.();
860
+ let stopReason = "end_turn";
861
+ try {
862
+ await this.session.init();
863
+ await this.session.addMessage(userMessage(raw));
864
+ yield { type: "turn_start", turn: 1, model: definition.name };
865
+ const previous = [...this.session.messages]
866
+ .reverse()
867
+ .find((message) => message.role === "assistant" && message.meta?.["agent"] === definition.name)
868
+ ?.meta?.["acpSession"];
869
+ const resume = typeof previous?.id === "string" && previous.cwd === this.session.cwd
870
+ ? { sessionId: previous.id, prompt: text }
871
+ : undefined;
872
+ const events = channel();
873
+ const started = new Map();
874
+ let reply = "";
875
+ const servers = await this.forwardedMcpServers();
876
+ const turn = runAcpTurn({
877
+ runtime,
878
+ cwd: this.session.cwd,
879
+ prompt: text,
880
+ ...(resume ? { resume } : {}),
881
+ ...(servers.length ? { mcpServers: servers } : {}),
882
+ fs: this.externalFs(),
883
+ signal,
884
+ askPermission: (title, input, toolCallId) => this.askForExternal(title, input, toolCallId, signal, definition.name),
885
+ onActivity: (title) => {
886
+ if (title === "Starting the agent…")
887
+ events.push({ type: "notice", message: `Starting ${definition.name}…` });
888
+ },
889
+ onUpdate: (update) => {
890
+ if (update.sessionUpdate === "agent_message_chunk" || update.sessionUpdate === "agent_thought_chunk") {
891
+ const content = update.content;
892
+ if (content?.type !== "text" || !content.text)
893
+ return;
894
+ if (update.sessionUpdate === "agent_thought_chunk")
895
+ return events.push({ type: "thinking_delta", text: content.text });
896
+ reply += content.text;
897
+ events.push({ type: "text_delta", text: content.text });
898
+ }
899
+ else if (update.sessionUpdate === "tool_call") {
900
+ const call = update;
901
+ const id = `${definition.name}:${call.toolCallId}`;
902
+ started.set(id, Date.now());
903
+ events.push({
904
+ type: "tool_start",
905
+ toolCallId: id,
906
+ toolName: call.kind || "tool",
907
+ title: call.title || call.kind || "tool",
908
+ input: call.rawInput ?? {},
909
+ });
910
+ }
911
+ else if (update.sessionUpdate === "tool_call_update") {
912
+ const call = update;
913
+ const id = `${definition.name}:${call.toolCallId}`;
914
+ if ((call.status !== "completed" && call.status !== "failed") || !started.has(id))
915
+ return;
916
+ const began = started.get(id);
917
+ started.delete(id);
918
+ events.push({
919
+ type: "tool_end",
920
+ toolCallId: id,
921
+ toolName: call.kind || "tool",
922
+ result: { content: [{ type: "text", text: call.status }], ...(call.status === "failed" ? { isError: true } : {}) },
923
+ durationMs: Date.now() - began,
924
+ });
925
+ }
926
+ },
927
+ });
928
+ void turn.then(() => events.close(), () => events.close());
929
+ for await (const event of events.drain())
930
+ yield event;
931
+ let acpSession;
932
+ try {
933
+ const result = await turn;
934
+ acpSession = { id: result.sessionId, cwd: this.session.cwd };
935
+ if (result.stopReason === "cancelled" || signal.aborted)
936
+ stopReason = "aborted";
937
+ if (!reply.trim())
938
+ reply = result.text;
939
+ }
940
+ catch (err) {
941
+ if (err instanceof AcpSignInRequired || err instanceof AcpUnavailable) {
942
+ reply = err.message;
943
+ yield { type: "text_delta", text: err.message };
944
+ }
945
+ else if (signal.aborted) {
946
+ stopReason = "aborted";
947
+ }
948
+ else {
949
+ stopReason = "error";
950
+ yield { type: "error", error: `${definition.name}: ${errorMessage(err)}`, fatal: false };
951
+ }
952
+ }
953
+ if (reply.trim()) {
954
+ const message = {
955
+ role: "assistant",
956
+ content: [{ type: "text", text: reply }],
957
+ meta: { timestamp: Date.now(), agent: definition.name, ...(acpSession ? { acpSession } : {}) },
958
+ };
959
+ await this.session.addMessage(message);
960
+ yield { type: "assistant_message", message };
961
+ }
962
+ }
963
+ finally {
964
+ this.running = false;
965
+ await this.session.persist().catch(() => { });
966
+ finishRun?.();
967
+ }
968
+ yield { type: "done", stopReason, usage: this.session.usage, costUsd: this.session.costUsd, turns: 1 };
969
+ }
970
+ /**
971
+ * This conversation's MCP servers, in the shape ACP's `session/new` takes,
972
+ * so an external agent works with the same tools. HTTP ones are filtered
973
+ * out later for an agent that does not take them.
974
+ */
975
+ async forwardedMcpServers() {
976
+ const pairs = (values) => Object.entries(values ?? {}).map(([name, value]) => ({ name, value }));
977
+ const found = await this.opts.mcp?.forwardable().catch(() => []) ?? [];
978
+ return found.map(({ name, config, headers }) => config.type === "stdio"
979
+ ? { name, command: config.command, args: config.args ?? [], env: pairs(config.env) }
980
+ : { type: "http", name, url: config.url, headers: pairs({ ...config.headers, ...headers }) });
981
+ }
982
+ /**
983
+ * An external agent's own tool call, put to the person the way this
984
+ * harness's calls are — the same card, placed in the conversation that
985
+ * started the task and labelled with who is asking. Plan mode and a
986
+ * read-only workspace refuse; bypass allows, as it would for our own tools.
987
+ */
988
+ async askForExternal(title, input, toolCallId, signal, from) {
989
+ const mode = this.session.permissions.getMode();
990
+ if (this.opts.workspace?.mode === "read-only" || mode === "plan" || !this.opts.permissionPrompt)
991
+ return false;
992
+ if (mode === "bypassPermissions")
993
+ return true;
994
+ const request = {
995
+ toolName: "external",
996
+ input,
997
+ title,
998
+ ...this.interactionPlace(),
999
+ ...(from ? { from } : {}),
1000
+ toolCallId,
1001
+ };
1002
+ const prompt = this.opts.permissionPrompt;
1003
+ const ask = () => prompt(request, signal);
1004
+ return signal ? untilAborted(signal, ask, false, false) : ask();
1005
+ }
1006
+ /** What a subagent left in its worktree, when it left anything. */
1007
+ async worktreeNote(child) {
1008
+ if (!child.worktree)
1009
+ return undefined;
1010
+ const status = await worktreeStatus(child.worktree).catch(() => undefined);
1011
+ if (status?.clean)
1012
+ return undefined;
1013
+ const what = status
1014
+ ? [status.commits ? `${status.commits} commit(s)` : "", status.changes.length ? `${status.changes.length} changed file(s)` : ""]
1015
+ .filter(Boolean)
1016
+ .join(", ")
1017
+ : "changes";
1018
+ const worktree = child.worktree;
1019
+ const where = worktree.branch ? `${worktree.path} on branch ${worktree.branch}` : `${worktree.path} (a copy of the folder)`;
1020
+ return `The subagent worked in ${where} and left ${what} there.`;
1021
+ }
1022
+ /**
1023
+ * A subagent is finished with: its browser and computer go, and a worktree
1024
+ * it changed nothing in is removed. One that it changed is kept — the
1025
+ * parent has to decide what happens to that work.
1026
+ */
1027
+ async disposeChild(child) {
1028
+ await child.equipment?.release().catch(() => { });
1029
+ if (child.worktree)
1030
+ await this.dropIfUntouched(child.worktree);
1031
+ }
1032
+ async dropIfUntouched(worktree) {
1033
+ const status = await worktreeStatus(worktree).catch(() => undefined);
1034
+ if (status?.clean)
1035
+ await finishWorktree(worktree, "remove").catch(() => { });
1036
+ }
313
1037
  // --- Main loop ----------------------------------------------------------
314
1038
  async *run(input, runOpts = {}) {
315
1039
  this.running = true;
@@ -323,6 +1047,12 @@ export class Agent {
323
1047
  this.running = false;
324
1048
  throw new Error("Agent host is closed");
325
1049
  }
1050
+ // Addressed to an external agent by name: its turn, not the model's.
1051
+ const direct = this.directTarget(input);
1052
+ if (direct) {
1053
+ yield* this.runDirect(direct, input, signal);
1054
+ return;
1055
+ }
326
1056
  // Fired once per session rather than per turn: hooks use it to seed
327
1057
  // context or announce the session, which should not repeat every prompt.
328
1058
  if (!this.sessionStarted) {
@@ -338,7 +1068,10 @@ export class Agent {
338
1068
  yield { type: "notice", message: start.additionalContext };
339
1069
  }
340
1070
  }
341
- const maxTurns = this.opts.maxTurns ?? DEFAULT_MAX_TURNS;
1071
+ const baseMaxTurns = this.opts.maxTurns ?? DEFAULT_MAX_TURNS;
1072
+ // Read per turn rather than once: a goal can be reached, or reported
1073
+ // blocked, partway through the run, and the ordinary ceiling returns then.
1074
+ const turnLimit = () => (activeGoal(this.state) ? Math.max(baseMaxTurns, GOAL_MAX_TURNS) : baseMaxTurns);
342
1075
  let stopReason = "end_turn";
343
1076
  let turn = 0;
344
1077
  const finishRun = this.opts.onRunStart?.();
@@ -359,7 +1092,13 @@ export class Agent {
359
1092
  yield { type: "memory_extracted", notes: extracted };
360
1093
  }
361
1094
  yield* this.ingestInput(input);
362
- while (turn < maxTurns) {
1095
+ // Background results that ended since the last run ride along with
1096
+ // whatever started this one. A run started only to read them in is
1097
+ // given no input of its own, and has nothing to answer if another path
1098
+ // got to them first.
1099
+ const noticed = yield* this.takeNotices();
1100
+ const nothingToAnswer = Array.isArray(input) && input.length === 0 && !noticed;
1101
+ while (!nothingToAnswer && turn < turnLimit()) {
363
1102
  if (signal.aborted) {
364
1103
  stopReason = "aborted";
365
1104
  break;
@@ -428,15 +1167,28 @@ export class Agent {
428
1167
  // rather than ending and starting a new run keeps one turn's worth
429
1168
  // of context, and matches what the user saw: they typed it before
430
1169
  // the reply landed.
431
- if (!signal.aborted && (yield* this.takeInbox()))
432
- continue;
1170
+ if (!signal.aborted) {
1171
+ const said = yield* this.takeInbox();
1172
+ const noticed = yield* this.takeNotices();
1173
+ if (said || noticed)
1174
+ continue;
1175
+ }
433
1176
  stopReason = assembled.stopReason === "tool_use" ? "end_turn" : assembled.stopReason;
1177
+ if (!signal.aborted && (yield* this.pushTowardGoal()))
1178
+ continue;
434
1179
  break;
435
1180
  }
1181
+ const goalBefore = activeGoal(this.state);
436
1182
  const results = [];
437
1183
  for await (const event of this.executeCalls(calls, signal, results)) {
438
1184
  yield event;
439
1185
  }
1186
+ // The goal tool settles the goal in place; saying so here is what lets
1187
+ // a UI take down its "working toward" line the moment it is done.
1188
+ if (goalBefore && goalBefore.status !== "active") {
1189
+ yield { type: "goal", goal: { ...goalBefore } };
1190
+ void this.session.persist().catch(() => { });
1191
+ }
440
1192
  // All tool results for a turn must go back in one user message —
441
1193
  // splitting them teaches the model to stop calling tools in parallel.
442
1194
  await this.session.addMessage({
@@ -452,8 +1204,10 @@ export class Agent {
452
1204
  // reaches here before it notices and would read a message it is about
453
1205
  // to stop answering, leaving it in the transcript with no reply. Left
454
1206
  // in the inbox it goes back to the composer instead.
455
- if (!signal.aborted)
1207
+ if (!signal.aborted) {
456
1208
  yield* this.takeInbox();
1209
+ yield* this.takeNotices();
1210
+ }
457
1211
  if (this.stopRequested) {
458
1212
  yield { type: "notice", message: this.stopRequested };
459
1213
  this.stopRequested = undefined;
@@ -465,10 +1219,10 @@ export class Agent {
465
1219
  break;
466
1220
  }
467
1221
  }
468
- if (turn >= maxTurns && stopReason === "end_turn") {
1222
+ if (turn >= turnLimit() && stopReason === "end_turn") {
469
1223
  yield {
470
1224
  type: "notice",
471
- message: `Stopped after ${maxTurns} turns without finishing. Send another message to continue.`,
1225
+ message: `Stopped after ${turn} turns without finishing. Send another message to continue.`,
472
1226
  };
473
1227
  }
474
1228
  await this.hooks.run({
@@ -491,6 +1245,12 @@ export class Agent {
491
1245
  this.running = false;
492
1246
  await this.session.persist().catch(() => { });
493
1247
  finishRun?.();
1248
+ // Results that ended after the loop's last look. Not after an interrupt:
1249
+ // the person just said stop, and a turn starting by itself half a second
1250
+ // later would be the agent ignoring them. They ride along with the next
1251
+ // run instead, or wake one when another task ends.
1252
+ if (stopReason !== "aborted" && this.background?.hasUndelivered())
1253
+ this.scheduleWake();
494
1254
  }
495
1255
  yield {
496
1256
  type: "done",
@@ -501,6 +1261,32 @@ export class Agent {
501
1261
  };
502
1262
  }
503
1263
  // --- Steps --------------------------------------------------------------
1264
+ /**
1265
+ * Where a reply ends with a goal still active: ask for more work.
1266
+ *
1267
+ * Returns whether the loop should go on. A model that keeps stopping short
1268
+ * of its goal gets a bounded number of reminders, not an endless supply.
1269
+ */
1270
+ async *pushTowardGoal() {
1271
+ const goal = activeGoal(this.state);
1272
+ if (!goal)
1273
+ return false;
1274
+ if (goal.nudges >= MAX_GOAL_NUDGES) {
1275
+ yield {
1276
+ type: "notice",
1277
+ message: `Goal still open after ${MAX_GOAL_NUDGES} reminders; stopping here. Send a message to keep going.`,
1278
+ };
1279
+ return false;
1280
+ }
1281
+ goal.nudges++;
1282
+ await this.session.addMessage({
1283
+ role: "user",
1284
+ content: [{ type: "text", text: goalNudge(goal) }],
1285
+ meta: { timestamp: Date.now(), synthetic: true, goalNudge: true },
1286
+ });
1287
+ yield { type: "goal", goal: { ...goal } };
1288
+ return true;
1289
+ }
504
1290
  async *ingestInput(input) {
505
1291
  if (Array.isArray(input)) {
506
1292
  for (const message of input)
@@ -675,11 +1461,75 @@ export class Agent {
675
1461
  const length = this.session.messages.length;
676
1462
  return Number.isSafeInteger(value) && value >= 0 ? Math.min(value, length) : length;
677
1463
  }
678
- async *maybeCompact() {
679
- if (this.opts.compaction === false)
1464
+ /**
1465
+ * How full the model's window is, in tokens: what the last request measured,
1466
+ * or, before one has (a new or reopened conversation), an estimate of the
1467
+ * next request — system prompt and tools included, which are a large share
1468
+ * of a short conversation.
1469
+ */
1470
+ contextTokens() {
1471
+ return this.contextBreakdown().total;
1472
+ }
1473
+ /**
1474
+ * The window, split the way the context panel draws it. Providers report one
1475
+ * number for the prompt, so the system prompt and the tool definitions are
1476
+ * estimated and the messages are what is left of the measured total; until
1477
+ * something is measured all three are estimates.
1478
+ */
1479
+ contextBreakdown() {
1480
+ const window = this.session.model.contextWindow;
1481
+ // Kept per transcript: the terminal asks on every frame it draws, and the
1482
+ // prompt is rebuilt from skills, memory and plugins each time it is made.
1483
+ const transcript = this.session.messages;
1484
+ let estimate = this.contextEstimate;
1485
+ if (!estimate || estimate.messages !== transcript || estimate.length !== transcript.length) {
1486
+ const request = this.buildRequest(new AbortController().signal);
1487
+ estimate = {
1488
+ messages: transcript,
1489
+ length: transcript.length,
1490
+ system: estimateStringTokens(request.system ?? ""),
1491
+ tools: request.tools ? estimateToolsTokens(request.tools) : 0,
1492
+ transcript: estimateMessagesTokens(transcript),
1493
+ };
1494
+ this.contextEstimate = estimate;
1495
+ }
1496
+ const measured = this.session.measuredContextTokens();
1497
+ const fixed = estimate.system + estimate.tools;
1498
+ const total = measured ?? fixed + estimate.transcript;
1499
+ const compaction = this.opts.compaction;
1500
+ return {
1501
+ total,
1502
+ window,
1503
+ system: estimate.system,
1504
+ tools: estimate.tools,
1505
+ messages: Math.max(0, total - fixed),
1506
+ measured: measured !== undefined,
1507
+ // Where the automatic kind fires: a share of what is left once the
1508
+ // reply has been given room (see shouldCompact).
1509
+ ...(compaction === false
1510
+ ? {}
1511
+ : { compactAt: compactionPoint(this.session.model, compaction?.threshold) }),
1512
+ };
1513
+ }
1514
+ contextEstimate;
1515
+ /**
1516
+ * Compact now, whatever the window says: the /compact command. Goes through
1517
+ * the same path as the automatic kind, so what it drops is archived for
1518
+ * recall and handed to memory extraction rather than simply discarded.
1519
+ * Returns what happened, or undefined when there was nothing safe to cut.
1520
+ */
1521
+ async compactNow() {
1522
+ let last;
1523
+ for await (const event of this.maybeCompact(true))
1524
+ last = event;
1525
+ return last;
1526
+ }
1527
+ async *maybeCompact(manual = false) {
1528
+ if (!manual && this.opts.compaction === false)
680
1529
  return;
681
1530
  const systemTokens = estimateStringTokens(this.systemPrompt());
682
- if (!shouldCompact(this.session.messages, this.session.model, systemTokens, this.opts.compaction)) {
1531
+ const options = this.opts.compaction || {};
1532
+ if (!manual && !shouldCompact(this.session.messages, this.session.model, systemTokens, options)) {
683
1533
  return;
684
1534
  }
685
1535
  await this.hooks.run({
@@ -695,7 +1545,9 @@ export class Agent {
695
1545
  // the session happens to be using.
696
1546
  const summarizer = this.summarizerModel();
697
1547
  const provider = this.session.providers.getProvider(summarizer.provider);
698
- const result = await compact(this.session.messages, provider, summarizer, this.opts.compaction ?? {});
1548
+ const result = await compact(this.session.messages, provider, summarizer,
1549
+ // Asked for by name, so keep less of the tail: the person wants room.
1550
+ manual ? { ...options, keepTailFraction: 0.15 } : options);
699
1551
  if (result.droppedMessages === 0)
700
1552
  return;
701
1553
  // Read before the transcript changes shape: the marker is a position in it.
@@ -723,7 +1575,26 @@ export class Agent {
723
1575
  yield { type: "notice", message: `Compaction failed: ${errorMessage(err)}` };
724
1576
  }
725
1577
  }
1578
+ /**
1579
+ * The tools offered to the model: the goal tool only while there is a goal,
1580
+ * and the computer tools only while this conversation has Computer Use on.
1581
+ */
1582
+ visibleTools() {
1583
+ const goal = activeGoal(this.state);
1584
+ const computer = computerOn(this.state);
1585
+ const worktrees = Boolean(this.opts.worktreeLauncher?.());
1586
+ const background = this.backgroundAvailable();
1587
+ return this.tools
1588
+ .visible(this.opts.toolFilter)
1589
+ .filter((tool) => (goal || tool.name !== GOAL_TOOL) && (computer || !isComputerTool(tool.name)))
1590
+ // Asking for the computer is for a conversation without it and with
1591
+ // somebody there: a routine or a headless run has nobody to answer.
1592
+ .filter((tool) => tool.name !== COMPUTER_ENABLE_TOOL || (!computer && Boolean(this.opts.permissionPrompt)))
1593
+ .filter((tool) => worktrees || tool.name !== WORKTREE_START_TOOL)
1594
+ .filter((tool) => background || ![TASK_OUTPUT_TOOL, TASK_STOP_TOOL, TASK_MESSAGE_TOOL].includes(tool.name));
1595
+ }
726
1596
  systemPrompt() {
1597
+ const goal = activeGoal(this.state);
727
1598
  return buildSystemPrompt({
728
1599
  systemPrompt: this.opts.systemPrompt,
729
1600
  appendSystemPrompt: this.opts.appendSystemPrompt,
@@ -732,10 +1603,16 @@ export class Agent {
732
1603
  permissionMode: this.session.permissions.getMode(),
733
1604
  projectInstructions: this.opts.projectInstructions,
734
1605
  mcpInstructions: this.opts.mcp?.serverInstructions(),
735
- toolNames: this.tools.visible(this.opts.toolFilter).map((t) => t.name),
1606
+ toolNames: this.visibleTools().map((t) => t.name),
736
1607
  // Only the descriptions — loading every skill body up front would
737
1608
  // defeat the point of having them be loadable.
738
- extraSections: [this.skills.promptSection(), this.memoryIndex, ...this.pluginSections].filter((section) => section !== undefined),
1609
+ extraSections: [
1610
+ this.skills.promptSection(),
1611
+ this.memoryIndex,
1612
+ ...this.pluginSections,
1613
+ ...(goal ? [goalPromptSection(goal)] : []),
1614
+ ...(computerOn(this.state) && this.tools.has("computer_list_apps") ? [computerPromptSection()] : []),
1615
+ ].filter((section) => section !== undefined),
739
1616
  });
740
1617
  }
741
1618
  buildRequest(signal) {
@@ -744,7 +1621,13 @@ export class Agent {
744
1621
  model,
745
1622
  system: this.systemPrompt(),
746
1623
  messages: this.session.messages,
747
- tools: model.supportsTools ? this.tools.schemas(this.opts.toolFilter) : undefined,
1624
+ tools: model.supportsTools
1625
+ ? this.visibleTools().map((tool) => ({
1626
+ name: tool.name,
1627
+ description: tool.description,
1628
+ inputSchema: tool.inputSchema,
1629
+ }))
1630
+ : undefined,
748
1631
  toolChoice: { type: "auto" },
749
1632
  thinking: thinkingFor(this.opts.thinking, this.session.effort),
750
1633
  cache: this.opts.cache ?? true,
@@ -814,7 +1697,10 @@ export class Agent {
814
1697
  // concurrently — one unsafe call forces the whole batch to serialize,
815
1698
  // because we cannot know how they interact.
816
1699
  const parallel = calls.length > 1 &&
817
- calls.every((call) => this.tools.get(call.name)?.concurrencySafe === true);
1700
+ calls.every((call) => {
1701
+ const tool = this.tools.get(call.name);
1702
+ return tool?.concurrencySafe === true || tool?.concurrencySafeFor?.(call.input, this) === true;
1703
+ });
818
1704
  if (!parallel) {
819
1705
  for (const call of calls) {
820
1706
  yield* this.executeOne(call, signal, results);
@@ -883,18 +1769,24 @@ export class Agent {
883
1769
  this.stopRequested = reason;
884
1770
  return;
885
1771
  }
886
- // The workspace policy runs before the approval prompt: a write the
887
- // policy forbids should never be offered for approval at all.
1772
+ // The workspace policy runs before the approval prompt. A write outside
1773
+ // the roots is a question for the person, not a refusal: they are the one
1774
+ // who knows whether that folder is fair game, and the answer to it stands
1775
+ // in for the ordinary approval. A read-only workspace, plan mode, or no
1776
+ // one to ask still refuses outright.
1777
+ let approved = false;
888
1778
  if (this.opts.workspace && !tool.readOnly) {
889
1779
  const check = checkWrite(this.opts.workspace, call.name, input, this.session.cwd);
890
1780
  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;
1781
+ const outside = yield* this.askOutsideRoots(call, input, title, check, signal);
1782
+ if (!outside) {
1783
+ results.push(errorResult(call, check.reason ?? "blocked by the workspace policy"));
1784
+ return;
1785
+ }
1786
+ approved = true;
895
1787
  }
896
1788
  }
897
- const allowed = yield* this.checkPermission(call, tool, input, title, signal);
1789
+ const allowed = approved || (yield* this.checkPermission(call, tool, input, title, signal));
898
1790
  if (!allowed)
899
1791
  return void results.push(errorResult(call, permissionDenialText(title)));
900
1792
  const ctx = {
@@ -1021,6 +1913,38 @@ export class Agent {
1021
1913
  isError: result.isError,
1022
1914
  });
1023
1915
  }
1916
+ /**
1917
+ * Ask about a write the workspace policy would refuse.
1918
+ *
1919
+ * Bypass mode approves it as it approves everything else. Read-only and plan
1920
+ * mode refuse it, as does a frontend with no way to ask.
1921
+ */
1922
+ async *askOutsideRoots(call, input, title, check, signal) {
1923
+ const mode = this.session.permissions.getMode();
1924
+ const refuse = function* (reason) {
1925
+ yield { type: "permission_result", toolCallId: call.id, allowed: false, reason };
1926
+ return false;
1927
+ };
1928
+ if (this.opts.workspace?.mode === "read-only" || mode === "plan" || !this.opts.permissionPrompt) {
1929
+ return yield* refuse(check.reason ?? "blocked by the workspace policy");
1930
+ }
1931
+ if (mode === "bypassPermissions")
1932
+ return true;
1933
+ const request = {
1934
+ toolName: call.name,
1935
+ input,
1936
+ title,
1937
+ reason: `Writes outside the project: ${check.offending ?? ""}`.trim(),
1938
+ ...this.interactionPlace(),
1939
+ toolCallId: call.id,
1940
+ };
1941
+ yield { type: "permission_request", request };
1942
+ const prompt = this.opts.permissionPrompt;
1943
+ const answer = await untilAborted(signal, () => prompt(request, signal), false, false);
1944
+ const allowed = typeof answer === "boolean" ? answer : answer.allow;
1945
+ yield { type: "permission_result", toolCallId: call.id, allowed };
1946
+ return allowed;
1947
+ }
1024
1948
  async *checkPermission(call, tool, input, title, signal) {
1025
1949
  const decision = this.session.permissions.evaluate(tool, input);
1026
1950
  if (decision.behavior === "allow")
@@ -1031,6 +1955,7 @@ export class Agent {
1031
1955
  toolCallId: call.id,
1032
1956
  allowed: false,
1033
1957
  reason: decision.message,
1958
+ ...(decision.rule === "plan" ? { plan: true } : {}),
1034
1959
  };
1035
1960
  return false;
1036
1961
  }
@@ -1040,7 +1965,7 @@ export class Agent {
1040
1965
  title,
1041
1966
  reason: decision.rule ? `matched rule ${decision.rule}` : undefined,
1042
1967
  ...(decision.rule ? { rule: decision.rule } : {}),
1043
- sessionId: this.session.id,
1968
+ ...this.interactionPlace(),
1044
1969
  toolCallId: call.id,
1045
1970
  };
1046
1971
  yield { type: "permission_request", request };
@@ -1142,4 +2067,36 @@ function touchesSkills(toolName, input) {
1142
2067
  // what is advertised, so re-scanning for those would be noise.
1143
2068
  return target.includes("skills") && target.endsWith("SKILL.md");
1144
2069
  }
2070
+ /**
2071
+ * A queue a callback pushes into and a generator drains, for turning an
2072
+ * external agent's callbacks into a stream of events as they happen.
2073
+ */
2074
+ function channel() {
2075
+ const items = [];
2076
+ let wake;
2077
+ let closed = false;
2078
+ const poke = () => {
2079
+ wake?.();
2080
+ wake = undefined;
2081
+ };
2082
+ return {
2083
+ push(item) {
2084
+ items.push(item);
2085
+ poke();
2086
+ },
2087
+ close() {
2088
+ closed = true;
2089
+ poke();
2090
+ },
2091
+ async *drain() {
2092
+ for (;;) {
2093
+ while (items.length)
2094
+ yield items.shift();
2095
+ if (closed)
2096
+ return;
2097
+ await new Promise((resolve) => (wake = resolve));
2098
+ }
2099
+ },
2100
+ };
2101
+ }
1145
2102
  //# sourceMappingURL=agent.js.map