operon-agents 0.1.0-alpha.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 (71) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +18 -0
  3. package/dist/app-server/client.d.ts +80 -0
  4. package/dist/app-server/client.js +187 -0
  5. package/dist/app-server/codec.d.ts +52 -0
  6. package/dist/app-server/codec.js +252 -0
  7. package/dist/app-server/index.d.ts +22 -0
  8. package/dist/app-server/index.js +22 -0
  9. package/dist/app-server/main.d.ts +3 -0
  10. package/dist/app-server/main.js +85 -0
  11. package/dist/app-server/protocol.d.ts +217 -0
  12. package/dist/app-server/protocol.js +100 -0
  13. package/dist/app-server/server.d.ts +51 -0
  14. package/dist/app-server/server.js +274 -0
  15. package/dist/core.d.ts +12 -0
  16. package/dist/core.js +12 -0
  17. package/dist/cron/clock.d.ts +11 -0
  18. package/dist/cron/clock.js +19 -0
  19. package/dist/cron/cron-expr.d.ts +15 -0
  20. package/dist/cron/cron-expr.js +267 -0
  21. package/dist/cron/extension.d.ts +25 -0
  22. package/dist/cron/extension.js +141 -0
  23. package/dist/cron/index.d.ts +20 -0
  24. package/dist/cron/index.js +11 -0
  25. package/dist/cron/jitter.d.ts +18 -0
  26. package/dist/cron/jitter.js +46 -0
  27. package/dist/cron/manager.d.ts +55 -0
  28. package/dist/cron/manager.js +123 -0
  29. package/dist/cron/persist.d.ts +10 -0
  30. package/dist/cron/persist.js +22 -0
  31. package/dist/cron/scheduler.d.ts +25 -0
  32. package/dist/cron/scheduler.js +192 -0
  33. package/dist/cron/session-store.d.ts +14 -0
  34. package/dist/cron/session-store.js +50 -0
  35. package/dist/cron/time-format.d.ts +2 -0
  36. package/dist/cron/time-format.js +12 -0
  37. package/dist/cron/tools.d.ts +7 -0
  38. package/dist/cron/tools.js +161 -0
  39. package/dist/cron/types.d.ts +9 -0
  40. package/dist/cron/types.js +2 -0
  41. package/dist/extensions/index.d.ts +23 -0
  42. package/dist/extensions/index.js +41 -0
  43. package/dist/extensions/loader.d.ts +121 -0
  44. package/dist/extensions/loader.js +285 -0
  45. package/dist/extensions/manager.d.ts +103 -0
  46. package/dist/extensions/manager.js +190 -0
  47. package/dist/extensions/runtime.d.ts +188 -0
  48. package/dist/extensions/runtime.js +1127 -0
  49. package/dist/extensions/services.d.ts +85 -0
  50. package/dist/extensions/services.js +265 -0
  51. package/dist/extensions/types.d.ts +594 -0
  52. package/dist/extensions/types.js +2 -0
  53. package/dist/harness.d.ts +746 -0
  54. package/dist/harness.js +1483 -0
  55. package/dist/index.d.ts +24 -0
  56. package/dist/index.js +29 -0
  57. package/dist/internal.d.ts +9 -0
  58. package/dist/internal.js +9 -0
  59. package/dist/llm.d.ts +2 -0
  60. package/dist/llm.js +2 -0
  61. package/dist/local.d.ts +24 -0
  62. package/dist/local.js +58 -0
  63. package/dist/mcp.d.ts +2 -0
  64. package/dist/mcp.js +2 -0
  65. package/dist/protocol.d.ts +2 -0
  66. package/dist/protocol.js +2 -0
  67. package/dist/tools.d.ts +2 -0
  68. package/dist/tools.js +2 -0
  69. package/dist/tracing.d.ts +2 -0
  70. package/dist/tracing.js +2 -0
  71. package/package.json +78 -0
