@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
package/dist/harness.js CHANGED
@@ -6,6 +6,7 @@ import { HookRegistry } from "./agent/hooks.js";
6
6
  import { Session } from "./agent/session.js";
7
7
  import { SessionStore } from "./agent/store.js";
8
8
  import { BUILTIN_SUBAGENTS, SubagentRegistry, loadMarkdownSubagents } from "./agent/subagent.js";
9
+ import { claudeAgent } from "./acp/claude.js";
9
10
  import { SkillRegistry } from "./agent/skills.js";
10
11
  import { SettingsStore } from "./agent/settings.js";
11
12
  import { createPolicy } from "./agent/workspace.js";
@@ -60,10 +61,40 @@ export class Harness {
60
61
  extensions = new PluginExtensions();
61
62
  /** Undefined when browser tools are turned off. Lazy: holds no process until used. */
62
63
  browser;
64
+ /**
65
+ * Who hosts a session started in a new worktree. Set by the frontend that
66
+ * holds sessions (the web server); while unset, `worktree_start` is not
67
+ * offered to the model.
68
+ */
69
+ worktreeLauncher;
70
+ /**
71
+ * Starts a turn in a conversation that is idle, to read in background task
72
+ * results that ended after its last run. Set by a frontend that keeps
73
+ * conversations alive between turns; while unset, background tasks are not
74
+ * offered and `task` runs everything in the foreground.
75
+ */
76
+ taskWaker;
77
+ /**
78
+ * Where pictures of the browser tabs a turn left go, and whether to take
79
+ * them at all. Set by a frontend that shows them; while unset, none are.
80
+ * `at` is the conversation's message count when the turn ended.
81
+ */
82
+ turnShotSink;
63
83
  /** Browser state belongs to a conversation, not to its project harness. */
64
84
  sessionBrowsers = new Map();
85
+ /** Computer Use state belongs to a conversation too. */
86
+ sessionComputers = new Map();
65
87
  /** Each live conversation's title, read when its browser needs a name. */
66
88
  sessionTitles = new Map();
89
+ /** Each live conversation's model, read when it creates a routine. */
90
+ sessionModels = new Map();
91
+ /**
92
+ * The model a routine runs on when it names none of its own, or names one
93
+ * that is gone. Set by whoever knows what the user picked — the app's
94
+ * composer — because the first-configured model is a guess: a local server
95
+ * that is not running, or a gated one the account never unlocked.
96
+ */
97
+ routineModel;
67
98
  memoryService;
68
99
  /** User-level notes, shared by every project. Absent when the user directory is not writable. */
69
100
  memoryUserService;
@@ -183,6 +214,8 @@ export class Harness {
183
214
  list: () => this.scheduler.status(),
184
215
  add: (input) => this.addSchedule(input),
185
216
  remove: (name) => this.removeSchedule(name),
217
+ sessionModel: (sessionId) => this.sessionModels.get(sessionId)?.(),
218
+ appStatus: (app, signal) => this.appStatus(app, signal),
186
219
  }));
187
220
  this.ephemeral = opts.ephemeral === true;
188
221
  this.runtimeAudit = new RuntimeAuditBus(this.logger);
@@ -237,6 +270,11 @@ export class Harness {
237
270
  .catch((err) => harness.logger.warn("session pruning failed", { error: String(err) }));
238
271
  }
239
272
  await harness.skills.discover(cwd, config.skillDirs ?? []);
273
+ // Claude Agent, when the user turned it on and this machine has their
274
+ // own Claude Code to hand work to. Before the markdown ones, so a
275
+ // project can redefine it.
276
+ if (config.claudeAgent === true)
277
+ await harness.registerClaudeAgent();
240
278
  // Markdown definitions layer over the JSON ones, so a repo can drop in an
241
279
  // agent file without editing config.
242
280
  for (const definition of await loadMarkdownSubagents(cwd, config.agentDirs ?? [])) {
@@ -283,19 +321,7 @@ export class Harness {
283
321
  }
284
322
  }, token: () => harness.accountToken(), syncUrl: () => resolveSyncUrl(harness.config.sync),
