@oharness/engine 0.10.0 → 0.12.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 (118) 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 +51 -1
  11. package/dist/agent/agent.js +322 -1
  12. package/dist/agent/agent.js.map +1 -1
  13. package/dist/agent/hooks.js +6 -6
  14. package/dist/agent/hooks.js.map +1 -1
  15. package/dist/agent/session.d.ts +2 -0
  16. package/dist/agent/session.js +19 -2
  17. package/dist/agent/session.js.map +1 -1
  18. package/dist/agent/skills.d.ts +16 -3
  19. package/dist/agent/skills.js +36 -2
  20. package/dist/agent/skills.js.map +1 -1
  21. package/dist/agent/subagent.d.ts +24 -2
  22. package/dist/agent/subagent.js +76 -16
  23. package/dist/agent/subagent.js.map +1 -1
  24. package/dist/auth/index.d.ts +3 -1
  25. package/dist/auth/index.js +3 -1
  26. package/dist/auth/index.js.map +1 -1
  27. package/dist/auth/manager.js +4 -0
  28. package/dist/auth/manager.js.map +1 -1
  29. package/dist/auth/oauth.d.ts +33 -0
  30. package/dist/auth/oauth.js +59 -8
  31. package/dist/auth/oauth.js.map +1 -1
  32. package/dist/auth/providers/anthropic.d.ts +25 -5
  33. package/dist/auth/providers/anthropic.js +47 -6
  34. package/dist/auth/providers/anthropic.js.map +1 -1
  35. package/dist/auth/providers/chatgpt.d.ts +8 -0
  36. package/dist/auth/providers/chatgpt.js +180 -0
  37. package/dist/auth/providers/chatgpt.js.map +1 -0
  38. package/dist/auth/providers/openrouter.d.ts +2 -0
  39. package/dist/auth/providers/openrouter.js +101 -0
  40. package/dist/auth/providers/openrouter.js.map +1 -0
  41. package/dist/auth/types.d.ts +15 -0
  42. package/dist/auth/types.js.map +1 -1
  43. package/dist/browser/index.d.ts +1 -1
  44. package/dist/browser/plugin.d.ts +6 -1
  45. package/dist/browser/plugin.js.map +1 -1
  46. package/dist/browser/session.d.ts +47 -0
  47. package/dist/browser/session.js +177 -2
  48. package/dist/browser/session.js.map +1 -1
  49. package/dist/browser/tools.js +139 -18
  50. package/dist/browser/tools.js.map +1 -1
  51. package/dist/computer/cua-driver.d.ts +60 -1
  52. package/dist/computer/cua-driver.js +149 -5
  53. package/dist/computer/cua-driver.js.map +1 -1
  54. package/dist/computer/helper-client.d.ts +3 -1
  55. package/dist/computer/helper-client.js +3 -0
  56. package/dist/computer/helper-client.js.map +1 -1
  57. package/dist/computer/index.d.ts +3 -2
  58. package/dist/computer/index.js +1 -1
  59. package/dist/computer/index.js.map +1 -1
  60. package/dist/computer/mode.d.ts +15 -0
  61. package/dist/computer/mode.js +11 -1
  62. package/dist/computer/mode.js.map +1 -1
  63. package/dist/computer/session.d.ts +36 -7
  64. package/dist/computer/session.js +118 -17
  65. package/dist/computer/session.js.map +1 -1
  66. package/dist/computer/target.d.ts +19 -0
  67. package/dist/computer/target.js.map +1 -1
  68. package/dist/computer/tools.js +135 -8
  69. package/dist/computer/tools.js.map +1 -1
  70. package/dist/config.d.ts +16 -0
  71. package/dist/config.js +2 -0
  72. package/dist/config.js.map +1 -1
  73. package/dist/foreign.d.ts +81 -0
  74. package/dist/foreign.js +198 -0
  75. package/dist/foreign.js.map +1 -0
  76. package/dist/harness.d.ts +79 -0
  77. package/dist/harness.js +241 -17
  78. package/dist/harness.js.map +1 -1
  79. package/dist/mcp/manager.d.ts +15 -0
  80. package/dist/mcp/manager.js +28 -1
  81. package/dist/mcp/manager.js.map +1 -1
  82. package/dist/mcp/registry.d.ts +2 -0
  83. package/dist/mcp/registry.js +1 -0
  84. package/dist/mcp/registry.js.map +1 -1
  85. package/dist/mcp/registry.json +394 -1
  86. package/dist/paths.d.ts +19 -0
  87. package/dist/paths.js +38 -0
  88. package/dist/paths.js.map +1 -1
  89. package/dist/plugin-market.d.ts +57 -0
  90. package/dist/plugin-market.js +229 -0
  91. package/dist/plugin-market.js.map +1 -0
  92. package/dist/plugin-market.json +1246 -0
  93. package/dist/plugins.d.ts +49 -2
  94. package/dist/plugins.js +252 -10
  95. package/dist/plugins.js.map +1 -1
  96. package/dist/providers/catalog.json +14 -3
  97. package/dist/providers/models-dev.d.ts +15 -0
  98. package/dist/providers/models-dev.js +14 -0
  99. package/dist/providers/models-dev.js.map +1 -1
  100. package/dist/providers/piai.d.ts +38 -1
  101. package/dist/providers/piai.js +70 -2
  102. package/dist/providers/piai.js.map +1 -1
  103. package/dist/providers/registry.d.ts +16 -1
  104. package/dist/providers/registry.js +42 -11
  105. package/dist/providers/registry.js.map +1 -1
  106. package/dist/runtime-plugins/builtin/studio.d.ts +7 -0
  107. package/dist/runtime-plugins/builtin/studio.js +69 -4
  108. package/dist/runtime-plugins/builtin/studio.js.map +1 -1
  109. package/dist/tools/builtin/bash.js +6 -10
  110. package/dist/tools/builtin/bash.js.map +1 -1
  111. package/dist/util/shell.d.ts +30 -0
  112. package/dist/util/shell.js +70 -0
  113. package/dist/util/shell.js.map +1 -1
  114. package/dist/util/toml.d.ts +22 -0
  115. package/dist/util/toml.js +305 -0
  116. package/dist/util/toml.js.map +1 -0
  117. package/package.json +4 -4
  118. package/skills/media-generation/SKILL.md +22 -1