@@ -0,0 +1,1483 @@
1
+ /**
2
+ * Mode 2 — batteries-included, in-process Harness facade.
3
+ *
4
+ * Wraps the `operon-agents-core` engine into the kind of ergonomic interface a CLI
5
+ * backend wants: `createHarness()` manages sessions, and each session exposes
6
+ * `prompt()/steer()/followUp()/cancel()/onEvent()/setApprovalHandler()` plus the full
7
+ * control plane (goals, plan mode, compaction, skills, plugins, …) delegated to
8
+ * the core `Session`.
9
+ *
10
+ * It is a pure composition of core primitives — no new engine behavior. For the
11
+ * low-level "assemble it yourself / stateless server" path, import from
12
+ * `operon-agents/core` instead.
13
+ */
14
+ import { readLog, SessionProjection, ListenerSink, agentEventFromRecord, newAgentEventId, Agent, buildAgentFromProfile, INTERRUPTION_STATE_KEY, flattenPendingInterrupts, parseInterruptionState, Runner, Session, emptyUsage, renderSteerText, isGuardrailTripwireError, askUserQuestionTool, backgroundCapability, compactionCapability, filesystemTools, goalCapability, LocalMachine, McpOAuthService, MemorySessionRepository, SessionRepositoryNotFoundError, planCapability, skillsCapability, SkillRegistry, mcpServersCapability, pluginsCapability, userHooksCapability, taskCapability, workflowCapability, DEFAULT_AGENT_PROFILES, DEFAULT_AGENT_PROFILE_NAME, DEFAULT_SUBAGENT_NAMES, profileSubagentProvider, DEFAULT_ADDRESS, } from "operon-agents-core";
15
+ import { mkdirSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { extensionsCapability, ExtensionRuntime, HarnessExtensionManager, ServiceRegistry, stageDefinition } from "./extensions/index.js";
18
+ import { createExtensionCommandRegistry } from "operon-agents-core";
19
+ /** Rendezvous budget for `replaceExtension` when the caller names none. */
20
+ const DEFAULT_REPLACE_TIMEOUT_MS = 30_000;
21
+ /** Session-store key holding the per-session extension params map (see `createSession({ params })`). */
22
+ const EXTENSION_PARAMS_STATE_KEY = "extensions:params";
23
+ /**
24
+ * The standard coding-agent capability bundle (goal / plan / todo / skills /
25
+ * cron / background / compaction). Filesystem access is NOT here — it is a tool
26
+ * on the agent (`filesystemTools()`), not a capability; so is AskUserQuestion
27
+ * (`askUserQuestionTool`, wired through ToolRunContext.responder).
28
+ *
29
+ * With `mcpServers` and/or `pluginManager`, an MCP capability is added; with `pluginManager`, the
30
+ * plugin's skills/MCP/session-start are wired in too. Intended to be called fresh per session (via
31
+ * a harness capability factory) so per-session state (goal/plan/todo/…) and the plugin set are
32
+ * isolated per session, mirroring a fresh-Session-per-create model.
33
+ */
34
+ export function defaultCapabilities(options = {}) {
35
+ const manager = options.pluginManager;
36
+ // Shared registry so the plugin session-start injector can render a skill the skills capability
37
+ // loaded (its roots include the plugin skill dirs below).
38
+ const registry = manager !== undefined ? new SkillRegistry() : undefined;
39
+ // `includeDefaultRoots` because the plugin dirs ADD to the project/user `.agents/skills` roots —
40
+ // without it, enabling a single skill-bearing plugin would hide every local skill.
41
+ const skills = manager !== undefined
42
+ ? skillsCapability({ registry, roots: manager.skillRoots(), includeDefaultRoots: true })
43
+ : skillsCapability();
44
+ const capabilities = [
45
+ goalCapability(),
46
+ workflowCapability(),
47
+ planCapability(),
48
+ taskCapability(),
49
+ skills,
50
+ backgroundCapability(),
51
+ compactionCapability({ maxContextTokens: options.maxContextTokens ?? 200_000 }),
52
+ ];
53
+ // Cron is a local-only capability (Invariant 7): the server host doesn't install it.
54
+ // MCP: workspace servers + enabled plugin servers (namespaced, so they can't collide).
55
+ if (options.mcpServers !== undefined || manager !== undefined) {
56
+ capabilities.push(mcpServersCapability({ ...(options.mcpServers ?? {}), ...(manager?.mcpServerConfigs() ?? {}) }, options.oauthService !== undefined ? { oauthService: options.oauthService } : {}));
57
+ }
58
+ // Plugins: load the manager + render the plugin's session-start skill from the shared registry
59
+ // (skills opens before this, so by injection time the registry holds the plugin skills).
60
+ if (manager !== undefined && registry !== undefined) {
61
+ capabilities.push(pluginsCapability(manager, (_pluginId, skillName) => registry.getSkill(skillName)?.content));
62
+ }
63
+ // Shell hooks: config first, then enabled plugin hooks (single HookEngine / SessionStart injector).
64
+ const hookDefs = [
65
+ ...(options.hooks ?? []),
66
+ ...(manager !== undefined ? manager.hookDefs() : []),
67
+ ];
68
+ if (hookDefs.length > 0) {
69
+ capabilities.push(userHooksCapability(hookDefs));
70
+ }
71
+ return capabilities;
72
+ }
73
+ /**
74
+ * The builtin subagents (`coder` / `explore` / `plan`) the `Agent` and `Workflow` tools spawn by
75
+ * default. The profiles declare no model, so each subagent inherits the session's model; their tool
76
+ * names resolve against the default agent's toolset (sharing read/write/edit/grep/glob/bash).
77
+ */
78
+ function defaultSubagentProvider(agentTools, resolveModel, extraProfiles = {}) {
79
+ const tools = {};
80
+ for (const tool of agentTools)
81
+ tools[tool.schema.name] = tool;
82
+ // Deployment-supplied profiles (disk locally, external on a server) extend the builtin fleet;
83
+ // the SOURCE differs by composition root, the wiring here does not.
84
+ const profiles = { ...DEFAULT_AGENT_PROFILES, ...extraProfiles };
85
+ const only = [...new Set([...DEFAULT_SUBAGENT_NAMES, ...Object.keys(extraProfiles)])];
86
+ return profileSubagentProvider(profiles, {
87
+ only,
88
+ tools,
89
+ ...(resolveModel !== undefined ? { resolveModel } : {}),
90
+ });
91
+ }
92
+ /** A `Responder` whose approval/question handlers can be swapped at runtime (last-wins). */
93
+ class MutableResponder {
94
+ approvalHandler;
95
+ questionHandler;
96
+ /** No handler ⇒ not a live approver ⇒ the run interrupts durably (resume later via
97
+ * `HarnessSession.resume`) instead of auto-rejecting the tool. */
98
+ isLiveApprover() {
99
+ return this.approvalHandler !== undefined;
100
+ }
101
+ async requestApproval(request, options) {
102
+ if (this.approvalHandler)
103
+ return this.approvalHandler(request, options);
104
+ return { decision: "rejected", feedback: "No approval handler registered on this session." };
105
+ }
106
+ async requestQuestion(request, options) {
107
+ if (this.questionHandler)
108
+ return this.questionHandler(request, options);
109
+ return null;
110
+ }
111
+ }
112
+ /**
113
+ * Ergonomic per-session facade. Run-driving methods (`prompt`/`steer`/`followUp`/`cancel`/
114
+ * `onEvent`/approval handlers) live here; everything else delegates to the core
115
+ * `Session` (reachable via `.core` for anything not surfaced).
116
+ */
117
+ export class HarnessSession {
118
+ id;
119
+ workDir;
120
+ /** The underlying core session — escape hatch for the full control plane. */
121
+ core;
122
+ /**
123
+ * The session's event stream folded into state. The consumer split: `onEvent` serves
124
+ * events-as-events (tracing, telemetry); the projection serves state — a late-joining
125
+ * consumer calls `observeProjection()` and gets an exact history/live seam (no loss,
126
+ * no duplicates, no dedup).
127
+ * Attached before the session's first run — see docs/architecture.md §2.3.
128
+ */
129
+ projection;
130
+ agent;
131
+ runner;
132
+ events;
133
+ responder;
134
+ onClosed;
135
+ runContext;
136
+ currentRun;
137
+ /** Barrier gate (docs/architecture.md §5.5): while set, no NEW run starts on this
138
+ * session — prompt/resume park at it, promptStream starts lazily behind it. The in-flight
139
+ * run is never touched. */
140
+ runGate;
141
+ /** Resolves when the latest run settles — the "arrived at the boundary" signal a barrier
142
+ * coordinator awaits. Undefined while idle. */
143
+ runSettled;
144
+ runSettles = new Map();
145
+ lastRunInterrupted = false;
146
+ closed = false;
147
+ deliveryCounter = 0;
148
+ /** A wake is already scheduled on the microtask queue; more enqueues fold into it. */
149
+ wakeScheduled = false;
150
+ /** Per-session turn cap; the Runner's own config is the fallback. */
151
+ maxTurns;
152
+ constructor(args) {
153
+ this.core = args.core;
154
+ this.id = args.core.id;
155
+ this.projection = args.projection;
156
+ this.workDir = args.workDir;
157
+ this.agent = args.agent;
158
+ this.runner = args.runner;
159
+ this.events = args.events;
160
+ this.responder = args.responder;
161
+ this.runContext = args.context;
162
+ this.maxTurns = args.maxTurns;
163
+ this.lastRunInterrupted = args.interrupted ?? false;
164
+ this.onClosed = args.onClosed;
165
+ // Nothing drains a queue that fills while the session sits idle — the run loop's
166
+ // turn-boundary drain only exists while a run is in flight. Without this, a background
167
+ // task settling minutes after its spawning turn ended (the normal case: spawning to the
168
+ // background is precisely how a turn stops waiting) would leave its result queued until
169
+ // the user happened to prompt again.
170
+ this.core.steer.setIdleWakeListener(() => this.scheduleIdleWake());
171
+ }
172
+ /**
173
+ * Start a turn to consume queued follow-ups, once the current synchronous work is done.
174
+ *
175
+ * Deferred to a microtask for two reasons: several enqueues in one stack fold into a single
176
+ * wake, and a settle that lands in a finishing run's own stack sees `currentRun` already
177
+ * cleared. Re-checked at fire time because either can change in between — and re-checked
178
+ * again after every run, since the queue may have filled during one whose drain had passed.
179
+ */
180
+ scheduleIdleWake() {
181
+ if (this.wakeScheduled)
182
+ return;
183
+ this.wakeScheduled = true;
184
+ queueMicrotask(() => {
185
+ this.wakeScheduled = false;
186
+ if (this.closed || this.lastRunInterrupted)
187
+ return;
188
+ // Held at a barrier: stand down instead of parking a drain run at the gate (stacked
189
+ // parked drains would race on release). `release()` re-schedules this wake.
190
+ if (this.runGate !== undefined)
191
+ return;
192
+ // A run in flight drains at its own turn boundary; an interrupted one must be resumed,
193
+ // not talked over.
194
+ if (this.currentRun !== undefined)
195
+ return;
196
+ // Either channel counts. A "steering" item is an interruption of work in flight, but with
197
+ // nothing in flight there is nothing to interrupt — it is simply the next input, and the
198
+ // turn picks it up at the top of its first step (`runTurn`'s drain runs before the model
199
+ // call), just as the follow-up queue is drained before the turn starts.
200
+ if (!this.core.steer.hasItems())
201
+ return;
202
+ // No input: the queue IS the prompt for this turn.
203
+ void this.runPrompt([]).catch(() => undefined);
204
+ });
205
+ }
206
+ /** Run options every run on this session carries — currently the session's turn cap. */
207
+ get runOverrides() {
208
+ return this.maxTurns !== undefined ? { maxTurns: this.maxTurns } : {};
209
+ }
210
+ /** Current runtime-only application context supplied to Agent callbacks. */
211
+ get context() {
212
+ return this.runContext;
213
+ }
214
+ get status() {
215
+ return {
216
+ state: this.closed
217
+ ? "closed"
218
+ : this.currentRun !== undefined
219
+ ? "running"
220
+ : this.lastRunInterrupted
221
+ ? "interrupted"
222
+ : "idle",
223
+ activeTurnId: this.core.steer.activeTurn,
224
+ hasQueuedMessages: this.core.steer.hasItems(),
225
+ };
226
+ }
227
+ /** Durable approvals / tool inputs awaiting answers, including after a reopen where the
228
+ * historical `turn.paused` event itself is no longer live in Projection. */
229
+ async pendingInterruptions() {
230
+ const raw = await this.core.store?.getState(INTERRUPTION_STATE_KEY);
231
+ if (raw === null || raw === undefined)
232
+ return [];
233
+ return flattenPendingInterrupts(parseInterruptionState(raw));
234
+ }
235
+ /** Replace the runtime-only context used by subsequent prompt/resume calls. */
236
+ setContext(context) {
237
+ this.runContext = context;
238
+ }
239
+ // ── Run driving (the methods core Session does not have) ──
240
+ beginRun(controller) {
241
+ this.currentRun = controller;
242
+ this.runSettled = new Promise((resolve) => {
243
+ this.runSettles.set(controller, resolve);
244
+ });
245
+ }
246
+ endRun(controller) {
247
+ if (this.currentRun === controller)
248
+ this.currentRun = undefined;
249
+ this.runSettles.get(controller)?.();
250
+ this.runSettles.delete(controller);
251
+ }
252
+ /**
253
+ * Barrier support (docs/architecture.md §5.5): refuse NEW runs until released and
254
+ * report when this session reaches its run boundary. Installing the gate is synchronous — a
255
+ * coordinator gates every affected session FIRST, then awaits quiescence, so the set of
256
+ * running sessions only shrinks. The in-flight run (if any) is never signaled or aborted;
257
+ * `quiescent` settles when it finishes on its own. `release` is idempotent and always safe —
258
+ * a coordinator calls it in a finally so a failed barrier leaves nothing held.
259
+ */
260
+ holdAtBoundary() {
261
+ let releaseGate;
262
+ const released = new Promise((resolve) => {
263
+ releaseGate = resolve;
264
+ });
265
+ const gate = { released, release: releaseGate };
266
+ this.runGate = gate;
267
+ const quiescent = this.currentRun !== undefined ? (this.runSettled ?? Promise.resolve()) : Promise.resolve();
268
+ return {
269
+ quiescent,
270
+ release: () => {
271
+ if (this.runGate === gate)
272
+ this.runGate = undefined;
273
+ gate.release();
274
+ // Anything queued while held drains on the normal idle-wake path.
275
+ this.scheduleIdleWake();
276
+ },
277
+ };
278
+ }
279
+ /** Run a turn to completion. Serialized with other runs on this session. */
280
+ async prompt(input) {
281
+ return this.runPrompt(input);
282
+ }
283
+ async runPrompt(input, inputOrigin) {
284
+ if (this.closed)
285
+ throw new Error(`session "${this.id}" is closed`);
286
+ // Barrier gate: park (never fail) until released. A loop, not an if — a new hold can land
287
+ // between the release and this task resuming.
288
+ while (this.runGate !== undefined)
289
+ await this.runGate.released;
290
+ const controller = new AbortController();
291
+ this.beginRun(controller);
292
+ try {
293
+ const result = await this.runner.run(this.agent, input, {
294
+ session: this.core,
295
+ signal: controller.signal,
296
+ context: this.runContext,
297
+ ...(inputOrigin !== undefined ? { inputOrigin } : {}),
298
+ ...this.runOverrides,
299
+ });
300
+ this.lastRunInterrupted = result.status === "interrupted";
301
+ return result;
302
+ }
303
+ catch (error) {
304
+ // Facade contract: a guardrail tripwire resolves as a terminal RunResult (the low-level
305
+ // Runner still throws — OpenAI parity). Callers branch on `result.status`, no try/catch.
306
+ if (isGuardrailTripwireError(error))
307
+ return this.guardrailBlocked(error);
308
+ throw error;
309
+ }
310
+ finally {
311
+ this.endRun(controller);
312
+ // A follow-up can land after this run's last drain but before it settles — that enqueue
313
+ // saw `currentRun` set and stood down, so the run itself has to look once on the way out.
314
+ this.scheduleIdleWake();
315
+ }
316
+ }
317
+ /**
318
+ * Hand a message to the frame running at `address` in this session (`main` for the root agent,
319
+ * `main/<agentId>` for a subagent); `false` when nobody is there. The seam an out-of-process
320
+ * coordinator uses to address a specific subagent.
321
+ */
322
+ steerTo(address, content, origin) {
323
+ if (this.closed || this.lastRunInterrupted)
324
+ return false;
325
+ // A run in flight drains its own queues, so handing the message to the frame is enough.
326
+ if (this.currentRun !== undefined)
327
+ return this.core.steerTo(address, content, origin);
328
+ // Idle: only the root frame can be woken. A subagent whose frame has ended is not a teammate
329
+ // waiting for mail — it is a finished delegation, and only the parent that delegated it can
330
+ // decide to continue it (`Agent(resume=...)`). Its store, capabilities, permissions and
331
+ // lifetime are all the parent's; there is no independent agent here to start.
332
+ if (address !== DEFAULT_ADDRESS)
333
+ return false;
334
+ // Queue it and let the idle-wake listener start the turn. Going through the bus (rather
335
+ // than synthesizing a prompt here) is what gives the message a `steerId` on its journal
336
+ // record, same as every other producer — the enqueue→consume trail stays unbroken.
337
+ this.core.steer.steer(content, origin);
338
+ return true;
339
+ }
340
+ /**
341
+ * Deliver an externally-originated message. A running session receives it through the
342
+ * requested SteerBus channel; an idle session starts a new turn immediately.
343
+ *
344
+ * ACCEPTANCE IS DURABLE BEFORE THIS RESOLVES. The input is journaled as `inbox.received`
345
+ * and that write is awaited before a receipt exists, so the receipt means "this survives a
346
+ * crash", not merely "this reached a process". Previously the message lived only in memory
347
+ * (SteerBus, or an argument to `runPrompt`) until the run that consumed it journaled it —
348
+ * a crash in that window lost a delivery the caller had already been told was accepted.
349
+ *
350
+ * The provenance of what the model ultimately sees is still persisted on the eventual
351
+ * `message.appended` record: acceptance and processing are journaled separately because
352
+ * they can legitimately differ (capability rewrite, guardrail rejection, or a capability
353
+ * answering the prompt outright).
354
+ */
355
+ async deliver(input, options) {
356
+ if (this.closed)
357
+ throw new Error(`session "${this.id}" is closed`);
358
+ if (this.lastRunInterrupted && this.currentRun === undefined) {
359
+ throw new Error(`session "${this.id}" is interrupted; resume it before delivering new work`);
360
+ }
361
+ if (!options.source.trim())
362
+ throw new Error("delivery source must not be empty");
363
+ this.deliveryCounter += 1;
364
+ const deliveryId = `delivery_${Date.now().toString(36)}_${this.deliveryCounter.toString(36)}`;
365
+ const mode = options.mode ?? "auto";
366
+ const channel = mode === "follow_up" ? "follow_up" : "steering";
367
+ const external = {
368
+ kind: "external",
369
+ source: options.source,
370
+ deliveryId,
371
+ actor: options.actor,
372
+ metadata: options.metadata,
373
+ channel,
374
+ };
375
+ const acceptedAt = Date.now();
376
+ // Durable acceptance, before anything is dispatched. A stored session gets its receipt from
377
+ // this record (the store is publication-aware, so the append also produces the
378
+ // `delivery.accepted` event); a storeless one has nothing to be durable about and emits the
379
+ // event directly.
380
+ if (this.core.store !== undefined) {
381
+ await this.core.store.appendRecord({
382
+ type: "inbox.received",
383
+ time: acceptedAt,
384
+ address: "main",
385
+ input,
386
+ origin: external,
387
+ mode,
388
+ });
389
+ }
390
+ else {
391
+ void this.core.events.emit({
392
+ type: "delivery.accepted",
393
+ deliveryId,
394
+ source: options.source,
395
+ channel,
396
+ address: "main",
397
+ sessionId: this.id,
398
+ });
399
+ }
400
+ return this.dispatchAccepted(input, external, acceptedAt);
401
+ }
402
+ /**
403
+ * Dispatch an input whose acceptance is ALREADY journaled.
404
+ *
405
+ * A worker draining a session's inbox calls this directly: the `inbox.received` record exists,
406
+ * so going back through `deliver` would write a second one and count the delivery twice. The
407
+ * split is the whole reason acceptance and dispatch are separable — accepting is a durable
408
+ * write that must happen before a receipt exists, dispatching is what someone holding the
409
+ * session's lease does with it afterwards, possibly on another machine, possibly much later.
410
+ *
411
+ * Run state is read HERE, not at acceptance: by now a turn may have started or ended, so
412
+ * whether this steers work in flight or starts a fresh turn can only be decided now.
413
+ */
414
+ dispatchAccepted(input, origin, acceptedAt = Date.now()) {
415
+ if (this.closed)
416
+ throw new Error(`session "${this.id}" is closed`);
417
+ const deliveryId = origin.deliveryId;
418
+ const channel = origin.channel ?? "steering";
419
+ if (this.currentRun !== undefined) {
420
+ const receipt = this.core.steer.steer(input, origin);
421
+ return {
422
+ deliveryId,
423
+ sessionId: this.id,
424
+ acceptedAt,
425
+ status: "queued",
426
+ channel,
427
+ steerId: receipt.steerId,
428
+ };
429
+ }
430
+ const promptOrigin = {
431
+ kind: "external",
432
+ source: origin.source,
433
+ deliveryId,
434
+ ...(origin.actor !== undefined ? { actor: origin.actor } : {}),
435
+ ...(origin.metadata !== undefined ? { metadata: origin.metadata } : {}),
436
+ };
437
+ const message = {
438
+ role: "user",
439
+ content: [{ type: "text", text: renderSteerText(origin, input) }],
440
+ timestamp: acceptedAt,
441
+ };
442
+ const completion = this.runPrompt([message], promptOrigin);
443
+ // Callers may only need acceptance. Keep a fire-and-observe delivery from becoming an
444
+ // unhandled rejection; awaiting `completion` still receives the original rejection.
445
+ completion.catch(() => undefined);
446
+ return { deliveryId, sessionId: this.id, acceptedAt, status: "started", channel: "turn", completion };
447
+ }
448
+ /**
449
+ * Resume a durably-interrupted run. When a `prompt` returns `status:"interrupted"` — which
450
+ * happens when no live approval handler is registered (`no live responder ⇒ durable`) — the run's
451
+ * interruption control tree was persisted to the session store. This loads it, applies the
452
+ * caller's `answers` (prefer `approvalId`; a unique `toolCallId` is also accepted), and continues the
453
+ * run to its next stop. Works across processes as long as the reopened session shares the same
454
+ * durable store. Same guardrail-facade contract as `prompt` (a tripwire resolves as a terminal
455
+ * `RunResult`, not a throw).
456
+ *
457
+ * An answer is either a bare `ApprovalResponse` (shorthand for the common approval case) or a
458
+ * discriminated `InterruptAnswer` — pass `{ kind: "input", data }` to answer a durable input
459
+ * suspension (`ctx.suspend`), which the approval-only shorthand cannot express.
460
+ *
461
+ * Recovery: if a previous resume threw mid-flight, the persisted state is left at
462
+ * `phase: "recovery_required"` with its revision bumped. Calling this again handles it —
463
+ * the state is re-read from the store here, so it is never stale — and continues from the
464
+ * last durable pause. Only callers driving `Runner.resume` directly with a kept-around
465
+ * copy see a "stale interruption state" error; re-fetching the persisted state clears it.
466
+ */
467
+ async resume(answers) {
468
+ // Barrier gate first (a coordinator holding the barrier should not be resumed against —
469
+ // the call parks here until release rather than failing).
470
+ while (this.runGate !== undefined)
471
+ await this.runGate.released;
472
+ if (this.currentRun !== undefined)
473
+ throw new Error(`session "${this.id}" already has an active run`);
474
+ const controller = new AbortController();
475
+ // Set this BEFORE the first await (past the gate). A host returning 202 from resume can
476
+ // immediately observe `running`, and a concurrent delivery joins this resumed turn instead
477
+ // of seeing stale interrupted state while the control record is being read.
478
+ this.beginRun(controller);
479
+ try {
480
+ const raw = await this.core.store?.getState(INTERRUPTION_STATE_KEY);
481
+ if (raw === null || raw === undefined) {
482
+ throw new Error("No interrupted run to resume on this session (no persisted interruption state).");
483
+ }
484
+ const interruption = parseInterruptionState(raw);
485
+ // Bare ApprovalResponses (no `kind` field — the common case) are wrapped into the
486
+ // discriminated InterruptAnswer shape; already-discriminated answers (including
487
+ // `kind: "input"` for durable input suspensions) pass through untouched.
488
+ const wrappedAnswers = Object.fromEntries(Object.entries(answers).map(([id, answer]) => [
489
+ id,
490
+ isInterruptAnswer(answer) ? answer : { kind: "approval", ...answer },
491
+ ]));
492
+ const result = await this.runner.resume(this.agent, { interruption, answers: wrappedAnswers }, {
493
+ session: this.core,
494
+ signal: controller.signal,
495
+ context: this.runContext,
496
+ ...this.runOverrides,
497
+ });
498
+ this.lastRunInterrupted = result.status === "interrupted";
499
+ return result;
500
+ }
501
+ catch (error) {
502
+ if (isGuardrailTripwireError(error))
503
+ return this.guardrailBlocked(error);
504
+ throw error;
505
+ }
506
+ finally {
507
+ this.endRun(controller);
508
+ }
509
+ }
510
+ /** Streaming variant: iterate events, await `.completed` for the result. */
511
+ promptStream(input) {
512
+ const controller = new AbortController();
513
+ // Ungated: start synchronously, byte-identical to the pre-barrier behavior. Gated: the
514
+ // handle returns immediately but the underlying run starts only after release (lazy start —
515
+ // promptStream has no callers that depend on first-event timing).
516
+ const gated = this.runGate !== undefined;
517
+ if (!gated)
518
+ this.beginRun(controller);
519
+ const start = (async () => {
520
+ if (gated) {
521
+ while (this.runGate !== undefined)
522
+ await this.runGate.released;
523
+ this.beginRun(controller);
524
+ }
525
+ return this.runner.runStream(this.agent, input, {
526
+ session: this.core,
527
+ signal: controller.signal,
528
+ context: this.runContext,
529
+ ...this.runOverrides,
530
+ });
531
+ })();
532
+ // Same facade contract as `prompt`: `completed` resolves with a "guardrail_blocked" result
533
+ // instead of rejecting. Events (including the `guardrail.blocked` frame) stream through the
534
+ // original handle unchanged.
535
+ const completed = start
536
+ .then((handle) => handle.completed)
537
+ .then((result) => {
538
+ this.lastRunInterrupted = result.status === "interrupted";
539
+ return result;
540
+ })
541
+ .catch((error) => {
542
+ if (isGuardrailTripwireError(error))
543
+ return this.guardrailBlocked(error);
544
+ throw error;
545
+ })
546
+ .finally(() => {
547
+ this.endRun(controller);
548
+ });
549
+ // Guard the floating promise so iterate-only callers don't trip an unhandled rejection
550
+ // (mirrors runStream's own internal guard); awaiters still receive a real rejection.
551
+ completed.catch(() => undefined);
552
+ const iterate = async function* () {
553
+ const handle = await start;
554
+ for await (const event of handle)
555
+ yield event;
556
+ };
557
+ return {
558
+ [Symbol.asyncIterator]: () => iterate()[Symbol.asyncIterator](),
559
+ completed,
560
+ };
561
+ }
562
+ /** Synthesize the terminal RunResult for a guardrail tripwire (facade contract). */
563
+ guardrailBlocked(error) {
564
+ // Terminal, like any other completed result: without this, a tripwire during resume()
565
+ // would leave the flag stuck true (the success path at the call sites never runs) and
566
+ // wedge the session in "interrupted" with its durable state already consumed.
567
+ this.lastRunInterrupted = false;
568
+ return {
569
+ status: "guardrail_blocked",
570
+ finalAgent: error.agentName ?? this.agent.name,
571
+ activeAddress: error.address ?? this.core.conversationHead()?.address ?? "main",
572
+ output: "",
573
+ messages: [],
574
+ usage: emptyUsage(),
575
+ guardrail: {
576
+ stage: error.stage,
577
+ guardrail: error.guardrailName,
578
+ ...(error.agentName !== undefined ? { agent: error.agentName } : {}),
579
+ },
580
+ };
581
+ }
582
+ /**
583
+ * Inject a user message into the in-flight (or next) turn. Returns the steer's correlation
584
+ * id: a `steer.queued` event carries it now, and the consuming `message.appended` event's
585
+ * `origin.steerId` matches it when the model actually sees the message.
586
+ */
587
+ steer(input) {
588
+ return this.core.steer.steer(input, { kind: "user" }).steerId;
589
+ }
590
+ /**
591
+ * Queue a user message for a new turn after the current run's active turn finishes.
592
+ * Returns the steer's correlation id (see `steer`), or null when the session has no
593
+ * active run — use `prompt` instead in that case.
594
+ */
595
+ followUp(input) {
596
+ if (this.currentRun === undefined)
597
+ return null;
598
+ return this.core.steer.followUp(input).steerId;
599
+ }
600
+ /** Abort the current run. */
601
+ cancel() {
602
+ this.currentRun?.abort();
603
+ }
604
+ /** Subscribe to this session's event stream. Returns an unsubscribe fn. */
605
+ onEvent(listener) {
606
+ return this.events.subscribe(listener);
607
+ }
608
+ // ── Projection (state-shaped consumers; see the `projection` field doc) ──
609
+ /** Current folded state. Consumers that also need subsequent events should use
610
+ * `observeProjection()` for an atomic history/live seam. */
611
+ snapshot(options) {
612
+ return this.projection.snapshot(options);
613
+ }
614
+ /** Post-apply event feed: every delivered event's effect is already in `snapshot()`.
615
+ * Listeners must be synchronous — backpressure belongs to the transport. */
616
+ subscribeProjection(listener) {
617
+ return this.projection.subscribe(listener);
618
+ }
619
+ /** Atomically subscribe to post-fold AgentEvents and capture the authoritative state at
620
+ * that boundary. Prefer this over coordinating `snapshot()` and `subscribeProjection()`
621
+ * manually in transports and UIs. */
622
+ observeProjection(listener, options) {
623
+ return this.projection.observe(listener, options);
624
+ }
625
+ /** Lightweight per-address summary (which agents exist, who is live/running). */
626
+ directory() {
627
+ return this.projection.directory();
628
+ }
629
+ setApprovalHandler(handler) {
630
+ this.responder.approvalHandler = handler;
631
+ }
632
+ setQuestionHandler(handler) {
633
+ this.responder.questionHandler = handler;
634
+ }
635
+ // ── Control plane (delegated to core Session) ──
636
+ setModel(model) {
637
+ this.core.setModel(model);
638
+ }
639
+ setThinking(level) {
640
+ this.core.setThinking(level);
641
+ }
642
+ /** In-memory switch is immediate; await the returned promise to confirm it was journaled. */
643
+ setPermissionMode(mode) {
644
+ return this.core.setPermissionMode(mode);
645
+ }
646
+ createGoal(...args) {
647
+ return this.core.createGoal(...args);
648
+ }
649
+ getGoal() {
650
+ return this.core.getGoal();
651
+ }
652
+ setPlanMode(...args) {
653
+ return this.core.setPlanMode(...args);
654
+ }
655
+ getPlan() {
656
+ return this.core.getPlan();
657
+ }
658
+ compact(...args) {
659
+ return this.core.compact(...args);
660
+ }
661
+ listSkills() {
662
+ return this.core.listSkills();
663
+ }
664
+ activateSkill(...args) {
665
+ return this.core.activateSkill(...args);
666
+ }
667
+ listPlugins() {
668
+ return this.core.listPlugins();
669
+ }
670
+ installPlugin(...args) {
671
+ return this.core.installPlugin(...args);
672
+ }
673
+ listBackgroundTasks(...args) {
674
+ return this.core.listBackgroundTasks(...args);
675
+ }
676
+ // ── Conversation log (flat, linear) — read the record stream for transcript rendering ──
677
+ getRecords(...args) {
678
+ return this.core.getRecords(...args);
679
+ }
680
+ listSubagents(...args) {
681
+ return this.core.listSubagents(...args);
682
+ }
683
+ reconcileSubagents(...args) {
684
+ return this.core.reconcileSubagents(...args);
685
+ }
686
+ // ── context introspection — where the model's window is spent, as of the last turn ──
687
+ getContextBreakdown() {
688
+ return this.core.getContextBreakdown();
689
+ }
690
+ // ── extensions — hot attach/detach on the live session (host API; never a model tool) ──
691
+ /**
692
+ * Add an extension mid-session. Applied at the next quiet point: immediately when idle,
693
+ * at the run's stop boundary when one is in flight — its tools appear at the next run's
694
+ * assembly. The change is journaled; it does NOT survive `resumeSession` (re-attach, or
695
+ * register it in `createHarness({ extensions })`, which every open re-evaluates). A definition
696
+ * with a `create` half is accepted only once registered — its service must already exist.
697
+ */
698
+ attachExtension(definition) {
699
+ return this.extensionRuntime().attach(definition);
700
+ }
701
+ /** Remove an extension mid-session: `session.end("detach")` fires, then its scope unwinds. */
702
+ detachExtension(extensionId) {
703
+ return this.extensionRuntime().detach(extensionId);
704
+ }
705
+ /** Ids of currently attached extensions; empty when the session has no extensions capability. */
706
+ attachedExtensionIds() {
707
+ try {
708
+ return this.extensionRuntime().attachedIds();
709
+ }
710
+ catch {
711
+ return [];
712
+ }
713
+ }
714
+ /** Currently attached extensions with what each `uses`; empty without an extensions capability. */
715
+ attachedExtensions() {
716
+ try {
717
+ return this.extensionRuntime().attachedExtensions();
718
+ }
719
+ catch {
720
+ return [];
721
+ }
722
+ }
723
+ /** Run a slash command against this session: the static registry plus capability-contributed
724
+ * dynamic commands (extensions' `registerCommand`). Commands are the SERIALIZABLE control
725
+ * surface — what an out-of-process host (app-server RPC) uses where an in-process host
726
+ * reaches `extensionHandle()`. */
727
+ runCommand(input) {
728
+ return sharedCommands().run(input, { session: this.core });
729
+ }
730
+ /** The control surface an extension published via `api.expose`; undefined when the extension
731
+ * is absent, detached, or exposed nothing. The generic replacement for per-feature session
732
+ * facade methods (e.g. cron's — see `cronExtension`). */
733
+ extensionHandle(extensionId) {
734
+ try {
735
+ return this.extensionRuntime().exposedHandle(extensionId);
736
+ }
737
+ catch {
738
+ return undefined;
739
+ }
740
+ }
741
+ extensionRuntime() {
742
+ const runtime = this.core.service("extensions");
743
+ if (!runtime)
744
+ throw new Error("this session has no extensions capability");
745
+ return runtime;
746
+ }
747
+ // ── goal lifecycle ──
748
+ pauseGoal(...args) {
749
+ return this.core.pauseGoal(...args);
750
+ }
751
+ resumeGoal(...args) {
752
+ return this.core.resumeGoal(...args);
753
+ }
754
+ cancelGoal(...args) {
755
+ return this.core.cancelGoal(...args);
756
+ }
757
+ setGoalBudget(...args) {
758
+ return this.core.setGoalBudget(...args);
759
+ }
760
+ // ── plan + compaction ──
761
+ clearPlan(...args) {
762
+ return this.core.clearPlan(...args);
763
+ }
764
+ pendingCompaction(...args) {
765
+ return this.core.pendingCompaction(...args);
766
+ }
767
+ cancelCompaction(...args) {
768
+ return this.core.cancelCompaction(...args);
769
+ }
770
+ // ── background tasks ──
771
+ readBackgroundTaskOutput(...args) {
772
+ return this.core.readBackgroundTaskOutput(...args);
773
+ }
774
+ readBackgroundTaskOutputDelta(...args) {
775
+ return this.core.readBackgroundTaskOutputDelta(...args);
776
+ }
777
+ stopBackgroundTask(...args) {
778
+ return this.core.stopBackgroundTask(...args);
779
+ }
780
+ /** Move a running detachable tool call (bash/subagent/workflow) into a background task. */
781
+ detachTool(...args) {
782
+ return this.core.detachTool(...args);
783
+ }
784
+ // ── plugins (full lifecycle) ──
785
+ getPluginInfo(...args) {
786
+ return this.core.getPluginInfo(...args);
787
+ }
788
+ setPluginEnabled(...args) {
789
+ return this.core.setPluginEnabled(...args);
790
+ }
791
+ setPluginMcpServerEnabled(...args) {
792
+ return this.core.setPluginMcpServerEnabled(...args);
793
+ }
794
+ removePlugin(...args) {
795
+ return this.core.removePlugin(...args);
796
+ }
797
+ reloadPlugins(...args) {
798
+ return this.core.reloadPlugins(...args);
799
+ }
800
+ // ── MCP ──
801
+ listMcpServers(...args) {
802
+ return this.core.listMcpServers(...args);
803
+ }
804
+ reconnectMcpServer(...args) {
805
+ return this.core.reconnectMcpServer(...args);
806
+ }
807
+ /** Close the session and release its resources. */
808
+ async close() {
809
+ if (this.closed)
810
+ return;
811
+ this.closed = true;
812
+ this.cancel();
813
+ this.projection.detach();
814
+ await this.core.close();
815
+ this.onClosed(this.id);
816
+ }
817
+ }
818
+ /** Manages a fleet of in-process sessions over a shared engine config. */
819
+ export class Harness {
820
+ options;
821
+ runner;
822
+ repo;
823
+ /** Concrete tools the default profile's tool NAMES resolve against (keyed by schema name). */
824
+ toolPalette;
825
+ activeSessions = new Map();
826
+ /** Process-level shared services. The host replaces providers here (`services.replace`);
827
+ * sessions only ever see handles (`ctx.shared`, `ctx.services`). */
828
+ services = new ServiceRegistry();
829
+ /** The one in-flight reshape barrier (mutex: a second barrier operation rejects). Sessions
830
+ * appearing while it holds are gated at birth — see the tail of `openFromStore`. */
831
+ activeBarrier;
832
+ /** File-extension manager (both tiers) — present only when the host opted in with
833
+ * `extensionDir`. NOT the Codex-compatible skill/MCP plugin system (`pluginManager`). */
834
+ extensions;
835
+ /** Service ids published by by-value `create` halves, in creation order — unregistered (and
836
+ * disposed) by `close()`, newest first. */
837
+ createdServices = [];
838
+ /** The by-value definitions this harness currently mounts into newborn sessions, in
839
+ * registration order. Seeded from `createHarness({ extensions })` and rewritten in place by
840
+ * `replaceExtension`, so a swap reaches sessions born after it — reading `options.extensions`
841
+ * directly would hand new sessions the OLD code, which is exactly the skew the barrier exists
842
+ * to prevent. */
843
+ valueDefs = new Map();
844
+ /** Root of extensions' data folders (`host.dataDir` = `<root>/<id>`); absent ⇒ none handed out. */
845
+ dataRoot;
846
+ /** Settles once every by-value `create` half has published its service; sessions open after it. */
847
+ sharedReady;
848
+ /** The extension whose `create` is running right now — its `createSession` must refuse. */
849
+ runningCreate;
850
+ constructor(options) {
851
+ this.options = options;
852
+ const agentTools = options.tools ?? [...filesystemTools(), askUserQuestionTool];
853
+ this.toolPalette = Object.fromEntries(agentTools.map((tool) => [tool.schema.name, tool]));
854
+ // The Agent/Workflow tools only appear when the run has subagents to spawn. Default the fleet
855
+ // to the builtin coder/explore/plan profiles so orchestration works out of the box; an explicit
856
+ // provider overrides it, and `null` opts out (hiding those tools).
857
+ const subagentProvider = options.subagentProvider === null
858
+ ? undefined
859
+ : options.subagentProvider ?? defaultSubagentProvider(agentTools, options.resolveModel, options.extraSubagentProfiles);
860
+ this.runner = new Runner({
861
+ resolveModel: options.resolveModel,
862
+ subagentProvider,
863
+ ...(options.workflowTool !== undefined ? { workflowTool: options.workflowTool } : {}),
864
+ maxTurns: options.maxTurns,
865
+ maxStepsPerTurn: options.maxStepsPerTurn,
866
+ ...(options.maxConcurrentSubagents !== undefined ? { maxConcurrentSubagents: options.maxConcurrentSubagents } : {}),
867
+ });
868
+ // Injected repository (disk locally, Pg/Redis on a server); in-memory when omitted.
869
+ this.repo = options.repository ?? new MemorySessionRepository();
870
+ this.dataRoot = options.extensionDataDir ?? (options.extensionDir !== undefined ? join(options.extensionDir, ".data") : undefined);
871
+ this.extensions = options.extensionDir === undefined
872
+ ? undefined
873
+ : new HarnessExtensionManager({
874
+ directory: options.extensionDir,
875
+ dataDir: this.dataRoot ?? join(options.extensionDir, ".data"),
876
+ bridge: {
877
+ services: this.services,
878
+ createSession: (sessionOptions) => this.createSession(sessionOptions),
879
+ sessions: () => [...this.activeSessions.values()],
880
+ withBarrier: (extensionIds, timeoutMs, fn) => this.withBarrier(extensionIds, timeoutMs, fn),
881
+ warn: (message) => console.warn(`[extensions] ${message}`),
882
+ },
883
+ });
884
+ for (const [name, value] of Object.entries(options.services ?? {})) {
885
+ // A plain object carrying an `instance` key is read as a registration descriptor;
886
+ // anything else registers as the service itself (methods-only, not replaceable).
887
+ if (isServiceDescriptor(value)) {
888
+ const { instance, ...serviceOptions } = value;
889
+ this.services.register(name, instance, serviceOptions);
890
+ }
891
+ else {
892
+ this.services.register(name, value);
893
+ }
894
+ }
895
+ for (const definition of options.extensions ?? []) {
896
+ if (this.valueDefs.has(definition.id))
897
+ throw new Error(`duplicate by-value extension id "${definition.id}" in createHarness({ extensions })`);
898
+ this.valueDefs.set(definition.id, definition);
899
+ }
900
+ this.sharedReady = this.registerDefinitions(options.extensions);
901
+ // Surfaced to whoever opens a session; never an unhandled rejection on its own.
902
+ this.sharedReady.catch(() => undefined);
903
+ }
904
+ /**
905
+ * Run one by-value definition's `create` half against a staging host: the service is COLLECTED,
906
+ * not registered, so a throwing `create` publishes nothing. Stays synchronous when `create` is
907
+ * — what lets a synchronous extension's service exist the moment `createHarness` returns.
908
+ * Shared by construction and `replaceExtension`.
909
+ */
910
+ stageValue(definition) {
911
+ // The data folder exists before `create` runs (synchronously: `create` may be too).
912
+ const dataDir = this.dataRoot !== undefined ? join(this.dataRoot, definition.id) : undefined;
913
+ if (dataDir !== undefined)
914
+ mkdirSync(dataDir, { recursive: true });
915
+ this.runningCreate = definition.id;
916
+ const done = (staged) => {
917
+ this.runningCreate = undefined;
918
+ return staged;
919
+ };
920
+ const failed = (error) => {
921
+ this.runningCreate = undefined;
922
+ throw error;
923
+ };
924
+ try {
925
+ const staged = stageDefinition(definition, {
926
+ defaultReplaceable: false,
927
+ ...(dataDir !== undefined ? { dataDir } : {}),
928
+ createSession: (sessionOptions) => {
929
+ if (this.runningCreate !== undefined) {
930
+ return Promise.reject(new Error(`extension "${this.runningCreate}": createSession is not available inside create() — open sessions later, from setup, a tool, or an event`));
931
+ }
932
+ return this.createSession(sessionOptions);
933
+ },
934
+ warn: (message) => console.warn(`[extensions] ${message}`),
935
+ });
936
+ return staged instanceof Promise ? staged.then(done, failed) : done(staged);
937
+ }
938
+ catch (error) {
939
+ return failed(error);
940
+ }
941
+ }
942
+ /**
943
+ * Register the by-value definitions, in order: check each one's `uses` against the registry
944
+ * (a consumer must come after its provider), then run its `create` half if it has one and
945
+ * register the result as a service under the extension's `id`. Stays synchronous as long as
946
+ * every `create` is — so `createHarness` returns with a synchronous extension's service
947
+ * already registered; the first async `create` turns the rest of the sequence into a promise
948
+ * chain that `openFromStore` awaits. Staging (`stageDefinition`) collects before registering,
949
+ * so a throwing `create` publishes nothing.
950
+ */
951
+ registerDefinitions(extensions) {
952
+ if (extensions === undefined)
953
+ return Promise.resolve();
954
+ const run = (definition) => {
955
+ for (const name of definition.uses ?? []) {
956
+ if (!this.services.has(name)) {
957
+ throw new Error(`extension "${definition.id}" uses service "${name}", which is not registered — list its provider earlier in createHarness({ extensions }) (or register it in services)`);
958
+ }
959
+ }
960
+ if (definition.create === undefined)
961
+ return;
962
+ const staged = this.stageValue(definition);
963
+ const publish = (result) => {
964
+ if (result.service !== undefined) {
965
+ this.services.register(result.service.name, result.service.instance, result.service.options);
966
+ this.createdServices.push(result.service.name);
967
+ }
968
+ };
969
+ if (staged instanceof Promise)
970
+ return staged.then(publish);
971
+ publish(staged);
972
+ };
973
+ let chain;
974
+ for (const definition of extensions) {
975
+ if (chain === undefined) {
976
+ const result = run(definition);
977
+ if (result !== undefined)
978
+ chain = result;
979
+ }
980
+ else {
981
+ chain = chain.then(() => run(definition));
982
+ }
983
+ }
984
+ return chain ?? Promise.resolve();
985
+ }
986
+ /**
987
+ * The default agent for sessions with no explicit `agent`: the builtin `agent` profile rendered as
988
+ * an Agent — its tool names resolved against {@link toolPalette}, its model forced to `options.model`
989
+ * (the profile declares none). Capability tools (goal/plan/todo/…) still join at runtime via the
990
+ * session's capabilities.
991
+ *
992
+ * Built fresh per session. Runtime-specific prompt data is not captured here: `instructions`
993
+ * resolves the current frame's machine + cwd through the Session cache, so root/subagent/worktree
994
+ * prompts stay isolated without coupling Agent lifetime to machine lifetime.
995
+ */
996
+ buildDefaultAgent(appendSystemPrompt, maxStepsPerTurn) {
997
+ const additional = appendSystemPrompt ?? this.options.appendSystemPrompt;
998
+ const steps = maxStepsPerTurn ?? this.options.maxStepsPerTurn;
999
+ return buildAgentFromProfile(DEFAULT_AGENT_PROFILES[DEFAULT_AGENT_PROFILE_NAME], {
1000
+ tools: this.toolPalette,
1001
+ model: this.options.model,
1002
+ ...(additional !== undefined ? { roleAdditional: additional } : {}),
1003
+ ...(steps !== undefined ? { maxStepsPerTurn: steps } : {}),
1004
+ ...(this.options.resolveModel !== undefined ? { resolveModel: this.options.resolveModel } : {}),
1005
+ });
1006
+ }
1007
+ get sessions() {
1008
+ return this.activeSessions;
1009
+ }
1010
+ async createSession(opts = {}) {
1011
+ const workDir = opts.workDir ?? this.options.workDir ?? process.cwd();
1012
+ const handle = await this.repo.create({ id: opts.id, workDir, ownerKey: opts.ownerKey, title: opts.title });
1013
+ return this.openFromStore(handle.id, handle.workDir, handle.store, opts);
1014
+ }
1015
+ async resumeSession(id, opts = {}) {
1016
+ const existing = this.activeSessions.get(id);
1017
+ if (existing) {
1018
+ if (hasOwnContext(opts))
1019
+ existing.setContext(opts.context);
1020
+ return existing;
1021
+ }
1022
+ const handle = await this.repo.open(id);
1023
+ if (handle === undefined)
1024
+ throw new SessionRepositoryNotFoundError(id);
1025
+ const session = await this.openFromStore(handle.id, handle.workDir, handle.store, opts);
1026
+ // Reopening: orphaned background subagents (running from a dead process) → lost.
1027
+ await session.reconcileSubagents();
1028
+ return session;
1029
+ }
1030
+ /** Fork an existing session into a new one (copies its log + state) and open it. */
1031
+ async forkSession(sourceId, opts = {}) {
1032
+ const handle = await this.repo.fork(sourceId, {
1033
+ ...(opts.id !== undefined ? { id: opts.id } : {}),
1034
+ ...(opts.ownerKey !== undefined ? { ownerKey: opts.ownerKey } : {}),
1035
+ ...(opts.title !== undefined ? { title: opts.title } : {}),
1036
+ });
1037
+ const session = await this.openFromStore(handle.id, handle.workDir, handle.store, opts);
1038
+ // A fork copies the subagent ledger; its background "running" rows have no live task → lost.
1039
+ await session.reconcileSubagents();
1040
+ return session;
1041
+ }
1042
+ /** The currently-open session with this id, or undefined (does not reopen — use resumeSession). */
1043
+ getSession(id) {
1044
+ return this.activeSessions.get(id);
1045
+ }
1046
+ /** Read a session's durable handle without constructing a Session, opening capabilities,
1047
+ * reconciling background tasks, or subscribing Projection. Intended for read-only servers.
1048
+ * Soft-deleted sessions resolve only with `{ includeDeleted: true }` — audit/purge tooling. */
1049
+ inspectSession(id, options) {
1050
+ return this.repo.open(id, options);
1051
+ }
1052
+ /** Read one catalog row without the O(N) `listSessions()` scan. */
1053
+ getSessionSummary(id) {
1054
+ return this.repo.get(id);
1055
+ }
1056
+ /** Close one open session by id (no-op if it isn't open). */
1057
+ async closeSession(id) {
1058
+ await this.activeSessions.get(id)?.close();
1059
+ }
1060
+ /**
1061
+ * Delete a session after releasing any open in-process handle. Soft by default — the durable
1062
+ * record is retained and `resumeSession` stops resolving it. `{ purge: true }` destroys it.
1063
+ */
1064
+ async deleteSession(id, options) {
1065
+ await this.closeSession(id);
1066
+ await this.repo.delete(id, options);
1067
+ }
1068
+ /** Undo a soft delete. No-op when the session is absent or was never deleted. */
1069
+ restoreSession(id) {
1070
+ return this.repo.restore(id);
1071
+ }
1072
+ async listSessions(filter) {
1073
+ return this.repo.list(filter);
1074
+ }
1075
+ async close() {
1076
+ for (const session of [...this.activeSessions.values()])
1077
+ await session.close();
1078
+ // By-value `create` halves die with the harness that ran them: unregister each service,
1079
+ // newest first, which drains it and runs its dispose hook (default: the instance's close()).
1080
+ await this.sharedReady.catch(() => undefined);
1081
+ for (const name of [...this.createdServices].reverse())
1082
+ await this.services.unregister(name);
1083
+ this.createdServices.length = 0;
1084
+ }
1085
+ /**
1086
+ * Replace by-value extensions with new versions, as one coordinated act — the by-value twin of
1087
+ * `harness.extensions.reload()` (docs/architecture.md §5.5). Every session holding
1088
+ * one of them rendezvous at its run boundary (in-flight runs finish untouched — nothing is
1089
+ * aborted); in that global quiet moment the old halves detach, each `create` half re-runs and
1090
+ * its service is swapped, and the new halves attach; then everyone resumes. Sessions born from
1091
+ * the quiet moment on are born with the new definitions. On rendezvous timeout NOTHING changes:
1092
+ * the barrier lifts, `BarrierTimeout` names the stuck sessions, and the caller retries later
1093
+ * (or closes the straggler).
1094
+ *
1095
+ * Pass EVERY definition a shape change affects in ONE call — the provider and the consumers
1096
+ * written against it — so they cross the barrier together; a consumer left out resumes against
1097
+ * the new shape running old code, which is the skew this whole mechanism exists to prevent.
1098
+ *
1099
+ * `services` swaps HOST-registered services (`createHarness({ services })`) in the same quiet
1100
+ * moment, for when the shape that changed is the host's rather than an extension's. Those obey
1101
+ * their `replaceable` flag; an extension's own `create` service swaps unconditionally, since
1102
+ * its owner is being replaced along with it.
1103
+ *
1104
+ * Implementation-only swaps need none of this — `harness.services.replace` (layer 1) lands on
1105
+ * the next call and touches no session at all.
1106
+ */
1107
+ async replaceExtension(next, options = {}) {
1108
+ const definitions = Array.isArray(next) ? [...next] : [next];
1109
+ if (definitions.length === 0) {
1110
+ throw new Error("replaceExtension needs at least one definition — to swap a service alone, use harness.services.replace");
1111
+ }
1112
+ for (const definition of definitions) {
1113
+ if (!this.valueDefs.has(definition.id)) {
1114
+ throw new Error(`cannot replace extension "${definition.id}": it is not registered on this harness by value — pass it in createHarness({ extensions }), or use harness.extensions.reload("${definition.id}") for a file extension`);
1115
+ }
1116
+ }
1117
+ // A `create` half still publishing would race the swap.
1118
+ await this.sharedReady;
1119
+ // Every new `create` runs BEFORE the barrier: staging COLLECTS, so one that throws leaves the
1120
+ // world completely untouched (no session detached, no service swapped) — the same discipline
1121
+ // construction and the file manager use. Whatever a failed act staged is disposed below.
1122
+ const staged = new Map();
1123
+ for (const definition of definitions) {
1124
+ if (definition.create !== undefined)
1125
+ staged.set(definition.id, await this.stageValue(definition));
1126
+ }
1127
+ const published = new Set();
1128
+ const ids = definitions.map((definition) => definition.id);
1129
+ try {
1130
+ await this.withBarrier(ids, options.timeoutMs ?? DEFAULT_REPLACE_TIMEOUT_MS, async (held) => {
1131
+ // Globally quiet. These two statements are ONE synchronous act: from here on newborn
1132
+ // sessions are born with the new definitions, and `targets` is exactly the set still
1133
+ // holding an old one — so nobody is swapped twice and nobody is missed.
1134
+ const targets = held()
1135
+ .map((session) => ({ session, held: ids.filter((id) => session.attachedExtensionIds().includes(id)) }))
1136
+ .filter((target) => target.held.length > 0);
1137
+ for (const definition of definitions)
1138
+ this.valueDefs.set(definition.id, definition);
1139
+ // Old halves out first: their `session.end` runs against the old shape.
1140
+ for (const target of targets) {
1141
+ for (const id of target.held)
1142
+ await target.session.detachExtension(id);
1143
+ }
1144
+ for (const [name, instance] of Object.entries(options.services ?? {})) {
1145
+ await this.services.replace(name, instance, options.drainTimeoutMs !== undefined ? { drainTimeoutMs: options.drainTimeoutMs } : {});
1146
+ }
1147
+ for (const definition of definitions) {
1148
+ await this.publishValueService(definition, staged.get(definition.id), options.drainTimeoutMs);
1149
+ published.add(definition.id);
1150
+ }
1151
+ // New halves in: their `session.start` runs against the new shape. The pending queue
1152
+ // applies immediately on idle sessions.
1153
+ for (const target of targets) {
1154
+ if (!this.activeSessions.has(target.session.id))
1155
+ continue; // closed while the barrier held
1156
+ for (const id of target.held)
1157
+ await target.session.attachExtension(this.valueDefs.get(id));
1158
+ }
1159
+ });
1160
+ }
1161
+ catch (error) {
1162
+ // Rendezvous timed out, or a step failed: nothing staged was published, so dispose it —
1163
+ // a `create` that opened a pool must not leak it because the barrier never converged.
1164
+ for (const [id, entry] of staged) {
1165
+ if (!published.has(id) && entry.service !== undefined)
1166
+ await this.disposeStaged(id, entry.service.instance);
1167
+ }
1168
+ throw error;
1169
+ }
1170
+ }
1171
+ /**
1172
+ * The service half of one replaced extension: publish what its `create` staged. `replace`
1173
+ * rather than unregister + register, so consumers this barrier never gated — other extensions
1174
+ * holding a `uses` handle — see no gap; `force` because the owner is being replaced along with
1175
+ * it (see `ServiceRegistry.replace`).
1176
+ */
1177
+ async publishValueService(definition, staged, drainTimeoutMs) {
1178
+ const id = definition.id;
1179
+ const previous = this.createdServices.indexOf(id);
1180
+ const drain = drainTimeoutMs !== undefined ? { drainTimeoutMs } : {};
1181
+ if (staged?.service !== undefined) {
1182
+ if (previous >= 0) {
1183
+ await this.services.replace(id, staged.service.instance, { force: true, ...drain });
1184
+ }
1185
+ else {
1186
+ this.services.register(id, staged.service.instance, staged.service.options);
1187
+ this.createdServices.push(id);
1188
+ }
1189
+ return;
1190
+ }
1191
+ // The new version dropped its `create` half: its service goes away with it.
1192
+ if (previous >= 0) {
1193
+ await this.services.unregister(id, drain);
1194
+ this.createdServices.splice(previous, 1);
1195
+ }
1196
+ }
1197
+ /** Dispose a `create` result that was staged but never published (default rule: its `close()`). */
1198
+ async disposeStaged(id, instance) {
1199
+ try {
1200
+ const close = instance?.close;
1201
+ if (typeof close === "function")
1202
+ await close.call(instance);
1203
+ }
1204
+ catch (error) {
1205
+ console.warn(`[extensions] extension "${id}": disposing the unpublished create result failed: ${error instanceof Error ? error.message : String(error)}`);
1206
+ }
1207
+ }
1208
+ /**
1209
+ * The barrier itself (docs/architecture.md §5.5), shared by `replaceExtension` and
1210
+ * the plugin manager: gate every session holding one of `extensionIds` (synchronously, FIRST
1211
+ * — the set of running sessions only shrinks from there), rendezvous bounded by `timeoutMs`
1212
+ * (a straggler throws BarrierTimeout and nothing ran), then run `fn` in the globally quiet
1213
+ * moment. `held()` reads live, so sessions born gated during the barrier are included.
1214
+ * Whatever happens, every gate is released.
1215
+ */
1216
+ async withBarrier(extensionIds, timeoutMs, fn) {
1217
+ if (this.activeBarrier !== undefined)
1218
+ throw new Error("another barrier operation is already in progress");
1219
+ const holds = new Map();
1220
+ this.activeBarrier = { holds };
1221
+ try {
1222
+ for (const session of this.activeSessions.values()) {
1223
+ const ids = session.attachedExtensionIds();
1224
+ if (extensionIds.some((id) => ids.includes(id)))
1225
+ holds.set(session.id, session.holdAtBoundary());
1226
+ }
1227
+ const stuck = await rendezvous(holds, timeoutMs);
1228
+ if (stuck.length > 0)
1229
+ throw new BarrierTimeout(stuck);
1230
+ const held = () => [...holds.keys()]
1231
+ .map((sessionId) => this.activeSessions.get(sessionId))
1232
+ .filter((session) => session !== undefined);
1233
+ await fn(held);
1234
+ }
1235
+ finally {
1236
+ // Whatever happened, nothing stays held — a failed barrier leaves the world running.
1237
+ for (const hold of holds.values())
1238
+ hold.release();
1239
+ this.activeBarrier = undefined;
1240
+ }
1241
+ }
1242
+ /** Resolve the capability set for one session — calling the factory fresh when one was given. */
1243
+ async resolveCapabilities(ctx, params = {}) {
1244
+ const configured = this.options.capabilities;
1245
+ const base = typeof configured === "function"
1246
+ ? await configured(ctx)
1247
+ : configured ?? defaultCapabilities({ maxContextTokens: this.options.maxContextTokens });
1248
+ // Every definition registered on this harness: by value (`extensions`, create halves
1249
+ // included — their per-session `setup` is mounted here like any other), then from files.
1250
+ const extensions = [...this.valueDefs.values(), ...(this.extensions?.sessionDefinitions() ?? [])];
1251
+ // Put extension transforms first. Shell PreToolUse remains in the staged permission policy,
1252
+ // so it evaluates the final rewritten args before normal authorization.
1253
+ // Installed even when empty: the runtime must exist for `attachExtension` to have
1254
+ // somewhere to land on sessions born without extensions.
1255
+ return [extensionsCapability(extensions, { host: this.extensionHost(ctx.sessionId), params }), ...base];
1256
+ }
1257
+ /**
1258
+ * The harness-tier reach handed to a session's extensions: other sessions, and the model
1259
+ * provider registry. Bound to `sessionId` so `fork()` forks THAT session, not whichever one
1260
+ * happens to be active.
1261
+ *
1262
+ * `isIdle`/`waitForIdle` resolve the HarnessSession lazily — at capability-build time the
1263
+ * session object does not exist yet (this runs while it is being constructed).
1264
+ */
1265
+ extensionHost(sessionId) {
1266
+ const self = this;
1267
+ const runtime = this.options.modelRuntime;
1268
+ return {
1269
+ sessionId,
1270
+ newSession: (options) => self.createSession({ ...(options?.title !== undefined ? { title: options.title } : {}) }),
1271
+ fork: (options) => self.forkSession(sessionId, { ...(options?.title !== undefined ? { title: options.title } : {}) }),
1272
+ openSession: (id) => self.resumeSession(id),
1273
+ listSessions: () => self.listSessions(),
1274
+ registerProvider: (provider) => {
1275
+ if (!runtime)
1276
+ throw new Error("registerProvider() requires the harness to be built with a `modelRuntime`");
1277
+ runtime.models.setProvider(provider);
1278
+ },
1279
+ unregisterProvider: (id) => {
1280
+ if (!runtime)
1281
+ throw new Error("unregisterProvider() requires the harness to be built with a `modelRuntime`");
1282
+ runtime.models.deleteProvider(id);
1283
+ },
1284
+ services: self.services,
1285
+ isIdle: () => self.activeSessions.get(sessionId)?.status.state !== "running",
1286
+ waitForIdle: async () => {
1287
+ const session = self.activeSessions.get(sessionId);
1288
+ // Taking and immediately releasing the run lock IS the wait: it can only be acquired
1289
+ // once the in-flight run has finished.
1290
+ if (session)
1291
+ await session.core.withRunLock(async () => undefined);
1292
+ },
1293
+ };
1294
+ }
1295
+ async openFromStore(id, workDir, store, opts) {
1296
+ // A session's `setup` reaches a create half through its service, so none opens before the
1297
+ // create halves have registered them.
1298
+ await this.sharedReady;
1299
+ // Per-session extension params: given at create, persisted, and read back on every later
1300
+ // open (resume, fork) so a session keeps them without the caller repeating them.
1301
+ let params = opts.params;
1302
+ if (params !== undefined)
1303
+ await store.putState(EXTENSION_PARAMS_STATE_KEY, params);
1304
+ else
1305
+ params = (await store.getState(EXTENSION_PARAMS_STATE_KEY)) ?? undefined;
1306
+ const events = new ListenerSink();
1307
+ const responder = new MutableResponder();
1308
+ // ONE open-time log read, shared: the projection seeds from it, and Session.open
1309
+ // receives it as `preloadedLog` so its capability restore + context pre-build fold the
1310
+ // same records — same IO, and the same `Message` objects (no second parsed copy).
1311
+ // Attach still happens BEFORE Session.open: no events flow yet, so the seed cannot
1312
+ // race the subscription, and anything capabilities emit during open is already folded.
1313
+ let preloadedLog = await readLog(store);
1314
+ const projection = await SessionProjection.attach({ id, store, events }, preloadedLog);
1315
+ const interrupted = await store.getState(INTERRUPTION_STATE_KEY) !== null;
1316
+ if (!interrupted) {
1317
+ const recoveryRecords = await closeOrphanedProjectionFrames(id, store, projection);
1318
+ if (recoveryRecords.length > 0)
1319
+ preloadedLog = [...preloadedLog, ...recoveryRecords];
1320
+ }
1321
+ const core = await Session.open({
1322
+ sessionId: id,
1323
+ store,
1324
+ preloadedLog,
1325
+ events,
1326
+ responder,
1327
+ machine: opts.machine ?? this.options.machine ?? new LocalMachine(workDir),
1328
+ capabilities: await this.resolveCapabilities({
1329
+ sessionId: id,
1330
+ workDir,
1331
+ ...(opts.mcpServers !== undefined ? { mcpServers: opts.mcpServers } : {}),
1332
+ }, params),
1333
+ permission: opts.permission ?? this.options.permission ?? { mode: "yolo" },
1334
+ eventPublication: opts.eventPublication ?? this.options.eventPublication ?? "immediate",
1335
+ // undefined → core resolves the `AGENTS_LOG` env fallback in Session.open.
1336
+ logger: this.options.logger,
1337
+ });
1338
+ const agent = opts.agent ??
1339
+ this.options.agent ??
1340
+ (await this.buildDefaultAgent(opts.appendSystemPrompt, opts.maxStepsPerTurn));
1341
+ const session = new HarnessSession({
1342
+ core,
1343
+ // The two caps land in different places: maxStepsPerTurn is an Agent property
1344
+ // (the Runner reads the agent's value first), maxTurns is a per-run option.
1345
+ agent,
1346
+ runner: this.runner,
1347
+ events,
1348
+ responder,
1349
+ projection,
1350
+ workDir,
1351
+ context: this.contextFor(opts),
1352
+ ...(opts.maxTurns ?? this.options.maxTurns) !== undefined
1353
+ ? { maxTurns: opts.maxTurns ?? this.options.maxTurns }
1354
+ : {},
1355
+ interrupted,
1356
+ onClosed: (sid) => this.activeSessions.delete(sid),
1357
+ });
1358
+ this.activeSessions.set(id, session);
1359
+ // Born gated: a session appearing while a reshape barrier holds (createSession,
1360
+ // resumeSession and openSession all land here) must not start runs mid-swap. Its
1361
+ // quiescent is trivially settled — it has no run — so it never delays the rendezvous.
1362
+ if (this.activeBarrier !== undefined)
1363
+ this.activeBarrier.holds.set(id, session.holdAtBoundary());
1364
+ return session;
1365
+ }
1366
+ contextFor(options) {
1367
+ return hasOwnContext(options) ? options.context : this.options.context;
1368
+ }
1369
+ }
1370
+ /**
1371
+ * A cold open proves that no in-process run still owns lifecycle frames left open in the
1372
+ * previous journal. Close those frames durably so historical event consumers and Projection
1373
+ * agree with HarnessSession.status. A durable interruption is excluded by the caller: its
1374
+ * open turn is intentional continuation state, not a crash remnant.
1375
+ */
1376
+ /**
1377
+ * Close the turns a dead process left open, without opening the session.
1378
+ *
1379
+ * A worker that dies mid-turn journals no `turn.ended`; to every reader the turn is still
1380
+ * running. Opening the session repairs that (the same recovery runs inside `resumeSession`),
1381
+ * but opening boots capabilities and a machine just to write two records. This does only the
1382
+ * repair: fold the log, and for every frame still inside a turn append `turn.ended` with
1383
+ * `reason: "failed"` (and `agent.ended` for a live sub-agent). Returns the events those records
1384
+ * project to, so a host that has live subscribers can hand them on — a record appended here
1385
+ * reaches the log, not any broadcast channel.
1386
+ *
1387
+ * A session paused for an answer is left alone: its open turn is waiting, not dead.
1388
+ */
1389
+ export async function closeOrphanedTurns(sessionId, store) {
1390
+ if ((await store.getState(INTERRUPTION_STATE_KEY)) !== null)
1391
+ return [];
1392
+ const projection = await SessionProjection.attach({ id: sessionId, store, events: new ListenerSink() }, await readLog(store));
1393
+ const records = await closeOrphanedProjectionFrames(sessionId, store, projection);
1394
+ const events = [];
1395
+ for (const record of records) {
1396
+ const event = agentEventFromRecord(record, sessionId);
1397
+ if (event !== undefined)
1398
+ events.push(event);
1399
+ }
1400
+ return events;
1401
+ }
1402
+ async function closeOrphanedProjectionFrames(sessionId, store, projection) {
1403
+ const recovered = [];
1404
+ const snapshot = projection.snapshot({ maxMessages: 0 });
1405
+ for (const agent of snapshot.agents) {
1406
+ if (agent.turn !== undefined) {
1407
+ await appendRecoveryLifecycle({
1408
+ type: "event.lifecycle",
1409
+ eventId: newAgentEventId(),
1410
+ time: Date.now(),
1411
+ address: agent.address,
1412
+ event: { type: "turn.ended", turnId: agent.turn.turnId, reason: "failed" },
1413
+ });
1414
+ }
1415
+ if (agent.live && agent.agent !== undefined) {
1416
+ await appendRecoveryLifecycle({
1417
+ type: "event.lifecycle",
1418
+ eventId: newAgentEventId(),
1419
+ time: Date.now(),
1420
+ address: agent.address,
1421
+ event: { type: "agent.ended", agent: agent.agent },
1422
+ });
1423
+ }
1424
+ }
1425
+ return recovered;
1426
+ async function appendRecoveryLifecycle(record) {
1427
+ await store.appendRecord(record);
1428
+ recovered.push(record);
1429
+ const event = agentEventFromRecord(record, sessionId);
1430
+ if (event !== undefined)
1431
+ projection.apply(event, record.time ?? Date.now());
1432
+ }
1433
+ }
1434
+ /** A reshape rendezvous that did not converge in time. Nothing was changed; the sessions that
1435
+ * never reached their run boundary are named so the caller can act on them and retry. */
1436
+ export class BarrierTimeout extends Error {
1437
+ stuckSessionIds;
1438
+ constructor(stuckSessionIds) {
1439
+ super(`reshape barrier timed out; still running: ${stuckSessionIds.join(", ")}`);
1440
+ this.name = "BarrierTimeout";
1441
+ this.stuckSessionIds = stuckSessionIds;
1442
+ }
1443
+ }
1444
+ /** Await every hold's quiescent within the budget; report who is still running on expiry. */
1445
+ async function rendezvous(holds, timeoutMs) {
1446
+ if (holds.size === 0)
1447
+ return [];
1448
+ const remaining = new Set(holds.keys());
1449
+ for (const [id, hold] of holds)
1450
+ void hold.quiescent.then(() => remaining.delete(id));
1451
+ let timer;
1452
+ const timedOut = await Promise.race([
1453
+ Promise.all([...holds.values()].map((hold) => hold.quiescent)).then(() => false),
1454
+ new Promise((resolve) => {
1455
+ timer = setTimeout(() => resolve(true), timeoutMs);
1456
+ }),
1457
+ ]);
1458
+ clearTimeout(timer);
1459
+ return timedOut ? [...remaining] : [];
1460
+ }
1461
+ let commandRegistry;
1462
+ function sharedCommands() {
1463
+ commandRegistry ??= createExtensionCommandRegistry();
1464
+ return commandRegistry;
1465
+ }
1466
+ function isServiceDescriptor(value) {
1467
+ return (typeof value === "object" &&
1468
+ value !== null &&
1469
+ Object.getPrototypeOf(value) === Object.prototype &&
1470
+ "instance" in value);
1471
+ }
1472
+ export function createHarness(options) {
1473
+ return new Harness(options);
1474
+ }
1475
+ function hasOwnContext(value) {
1476
+ return Object.prototype.hasOwnProperty.call(value, "context");
1477
+ }
1478
+ /** An already-discriminated resume answer. ApprovalResponse has no `kind` field, so the
1479
+ * presence of a valid discriminant is unambiguous. */
1480
+ function isInterruptAnswer(answer) {
1481
+ return "kind" in answer && (answer.kind === "approval" || answer.kind === "input");
1482
+ }
1483
+ //# sourceMappingURL=harness.js.map