285
323
  // Merge proposals use the small model, like extraction and titles.
286
- generate: async (system, prompt, signal) => {
287
- const model = harness.providers.resolve(harness.config.smallModel ?? harness.config.model ?? harness.defaultModel());
288
- const provider = harness.providers.getProvider(model.provider);
289
- const parts = [];
290
- for await (const event of provider.stream({
291
- model, system, messages: [userMessage(prompt)],
292
- maxOutputTokens: Math.min(8_000, model.maxOutputTokens), thinking: { enabled: false, effort: "low" },
293
- ...(signal ? { signal } : {}),
294
- }))
295
- if (event.type === "text_delta")
296
- parts.push(event.text);
297
- return parts.join("");
298
- },
324
+ generate: (system, prompt, signal) => harness.generateText(system, prompt, signal),
299
325
  }, (service, user) => { harness.memoryService = service; harness.memoryUserService = user; });
300
326
  /**
301
327
  * Media generation, billed to the OHarness account and nothing else.
@@ -408,6 +434,50 @@ export class Harness {
408
434
  * started with the browser off has no tools to switch, so that one stays as
409
435
  * it is until a restart.
410
436
  */
437
+ /** The Claude Agent definition this harness registered, while it is on. */
438
+ claudeRegistered;
439
+ /**
440
+ * Whether Claude Agent is switched on, and whether it could be.
441
+ *
442
+ * `available` is whether this machine has the user's `claude` (and `npx`
443
+ * for the adapter); `unavailable` is the reason it would not run even so,
444
+ * such as an old Node. Detected afresh each time, since installing Claude
445
+ * Code is something done in another window while this page is open.
446
+ */
447
+ async claudeAgentStatus() {
448
+ const found = await claudeAgent().catch(() => undefined);
449
+ return {
450
+ on: this.config.claudeAgent === true,
451
+ available: Boolean(found),
452
+ ...(found?.runtime?.unavailable ? { unavailable: found.runtime.unavailable } : {}),
453
+ };
454
+ }
455
+ /**
456
+ * Turn Claude Agent on or off, now and for later launches.
457
+ *
458
+ * Written to the user's own config, the same place `claudeAgent` is read
459
+ * from at startup, so the terminal frontends and the page agree. Takes
460
+ * effect at once: open conversations share this harness's subagent list.
461
+ */
462
+ async setClaudeAgent(on) {
463
+ await updateUserConfig((current) => ({ ...current, claudeAgent: on }));
464
+ this.config.claudeAgent = on;
465
+ if (on)
466
+ await this.registerClaudeAgent();
467
+ else if (this.claudeRegistered) {
468
+ this.subagents.unregister(this.claudeRegistered);
469
+ this.claudeRegistered = undefined;
470
+ }
471
+ }
472
+ async registerClaudeAgent() {
473
+ if (this.claudeRegistered || process.env["OHARNESS_NO_CLAUDE_AGENT"] === "1")
474
+ return;
475
+ const claude = await claudeAgent().catch(() => undefined);
476
+ if (!claude)
477
+ return;
478
+ this.subagents.register(claude);
479
+ this.claudeRegistered = claude;
480
+ }
411
481
  async setBrowserMode(mode) {
412
482
  await updateUserConfig((current) => ({ ...current, browser: { ...current.browser, mode } }));
413
483
  this.config.browser = { ...this.config.browser, mode };
@@ -543,11 +613,41 @@ export class Harness {
543
613
  const where = await stat(cwd).catch(() => undefined);
544
614
  if (!where?.isDirectory())
545
615
  throw new ConfigError(`"${cwd}" is not a directory`);
546
- const def = { name, cron, prompt, cwd, createdAt: Date.now() };
616
+ const apps = [...new Set((input.apps ?? []).map((app) => app.trim().toLowerCase()).filter(Boolean))];
617
+ const def = {
618
+ name, cron, prompt, cwd, createdAt: Date.now(),
619
+ ...(input.model ? { model: input.model } : {}),
620
+ ...(apps.length ? { apps } : {}),
621
+ };
547
622
  await updateUserConfig((current) => ({ ...current, schedules: [...(current.schedules ?? []), def] }));
548
623
  this.scheduler.add(def);
549
624
  return def;
550
625
  }
626
+ /**
627
+ * The model a routine should run on: its own if that still resolves, else
628
+ * the one `routineModel` names, else nothing — and the session default.
629
+ */
630
+ routineModelFor(own) {
631
+ for (const ref of [own, this.routineModel?.()]) {
632
+ if (!ref)
633
+ continue;
634
+ try {
635
+ return this.providers.resolve(ref).id;
636
+ }
637
+ catch {
638
+ this.logger.warn("routine model is not available; trying the next", { ref });
639
+ }
640
+ }
641
+ return undefined;
642
+ }
643
+ /**
644
+ * Whether a Cloud app is connected for this account, asked without opening
645
+ * a card. `label` is the app's display name when the Cloud gave one.
646
+ */
647
+ async appStatus(app, signal) {
648
+ const data = await this.cloudConnectors.call("catalog", { app }, signal);
649
+ return { connected: data.connected !== false, label: typeof data.label === "string" ? data.label : app };
650
+ }
551
651
  /**
552
652
  * Bring the scheduler in line with the config files as they are now.
553
653
  *
@@ -727,9 +827,9 @@ export class Harness {
727
827
  * the user catalog so the next run starts with a current list.
728
828
  */
729
829
  async refreshModels(providerId) {
730
- const discovered = await this.providers.refreshModels(providerId);
731
- if (discovered.length > 0) {
732
- await this.saveDiscovered(discovered).catch((err) => this.logger.warn("could not persist the discovered catalog", {
830
+ const { changed: discovered, cleared } = await this.providers.refreshListings(providerId);
831
+ if (discovered.length > 0 || cleared.length > 0) {
832
+ await this.saveDiscovered(discovered, cleared).catch((err) => this.logger.warn("could not persist the discovered catalog", {
733
833
  error: String(err),
734
834
  }));
735
835
  }
@@ -744,12 +844,22 @@ export class Harness {
744
844
  * happen to — and answers 404 when picked. Nothing to refresh from (no
745
845
  * listing ever made) counts as stale. Resolves to undefined when the list
746
846
  * was fresh enough to leave alone.
847
+ *
848
+ * The age is the oldest provider's, from `discovered`, not `discoveredAt`:
849
+ * that one moves whenever any single provider is listed — a sign-in, a
850
+ * key saved for one vendor — and read alone it kept every other provider's
851
+ * week-old list looking fresh. `discoveredAt` is the fallback for a file
852
+ * written before `discovered` existed.
747
853
  */
748
854
  async refreshStaleModels(maxAgeMs = MODEL_LIST_MAX_AGE_MS) {
749
855
  let at = NaN;
750
856
  try {
751
857
  const raw = JSON.parse(await readFile(userCatalogPath(), "utf8"));
752
- at = Date.parse(String(raw["discoveredAt"] ?? ""));
858
+ const each = raw["discovered"];
859
+ const times = each && typeof each === "object" && !Array.isArray(each)
860
+ ? Object.values(each).map((value) => Date.parse(String(value))).filter(Number.isFinite)
861
+ : [];
862
+ at = times.length > 0 ? Math.min(...times) : Date.parse(String(raw["discoveredAt"] ?? ""));
753
863
  }
754
864
  catch {
755
865
  // No file or an unreadable one: never listed, so due.
@@ -784,8 +894,9 @@ export class Harness {
784
894
  * it: merging only ever grew the file, so a model the vendor withdrew stayed
785
895
  * in the picker for good. `discovered` records which providers have a live
786
896
  * listing, so the pi-ai snapshot stops filling in models for them.
897
+ * `cleared` names local servers that listed nothing, whose entries go.
787
898
  */
788
- async saveDiscovered(models) {
899
+ async saveDiscovered(models, cleared = []) {
789
900
  const file = userCatalogPath();
790
901
  let existing = {};
791
902
  try {
@@ -797,7 +908,7 @@ export class Harness {
797
908
  const byId = new Map((Array.isArray(existing["models"]) ? existing["models"] : [])
798
909
  .filter((m) => !!m && typeof m === "object")
799
910
  .map((m) => [String(m["id"]), m]));
800
- const listed = new Set(models.map((model) => model.provider));
911
+ const listed = new Set([...models.map((model) => model.provider), ...cleared]);
801
912
  const fresh = new Set(models.map((model) => model.id));
802
913
  for (const [id, model] of byId) {
803
914
  const { provider, model: wire } = model;
@@ -872,6 +983,7 @@ export class Harness {
872
983
  auditReadyFor;
873
984
  equip(session) {
874
985
  this.sessionTitles.set(session.id, () => session.title);
986
+ this.sessionModels.set(session.id, () => session.model.id);
875
987
  session.permissions.attachSettings(this.settings);
876
988
  // Attached whatever the setting says, and the setting read when a decision
877
989
  // actually happens. Deciding here instead would make the switch on the
@@ -930,7 +1042,8 @@ export class Harness {
930
1042
  */
931
1043
  async forkSession(id, opts = {}) {
932
1044
  const forked = await Session.fork(id, {
933
- model: opts.model ?? this.config.model ?? this.defaultModel(),
1045
+ model: opts.model ?? "",
1046
+ fallbackModel: this.config.model ?? this.defaultModel(),
934
1047
  providers: this.providers,
935
1048
  permissionMode: this.config.permissionMode,
936
1049
  permissionRules: this.config.permissions,
@@ -959,7 +1072,10 @@ export class Harness {
959
1072
  /** Reload a persisted session by id; returns undefined if it is unknown. */
960
1073
  async resumeSession(id, model) {
961
1074
  const resumed = await Session.resume(id, {
962
- model: model ?? this.config.model ?? this.defaultModel(),
1075
+ // Empty means "the session's own": a conversation reopens on the model
1076
+ // it was using, and the default is only for one whose model is gone.
1077
+ model: model ?? "",
1078
+ fallbackModel: this.config.model ?? this.defaultModel(),
963
1079
  providers: this.providers,
964
1080
  permissionMode: this.config.permissionMode,
965
1081
  permissionRules: this.config.permissions,
@@ -972,9 +1088,15 @@ export class Harness {
972
1088
  if (this.lifecycle.signal.aborted)
973
1089
  throw new ConfigError("Harness is closed");
974
1090
  const browser = this.browserFor(session.id);
975
- const tools = browser && browser !== this.browser
1091
+ let tools = browser && browser !== this.browser
976
1092
  ? this.tools.clone().registerAll(this.browserUse.tools(browser))
977
1093
  : this.tools;
1094
+ // Registered for every conversation and shown only to those that switched
1095
+ // it on; see Agent.visibleTools.
1096
+ const computer = this.computerFor(session.id);
1097
+ if (computer && this.computerUse) {
1098
+ tools = (tools === this.tools ? tools.clone() : tools).registerAll(this.computerUse.tools(computer));
1099
+ }
978
1100
  return new Agent({
979
1101
  session,
980
1102
  tools,
@@ -998,17 +1120,96 @@ export class Harness {
998
1120
  cache: this.config.cache,
999
1121
  logger: this.logger,
1000
1122
  lifecycleSignal: this.lifecycle.signal,
1001
- onRunStart: () => {
1002
- let finish;
1003
- const done = new Promise((resolve) => { finish = resolve; });
1004
- this.activeRuns.add(done);
1005
- return () => { this.activeRuns.delete(done); finish(); };
1006
- },
1123
+ worktreeLauncher: () => this.worktreeLauncher,
1124
+ taskWaker: () => this.taskWaker,
1125
+ equipSubagent: (sessionId, label, tools) => this.equipSubagent(sessionId, label, tools),
1126
+ onRunStart: () => this.trackRun(computer, browser, session),
1007
1127
  onAudit: (event) => { if (this.config.audit !== false)
1008
1128
  this.runtimeAudit.publish(event); },
1009
1129
  ...overrides,
1010
1130
  });
1011
1131
  }
1132
+ /**
1133
+ * Count a run toward shutdown's drain. Its end lets go of the windows and
1134
+ * control its conversation held, so another need not wait out the lease.
1135
+ */
1136
+ trackRun(computer, browser, session) {
1137
+ let finish;
1138
+ const done = new Promise((resolve) => { finish = resolve; });
1139
+ this.activeRuns.add(done);
1140
+ return () => {
1141
+ this.activeRuns.delete(done);
1142
+ computer?.endTurn();
1143
+ // A subagent's tabs are not pictured: it has no transcript of its own.
1144
+ if (session)
1145
+ this.pictureBrowserTurn(browser, session);
1146
+ finish();
1147
+ };
1148
+ }
1149
+ /**
1150
+ * The tabs a turn left, beside the conversation (see BrowserUseSession.endTurn).
1151
+ * Where the turn ended is read now, before a queued message starts the next.
1152
+ */
1153
+ pictureBrowserTurn(browser, session) {
1154
+ if (!browser?.endTurn)
1155
+ return;
1156
+ const sink = this.turnShotSink;
1157
+ const at = session.messages.length;
1158
+ void browser
1159
+ .endTurn(Boolean(sink?.enabled()))
1160
+ .then((shots) => { if (shots.length)
1161
+ sink?.save(session.id, at, shots); })
1162
+ .catch(() => { });
1163
+ }
1164
+ /**
1165
+ * A subagent's own browser and computer.
1166
+ *
1167
+ * Without them it would drive its parent's tabs and act under its parent's
1168
+ * window leases, where the arbiter cannot tell the two apart — and several
1169
+ * subagents at once would take each other's pages away. Only the tools the
1170
+ * subagent was already given are rebound; a filter that left the browser
1171
+ * out leaves it out. Both are closed again when the subagent ends.
1172
+ */
1173
+ equipSubagent(sessionId, label, tools) {
1174
+ const browser = this.browser
1175
+ ? this.browserUse.createSession({
1176
+ sessionId,
1177
+ mode: this.browserMode(),
1178
+ logger: this.logger,
1179
+ label: () => label,
1180
+ cwd: this.cwd,
1181
+ })
1182
+ : undefined;
1183
+ if (browser)
1184
+ for (const tool of this.browserUse.tools(browser))
1185
+ if (tools.has(tool.name))
1186
+ tools.register(tool);
1187
+ const computer = this.options.computerUsePlugin?.createSession({ sessionId, label: () => label });
1188
+ if (computer && this.computerUse) {
1189
+ for (const tool of this.computerUse.tools(computer))
1190
+ if (tools.has(tool.name))
1191
+ tools.register(tool);
1192
+ }
1193
+ return {
1194
+ onRunStart: () => this.trackRun(computer),
1195
+ // A later run's registry gets the same browser and computer again.
1196
+ bind: (next) => {
1197
+ if (browser)
1198
+ for (const tool of this.browserUse.tools(browser))
1199
+ if (next.has(tool.name))
1200
+ next.register(tool);
1201
+ if (computer && this.computerUse) {
1202
+ for (const tool of this.computerUse.tools(computer))
1203
+ if (next.has(tool.name))
1204
+ next.register(tool);
1205
+ }
1206
+ },
1207
+ release: async () => {
1208
+ await computer?.close();
1209
+ await browser?.close();
1210
+ },
1211
+ };
1212
+ }
1012
1213
  /**
1013
1214
  * The lazy browser owned by one session.
1014
1215
  *
@@ -1040,11 +1241,35 @@ export class Harness {
1040
1241
  this.sessionBrowsers.set(sessionId, browser);
1041
1242
  return browser;
1042
1243
  }
1244
+ /** Whether this harness can offer Computer Use at all. */
1245
+ get computerUse() {
1246
+ return this.options.computerUsePlugin;
1247
+ }
1248
+ /** One conversation's view of the computer; undefined without the feature. */
1249
+ computerFor(sessionId) {
1250
+ const plugin = this.options.computerUsePlugin;
1251
+ if (!plugin)
1252
+ return undefined;
1253
+ let found = this.sessionComputers.get(sessionId);
1254
+ if (!found) {
1255
+ found = plugin.createSession({ sessionId, label: () => this.sessionTitles.get(sessionId)?.() });
1256
+ this.sessionComputers.set(sessionId, found);
1257
+ }
1258
+ return found;
1259
+ }
1260
+ /** Release a closed conversation's leases and its place in the control queue. */
1261
+ async closeComputerFor(sessionId) {
1262
+ const computer = this.sessionComputers.get(sessionId);
1263
+ this.sessionComputers.delete(sessionId);
1264
+ await computer?.close();
1265
+ }
1043
1266
  /** Release the process and transient page state when a session tab closes. */
1044
1267
  async closeBrowserFor(sessionId) {
1268
+ await this.closeComputerFor(sessionId);
1045
1269
  const browser = this.sessionBrowsers.get(sessionId);
1046
1270
  this.sessionBrowsers.delete(sessionId);
1047
1271
  this.sessionTitles.delete(sessionId);
1272
+ this.sessionModels.delete(sessionId);
1048
1273
  await browser?.close();
1049
1274
  }
1050
1275
  /**
@@ -1053,7 +1278,8 @@ export class Harness {
1053
1278
  */
1054
1279
  async *run(prompt, opts = {}) {
1055
1280
  const session = this.createSession({ model: opts.model });
1056
- const agent = this.agent(session);
1281
+ // One prompt and done: nothing would be left to read a late result into.
1282
+ const agent = this.agent(session, { taskWaker: () => undefined });
1057
1283
  yield* agent.run(prompt, { signal: opts.signal });
1058
1284
  }
1059
1285
  close() {
@@ -1086,11 +1312,42 @@ export class Harness {
1086
1312
  await this.mcp.closeAll();
1087
1313
  // Last, and never awaited for long: a browser that will not close should
1088
1314
  // not be what keeps the process alive.
1315
+ for (const computer of this.sessionComputers.values())
1316
+ await computer.close();
1317
+ this.sessionComputers.clear();
1089
1318
  const browsers = new Set(this.sessionBrowsers.values());
1090
1319
  if (this.browser)
1091
1320
  browsers.add(this.browser);
1092
1321
  await Promise.all([...browsers].map((browser) => browser.close()));
1093
1322
  }
1323
+ /**
1324
+ * One reply from the small model, as text: no tools, no thinking, no
1325
+ * session. For the chores around the work — merge proposals, suggestions —
1326
+ * where the configured default model would be paying for depth nobody reads.
1327
+ * `fallback` is a model the caller knows the user can reach, such as the one
1328
+ * picked in the composer; it wins over the first-configured guess.
1329
+ */
1330
+ async generateText(system, prompt, signal, maxOutputTokens = 8_000, fallback) {
1331
+ return this.generateWith(this.config.smallModel ?? fallback ?? this.config.model ?? this.defaultModel(), system, prompt, signal, maxOutputTokens);
1332
+ }
1333
+ /**
1334
+ * The same one reply, from a model the caller names rather than the small
1335
+ * one: for a chore that should cost and sound like the conversation it is
1336
+ * about, such as the composer's next-message suggestion.
1337
+ */
1338
+ async generateWith(ref, system, prompt, signal, maxOutputTokens = 8_000) {
1339
+ const model = this.providers.resolve(ref);
1340
+ const provider = this.providers.getProvider(model.provider);
1341
+ const parts = [];
1342
+ for await (const event of provider.stream({
1343
+ model, system, messages: [userMessage(prompt)],
1344
+ maxOutputTokens: Math.min(maxOutputTokens, model.maxOutputTokens), thinking: { enabled: false, effort: "low" },
1345
+ ...(signal ? { signal } : {}),
1346
+ }))
1347
+ if (event.type === "text_delta")
1348
+ parts.push(event.text);
1349
+ return parts.join("");
1350
+ }
1094
1351
  /**
1095
1352
  * First model whose provider has credentials. Only the cheap synchronous
1096
1353
  * check runs here — a subscription-only provider is picked up once the