package/dist/harness.d.ts CHANGED
@@ -27,6 +27,7 @@ import type { Catalog, CatalogProvider } from "./providers/catalog.ts";
27
27
  import type { EffortChoice, ModelSpec } from "./providers/types.ts";
28
28
  import type { BrowserTargetFactory } from "./browser/target.ts";
29
29
  import type { BrowserUsePlugin, BrowserUseSession } from "./browser/plugin.ts";
30
+ import type { BrowserTurnShot } from "./browser/session.ts";
30
31
  import type { ComputerUsePlugin } from "./computer/plugin.ts";
31
32
  import type { ComputerUseSession } from "./computer/session.ts";
32
33
  import type { BrowserProviderPlugin } from "./browser/provider.ts";
@@ -126,6 +127,15 @@ export declare class Harness {
126
127
  * offered and `task` runs everything in the foreground.
127
128
  */
128
129
  taskWaker?: (sessionId: string) => void;
130
+ /**
131
+ * Where pictures of the browser tabs a turn left go, and whether to take
132
+ * them at all. Set by a frontend that shows them; while unset, none are.
133
+ * `at` is the conversation's message count when the turn ended.
134
+ */
135
+ turnShotSink?: {
136
+ enabled(): boolean;
137
+ save(sessionId: string, at: number, shots: BrowserTurnShot[]): void;
138
+ };
129
139
  /** Browser state belongs to a conversation, not to its project harness. */
130
140
  private readonly sessionBrowsers;
131
141
  /** Computer Use state belongs to a conversation too. */
@@ -212,6 +222,30 @@ export declare class Harness {
212
222
  * started with the browser off has no tools to switch, so that one stays as
213
223
  * it is until a restart.
214
224
  */
225
+ /** The Claude Agent definition this harness registered, while it is on. */
226
+ private claudeRegistered?;
227
+ /**
228
+ * Whether Claude Agent is switched on, and whether it could be.
229
+ *
230
+ * `available` is whether this machine has the user's `claude` (and `npx`
231
+ * for the adapter); `unavailable` is the reason it would not run even so,
232
+ * such as an old Node. Detected afresh each time, since installing Claude
233
+ * Code is something done in another window while this page is open.
234
+ */
235
+ claudeAgentStatus(): Promise<{
236
+ on: boolean;
237
+ available: boolean;
238
+ unavailable?: string;
239
+ }>;
240
+ /**
241
+ * Turn Claude Agent on or off, now and for later launches.
242
+ *
243
+ * Written to the user's own config, the same place `claudeAgent` is read
244
+ * from at startup, so the terminal frontends and the page agree. Takes
245
+ * effect at once: open conversations share this harness's subagent list.
246
+ */
247
+ setClaudeAgent(on: boolean): Promise<void>;
248
+ private registerClaudeAgent;
215
249
  setBrowserMode(mode: "auto" | "builtin" | "extension"): Promise<void>;
216
250
  /**
217
251
  * Whether permission decisions are being recorded, and who decided that.
@@ -370,6 +404,12 @@ export declare class Harness {
370
404
  * happen to — and answers 404 when picked. Nothing to refresh from (no
371
405
  * listing ever made) counts as stale. Resolves to undefined when the list
372
406
  * was fresh enough to leave alone.
407
+ *
408
+ * The age is the oldest provider's, from `discovered`, not `discoveredAt`:
409
+ * that one moves whenever any single provider is listed — a sign-in, a
410
+ * key saved for one vendor — and read alone it kept every other provider's
411
+ * week-old list looking fresh. `discoveredAt` is the fallback for a file
412
+ * written before `discovered` existed.
373
413
  */
374
414
  refreshStaleModels(maxAgeMs?: number): Promise<ModelSpec[] | undefined>;
375
415
  /**
@@ -390,6 +430,7 @@ export declare class Harness {
390
430
  * it: merging only ever grew the file, so a model the vendor withdrew stayed
391
431
  * in the picker for good. `discovered` records which providers have a live
392
432
  * listing, so the pi-ai snapshot stops filling in models for them.
433
+ * `cleared` names local servers that listed nothing, whose entries go.
393
434
  */
394
435
  private saveDiscovered;
395
436
  /**
@@ -400,6 +441,33 @@ export declare class Harness {
400
441
  * which breaks the one loop that would let the agent improve itself.
401
442
  */
402
443
  reloadSkills(): Promise<number>;
444
+ /**
445
+ * Switch one skill off or back on, now and for later launches.
446
+ *
447
+ * Off rather than deleted: most skills in the list are files another tool
448
+ * put there — Claude Code's, Codex's, a plugin's — and removing someone
449
+ * else's file to hide it from this harness would take it from that tool
450
+ * too.
451
+ */
452
+ setSkillEnabled(name: string, on: boolean): Promise<void>;
453
+ /**
454
+ * Every markdown agent this harness reads, with the dialect each was
455
+ * written in: a Claude Code plugin's names tools and models the way
456
+ * Claude Code does, and is translated on the way in.
457
+ */
458
+ private markdownAgents;
459
+ /**
460
+ * Take in a plugin that arrived while the harness was running.
461
+ *
462
+ * What `create` does for every plugin at launch, for one: its skills and
463
+ * agents join the registries, its servers connect. Without this an install
464
+ * from the menu would sit inert until a restart, which reads as a broken
465
+ * install. A server name the config already has is left to the config, the
466
+ * same precedence a launch gives it.
467
+ */
468
+ adoptPlugin(directory: string): Promise<PluginDefinition | undefined>;
469
+ /** Undo `adoptPlugin` for a plugin whose directory is gone. */
470
+ dropPlugin(directory: string): Promise<void>;
403
471
  /** Providers declared by the catalog, for `/models` and login hints. */
404
472
  listCatalogProviders(): CatalogProvider[];
405
473
  listModels(configuredOnly?: boolean): ModelSpec[];
@@ -472,6 +540,11 @@ export declare class Harness {
472
540
  * control its conversation held, so another need not wait out the lease.
473
541
  */
474
542
  private trackRun;
543
+ /**
544
+ * The tabs a turn left, beside the conversation (see BrowserUseSession.endTurn).
545
+ * Where the turn ended is read now, before a queued message starts the next.
546
+ */
547
+ private pictureBrowserTurn;
475
548
  /**
476
549
  * A subagent's own browser and computer.
477
550
  *
@@ -517,6 +590,12 @@ export declare class Harness {
517
590
  * picked in the composer; it wins over the first-configured guess.
518
591
  */
519
592
  generateText(system: string, prompt: string, signal?: AbortSignal, maxOutputTokens?: number, fallback?: string): Promise<string>;
593
+ /**
594
+ * The same one reply, from a model the caller names rather than the small
595
+ * one: for a chore that should cost and sound like the conversation it is
596
+ * about, such as the composer's next-message suggestion.
597
+ */
598
+ generateWith(ref: string, system: string, prompt: string, signal?: AbortSignal, maxOutputTokens?: number): Promise<string>;
520
599
  /**
521
600
  * First model whose provider has credentials. Only the cheap synchronous
522
601
  * check runs here — a subscription-only provider is picked up once the
package/dist/harness.js CHANGED
@@ -6,6 +6,8 @@ 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 { foreignAgentRoots, foreignSkillRoots } from "./foreign.js";
10
+ import { claudeAgent } from "./acp/claude.js";
9
11
  import { SkillRegistry } from "./agent/skills.js";
10
12
  import { SettingsStore } from "./agent/settings.js";
11
13
  import { createPolicy } from "./agent/workspace.js";
@@ -73,6 +75,12 @@ export class Harness {
73
75
  * offered and `task` runs everything in the foreground.
74
76
  */
75
77
  taskWaker;
78
+ /**
79
+ * Where pictures of the browser tabs a turn left go, and whether to take
80
+ * them at all. Set by a frontend that shows them; while unset, none are.
81
+ * `at` is the conversation's message count when the turn ended.
82
+ */
83
+ turnShotSink;
76
84
  /** Browser state belongs to a conversation, not to its project harness. */
77
85
  sessionBrowsers = new Map();
78
86
  /** Computer Use state belongs to a conversation too. */
@@ -198,7 +206,14 @@ export class Harness {
198
206
  ...(config.skillDirs ?? []),
199
207
  ...(config.agentDirs ?? []),
200
208
  ],
201
- ...(config.readableRoots !== undefined ? { readableRoots: config.readableRoots } : {}),
209
+ // Claude Code's and Codex's skill and agent directories are read, so
210
+ // the agent can open a skill's scripts/, but not written: those belong
211
+ // to the user's other tools, not to anything this harness installed.
212
+ readableRoots: [
213
+ ...(config.readableRoots ?? []),
214
+ ...Object.values(foreignSkillRoots(this.cwd, config.imports)).flat().map((root) => root.dir),
215
+ ...Object.values(foreignAgentRoots(this.cwd, config.imports)).flat().map((root) => root.dir),
216
+ ],
202
217
  });
203
218
  this.mcp = new McpManager(this.logger, this.auth.store, (id) => this.auth.secret(id));
204
219
  this.store = new SessionStore(config.sessionDir);
@@ -234,7 +249,7 @@ export class Harness {
234
249
  : (await ensureProjectManifest(cwd)).id;
235
250
  opts = { ...opts, cwd };
236
251
  const baseConfig = opts.config ?? (await loadConfig(cwd));
237
- const plugins = await discoverPlugins(cwd, baseConfig.plugins);
252
+ const plugins = await discoverPlugins(cwd, pluginOptions(baseConfig));
238
253
  // Explicit user/project config is applied after plugins, so it can always
239
254
  // tighten policy or replace a plugin's MCP server declaration.
240
255
  const config = mergeConfig(pluginContributions(plugins), baseConfig);
@@ -262,10 +277,16 @@ export class Harness {
262
277
  })
263
278
  .catch((err) => harness.logger.warn("session pruning failed", { error: String(err) }));
264
279
  }
265
- await harness.skills.discover(cwd, config.skillDirs ?? []);
280
+ harness.skills.setDisabled(config.disabledSkills ?? []);
281
+ await harness.skills.discover(cwd, config.skillDirs ?? [], config.imports ?? {});
282
+ // Claude Agent, when the user turned it on and this machine has their
283
+ // own Claude Code to hand work to. Before the markdown ones, so a
284
+ // project can redefine it.
285
+ if (config.claudeAgent === true)
286
+ await harness.registerClaudeAgent();
266
287
  // Markdown definitions layer over the JSON ones, so a repo can drop in an
267
288
  // agent file without editing config.
268
- for (const definition of await loadMarkdownSubagents(cwd, config.agentDirs ?? [])) {
289
+ for (const definition of await harness.markdownAgents()) {
269
290
  harness.subagents.register(definition);
270
291
  }
271
292
  if (config.mcpServers && Object.keys(config.mcpServers).length > 0) {
@@ -422,6 +443,50 @@ export class Harness {
422
443
  * started with the browser off has no tools to switch, so that one stays as
423
444
  * it is until a restart.
424
445
  */
446
+ /** The Claude Agent definition this harness registered, while it is on. */
447
+ claudeRegistered;
448
+ /**
449
+ * Whether Claude Agent is switched on, and whether it could be.
450
+ *
451
+ * `available` is whether this machine has the user's `claude` (and `npx`
452
+ * for the adapter); `unavailable` is the reason it would not run even so,
453
+ * such as an old Node. Detected afresh each time, since installing Claude
454
+ * Code is something done in another window while this page is open.
455
+ */
456
+ async claudeAgentStatus() {
457
+ const found = await claudeAgent().catch(() => undefined);
458
+ return {
459
+ on: this.config.claudeAgent === true,
460
+ available: Boolean(found),
461
+ ...(found?.runtime?.unavailable ? { unavailable: found.runtime.unavailable } : {}),
462
+ };
463
+ }
464
+ /**
465
+ * Turn Claude Agent on or off, now and for later launches.
466
+ *
467
+ * Written to the user's own config, the same place `claudeAgent` is read
468
+ * from at startup, so the terminal frontends and the page agree. Takes
469
+ * effect at once: open conversations share this harness's subagent list.
470
+ */
471
+ async setClaudeAgent(on) {
472
+ await updateUserConfig((current) => ({ ...current, claudeAgent: on }));
473
+ this.config.claudeAgent = on;
474
+ if (on)
475
+ await this.registerClaudeAgent();
476
+ else if (this.claudeRegistered) {
477
+ this.subagents.unregister(this.claudeRegistered);
478
+ this.claudeRegistered = undefined;
479
+ }
480
+ }
481
+ async registerClaudeAgent() {
482
+ if (this.claudeRegistered || process.env["OHARNESS_NO_CLAUDE_AGENT"] === "1")
483
+ return;
484
+ const claude = await claudeAgent().catch(() => undefined);
485
+ if (!claude)
486
+ return;
487
+ this.subagents.register(claude);
488
+ this.claudeRegistered = claude;
489
+ }
425
490
  async setBrowserMode(mode) {
426
491
  await updateUserConfig((current) => ({ ...current, browser: { ...current.browser, mode } }));
427
492
  this.config.browser = { ...this.config.browser, mode };
@@ -771,9 +836,9 @@ export class Harness {
771
836
  * the user catalog so the next run starts with a current list.
772
837
  */
773
838
  async refreshModels(providerId) {
774
- const discovered = await this.providers.refreshModels(providerId);
775
- if (discovered.length > 0) {
776
- await this.saveDiscovered(discovered).catch((err) => this.logger.warn("could not persist the discovered catalog", {
839
+ const { changed: discovered, cleared } = await this.providers.refreshListings(providerId);
840
+ if (discovered.length > 0 || cleared.length > 0) {
841
+ await this.saveDiscovered(discovered, cleared).catch((err) => this.logger.warn("could not persist the discovered catalog", {
777
842
  error: String(err),
778
843
  }));
779
844
  }
@@ -788,12 +853,22 @@ export class Harness {
788
853
  * happen to — and answers 404 when picked. Nothing to refresh from (no
789
854
  * listing ever made) counts as stale. Resolves to undefined when the list
790
855
  * was fresh enough to leave alone.
856
+ *
857
+ * The age is the oldest provider's, from `discovered`, not `discoveredAt`:
858
+ * that one moves whenever any single provider is listed — a sign-in, a
859
+ * key saved for one vendor — and read alone it kept every other provider's
860
+ * week-old list looking fresh. `discoveredAt` is the fallback for a file
861
+ * written before `discovered` existed.
791
862
  */
792
863
  async refreshStaleModels(maxAgeMs = MODEL_LIST_MAX_AGE_MS) {
793
864
  let at = NaN;
794
865
  try {
795
866
  const raw = JSON.parse(await readFile(userCatalogPath(), "utf8"));
796
- at = Date.parse(String(raw["discoveredAt"] ?? ""));
867
+ const each = raw["discovered"];
868
+ const times = each && typeof each === "object" && !Array.isArray(each)
869
+ ? Object.values(each).map((value) => Date.parse(String(value))).filter(Number.isFinite)
870
+ : [];
871
+ at = times.length > 0 ? Math.min(...times) : Date.parse(String(raw["discoveredAt"] ?? ""));
797
872
  }
798
873
  catch {
799
874
  // No file or an unreadable one: never listed, so due.
@@ -828,8 +903,9 @@ export class Harness {
828
903
  * it: merging only ever grew the file, so a model the vendor withdrew stayed
829
904
  * in the picker for good. `discovered` records which providers have a live
830
905
  * listing, so the pi-ai snapshot stops filling in models for them.
906
+ * `cleared` names local servers that listed nothing, whose entries go.
831
907
  */
832
- async saveDiscovered(models) {
908
+ async saveDiscovered(models, cleared = []) {
833
909
  const file = userCatalogPath();
834
910
  let existing = {};
835
911
  try {
@@ -841,7 +917,7 @@ export class Harness {
841
917
  const byId = new Map((Array.isArray(existing["models"]) ? existing["models"] : [])
842
918
  .filter((m) => !!m && typeof m === "object")
843
919
  .map((m) => [String(m["id"]), m]));
844
- const listed = new Set(models.map((model) => model.provider));
920
+ const listed = new Set([...models.map((model) => model.provider), ...cleared]);
845
921
  const fresh = new Set(models.map((model) => model.id));
846
922
  for (const [id, model] of byId) {
847
923
  const { provider, model: wire } = model;
@@ -872,9 +948,109 @@ export class Harness {
872
948
  * which breaks the one loop that would let the agent improve itself.
873
949
  */
874
950
  async reloadSkills() {
875
- await this.skills.discover(this.cwd, this.config.skillDirs ?? []);
951
+ await this.skills.discover(this.cwd, this.config.skillDirs ?? [], this.config.imports ?? {});
876
952
  return this.skills.size;
877
953
  }
954
+ /**
955
+ * Switch one skill off or back on, now and for later launches.
956
+ *
957
+ * Off rather than deleted: most skills in the list are files another tool
958
+ * put there — Claude Code's, Codex's, a plugin's — and removing someone
959
+ * else's file to hide it from this harness would take it from that tool
960
+ * too.
961
+ */
962
+ async setSkillEnabled(name, on) {
963
+ await updateUserConfig((current) => {
964
+ const off = new Set(current.disabledSkills ?? []);
965
+ if (on)
966
+ off.delete(name);
967
+ else
968
+ off.add(name);
969
+ return { ...current, disabledSkills: [...off].sort() };
970
+ });
971
+ const off = new Set(this.config.disabledSkills ?? []);
972
+ if (on)
973
+ off.delete(name);
974
+ else
975
+ off.add(name);
976
+ this.config.disabledSkills = [...off];
977
+ this.skills.setDisabled(off);
978
+ }
979
+ /**
980
+ * Every markdown agent this harness reads, with the dialect each was
981
+ * written in: a Claude Code plugin's names tools and models the way
982
+ * Claude Code does, and is translated on the way in.
983
+ */
984
+ async markdownAgents() {
985
+ const claude = new Set(this.plugins.filter((plugin) => plugin.claudeFormat).map((plugin) => plugin.agentDir));
986
+ const roots = (this.config.agentDirs ?? []).map((dir) => claude.has(dir) ? { dir, dialect: "claude" } : { dir });
987
+ return loadMarkdownSubagents(this.cwd, roots, this.config.imports ?? {});
988
+ }
989
+ /**
990
+ * Take in a plugin that arrived while the harness was running.
991
+ *
992
+ * What `create` does for every plugin at launch, for one: its skills and
993
+ * agents join the registries, its servers connect. Without this an install
994
+ * from the menu would sit inert until a restart, which reads as a broken
995
+ * install. A server name the config already has is left to the config, the
996
+ * same precedence a launch gives it.
997
+ */
998
+ async adoptPlugin(directory) {
999
+ const plugin = (await discoverPlugins(this.cwd, pluginOptions(this.config))).find((one) => one.directory === directory);
1000
+ if (!plugin)
1001
+ return undefined;
1002
+ const known = this.plugins.findIndex((one) => one.name === plugin.name);
1003
+ if (known >= 0)
1004
+ this.plugins.splice(known, 1, plugin);
1005
+ else
1006
+ this.plugins.push(plugin);
1007
+ const contributed = pluginContributions([plugin]);
1008
+ this.config.skillDirs = [...new Set([...(this.config.skillDirs ?? []), ...contributed.skillDirs])];
1009
+ this.config.agentDirs = [...new Set([...(this.config.agentDirs ?? []), ...contributed.agentDirs])];
1010
+ await this.reloadSkills();
1011
+ for (const definition of contributed.subagents)
1012
+ this.subagents.register(definition);
1013
+ for (const definition of await this.markdownAgents()) {
1014
+ this.subagents.register(definition);
1015
+ }
1016
+ for (const [name, server] of Object.entries(contributed.mcpServers)) {
1017
+ if (this.config.mcpServers?.[name] || this.mcp.configFor(name))
1018
+ continue;
1019
+ this.config.mcpServers = { ...(this.config.mcpServers ?? {}), [name]: server };
1020
+ // One server that will not start is not a failed install; its row says
1021
+ // why, as any connector's does.
1022
+ await this.addMcpServer(name, server).catch((err) => this.logger.warn("plugin server unavailable", { server: name, error: String(err) }));
1023
+ }
1024
+ return plugin;
1025
+ }
1026
+ /** Undo `adoptPlugin` for a plugin whose directory is gone. */
1027
+ async dropPlugin(directory) {
1028
+ const index = this.plugins.findIndex((one) => one.directory === directory);
1029
+ if (index < 0)
1030
+ return;
1031
+ const [plugin] = this.plugins.splice(index, 1);
1032
+ const contributed = pluginContributions([plugin]);
1033
+ const before = await this.markdownAgents();
1034
+ this.config.skillDirs = (this.config.skillDirs ?? []).filter((dir) => !contributed.skillDirs.includes(dir));
1035
+ this.config.agentDirs = (this.config.agentDirs ?? []).filter((dir) => !contributed.agentDirs.includes(dir));
1036
+ await this.reloadSkills();
1037
+ const after = new Set((await this.markdownAgents()).map((one) => one.name));
1038
+ for (const definition of [...before, ...contributed.subagents]) {
1039
+ if (after.has(definition.name))
1040
+ continue;
1041
+ const registered = this.subagents.get(definition.name);
1042
+ if (registered)
1043
+ this.subagents.unregister(registered);
1044
+ }
1045
+ for (const name of Object.keys(contributed.mcpServers)) {
1046
+ const mine = this.config.mcpServers?.[name] === contributed.mcpServers[name];
1047
+ if (!mine)
1048
+ continue;
1049
+ const { [name]: _gone, ...rest } = this.config.mcpServers ?? {};
1050
+ this.config.mcpServers = rest;
1051
+ await this.removeMcpServer(name);
1052
+ }
1053
+ }
878
1054
  /** Providers declared by the catalog, for `/models` and login hints. */
879
1055
  listCatalogProviders() {
880
1056
  return this.providers.listCatalogProviders();
@@ -975,7 +1151,8 @@ export class Harness {
975
1151
  */
976
1152
  async forkSession(id, opts = {}) {
977
1153
  const forked = await Session.fork(id, {
978
- model: opts.model ?? this.config.model ?? this.defaultModel(),
1154
+ model: opts.model ?? "",
1155
+ fallbackModel: this.config.model ?? this.defaultModel(),
979
1156
  providers: this.providers,
980
1157
  permissionMode: this.config.permissionMode,
981
1158
  permissionRules: this.config.permissions,
@@ -1004,7 +1181,10 @@ export class Harness {
1004
1181
  /** Reload a persisted session by id; returns undefined if it is unknown. */
1005
1182
  async resumeSession(id, model) {
1006
1183
  const resumed = await Session.resume(id, {
1007
- model: model ?? this.config.model ?? this.defaultModel(),
1184
+ // Empty means "the session's own": a conversation reopens on the model
1185
+ // it was using, and the default is only for one whose model is gone.
1186
+ model: model ?? "",
1187
+ fallbackModel: this.config.model ?? this.defaultModel(),
1008
1188
  providers: this.providers,
1009
1189
  permissionMode: this.config.permissionMode,
1010
1190
  permissionRules: this.config.permissions,
@@ -1052,7 +1232,7 @@ export class Harness {
1052
1232
  worktreeLauncher: () => this.worktreeLauncher,
1053
1233
  taskWaker: () => this.taskWaker,
1054
1234
  equipSubagent: (sessionId, label, tools) => this.equipSubagent(sessionId, label, tools),
1055
- onRunStart: () => this.trackRun(computer),
1235
+ onRunStart: () => this.trackRun(computer, browser, session),
1056
1236
  onAudit: (event) => { if (this.config.audit !== false)
1057
1237
  this.runtimeAudit.publish(event); },
1058
1238
  ...overrides,
@@ -1062,11 +1242,33 @@ export class Harness {
1062
1242
  * Count a run toward shutdown's drain. Its end lets go of the windows and
1063
1243
  * control its conversation held, so another need not wait out the lease.
1064
1244
  */
1065
- trackRun(computer) {
1245
+ trackRun(computer, browser, session) {
1066
1246
  let finish;
1067
1247
  const done = new Promise((resolve) => { finish = resolve; });
1068
1248
  this.activeRuns.add(done);
1069
- return () => { this.activeRuns.delete(done); computer?.endTurn(); finish(); };
1249
+ return () => {
1250
+ this.activeRuns.delete(done);
1251
+ computer?.endTurn();
1252
+ // A subagent's tabs are not pictured: it has no transcript of its own.
1253
+ if (session)
1254
+ this.pictureBrowserTurn(browser, session);
1255
+ finish();
1256
+ };
1257
+ }
1258
+ /**
1259
+ * The tabs a turn left, beside the conversation (see BrowserUseSession.endTurn).
1260
+ * Where the turn ended is read now, before a queued message starts the next.
1261
+ */
1262
+ pictureBrowserTurn(browser, session) {
1263
+ if (!browser?.endTurn)
1264
+ return;
1265
+ const sink = this.turnShotSink;
1266
+ const at = session.messages.length;
1267
+ void browser
1268
+ .endTurn(Boolean(sink?.enabled()))
1269
+ .then((shots) => { if (shots.length)
1270
+ sink?.save(session.id, at, shots); })
1271
+ .catch(() => { });
1070
1272
  }
1071
1273
  /**
1072
1274
  * A subagent's own browser and computer.
@@ -1235,7 +1437,15 @@ export class Harness {
1235
1437
  * picked in the composer; it wins over the first-configured guess.
1236
1438
  */
1237
1439
  async generateText(system, prompt, signal, maxOutputTokens = 8_000, fallback) {
1238
- const model = this.providers.resolve(this.config.smallModel ?? fallback ?? this.config.model ?? this.defaultModel());
1440
+ return this.generateWith(this.config.smallModel ?? fallback ?? this.config.model ?? this.defaultModel(), system, prompt, signal, maxOutputTokens);
1441
+ }
1442
+ /**
1443
+ * The same one reply, from a model the caller names rather than the small
1444
+ * one: for a chore that should cost and sound like the conversation it is
1445
+ * about, such as the composer's next-message suggestion.
1446
+ */
1447
+ async generateWith(ref, system, prompt, signal, maxOutputTokens = 8_000) {
1448
+ const model = this.providers.resolve(ref);
1239
1449
  const provider = this.providers.getProvider(model.provider);
1240
1450
  const parts = [];
1241
1451
  for await (const event of provider.stream({
@@ -1260,4 +1470,18 @@ export class Harness {
1260
1470
  return first.id;
1261
1471
  }
1262
1472
  }
1473
+ /**
1474
+ * Plugin discovery, with `imports` deciding for the two other tools' caches
1475
+ * where `plugins` does not: switching Codex off is switching off its skills,
1476
+ * servers and plugins together.
1477
+ */
1478
+ function pluginOptions(config) {
1479
+ const claudeCode = config.plugins?.claudeCode ?? config.imports?.claudeCode;
1480
+ const codex = config.plugins?.codex ?? config.imports?.codex;
1481
+ return {
1482
+ ...config.plugins,
1483
+ ...(claudeCode !== undefined ? { claudeCode } : {}),
1484
+ ...(codex !== undefined ? { codex } : {}),
1485
+ };
1486
+ }
1263
1487
  //# sourceMappingURL=harness.js.map