operon-agents 0.1.0-alpha.2 → 0.1.0-alpha.21

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.
package/dist/harness.js CHANGED
@@ -11,11 +11,19 @@
11
11
  * low-level "assemble it yourself / stateless server" path, import from
12
12
  * `operon-agents/core` instead.
13
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";
14
+ import { readSessionTails, SessionProjection, ListenerSink, agentEventFromRecord, newAgentEventId, Agent, buildAgentFromProfile, INTERRUPTION_STATE_KEY, flattenPendingInterrupts, parseInterruptionState, Runner, Session, emptyUsage, isGuardrailTripwireError, askUserQuestionTool, backgroundCapability, compactionCapability, filesystemTools, goalCapability, LocalEnvironment, McpOAuthService, MemorySessionRepository, SessionRepositoryNotFoundError, planCapability, skillsCapability, SkillRegistry, mcpServersCapability, pluginsCapability, userHooksCapability, taskCapability, workflowCapability, DEFAULT_AGENT_PROFILES, DEFAULT_AGENT_PROFILE_NAME, DEFAULT_SUBAGENT_NAMES, profileSubagentProvider, DEFAULT_ADDRESS, envLogger, noopLogger, } from "operon-agents-core";
15
15
  import { mkdirSync } from "node:fs";
16
+ import { workspaceMcpCapability } from "./workspace-mcp.js";
16
17
  import { join } from "node:path";
17
- import { extensionsCapability, ExtensionRuntime, HarnessExtensionManager, ServiceRegistry, stageDefinition } from "./extensions/index.js";
18
+ import { extensionCapability, extensionRuntimeOf, extensionsCapability, HarnessExtensionManager, ServiceRegistry, stageDefinition } from "./extensions/index.js";
18
19
  import { createExtensionCommandRegistry } from "operon-agents-core";
20
+ /** How long `HarnessSession.close()` waits for aborted runs to settle before continuing teardown. */
21
+ let CLOSE_RUN_WAIT_MS = 5_000;
22
+ /** Test-only (exported via `operon-agents/internal`): shrink the close deadlines. */
23
+ export function setHarnessCloseTimeoutsForTest(ms) {
24
+ if (ms.runSettle !== undefined)
25
+ CLOSE_RUN_WAIT_MS = ms.runSettle;
26
+ }
19
27
  /** Rendezvous budget for `replaceExtension` when the caller names none. */
20
28
  const DEFAULT_REPLACE_TIMEOUT_MS = 30_000;
21
29
  /** Session-store key holding the per-session extension params map (see `createSession({ params })`). */
@@ -26,21 +34,34 @@ const EXTENSION_PARAMS_STATE_KEY = "extensions:params";
26
34
  * on the agent (`filesystemTools()`), not a capability; so is AskUserQuestion
27
35
  * (`askUserQuestionTool`, wired through ToolRunContext.responder).
28
36
  *
29
- * With `mcpServers` and/or `pluginManager`, an MCP capability is added; with `pluginManager`, the
37
+ * With `mcpServers`, `sessionMcpServers` and/or `pluginManager`, an MCP capability is added; with `pluginManager`, the
30
38
  * plugin's skills/MCP/session-start are wired in too. Intended to be called fresh per session (via
31
39
  * a harness capability factory) so per-session state (goal/plan/todo/…) and the plugin set are
32
40
  * isolated per session, mirroring a fresh-Session-per-create model.
33
41
  */
34
42
  export function defaultCapabilities(options = {}) {
35
- const manager = options.pluginManager;
43
+ const manager = options.pluginManager ?? options.shared?.pluginManager;
44
+ // A session on its own environment ignores the process-wide registry: it was scanned elsewhere.
45
+ const strict = options.strictIsolation === true;
46
+ // A session on its own environment, or one tenant among many, gets nothing the process shares.
47
+ const isolated = strict || options.ownEnvironment === true;
48
+ const sharedSkills = isolated ? undefined : options.shared?.skillRegistry;
49
+ const sharedServers = isolated ? undefined : options.shared?.mcpServers;
50
+ const oauthService = options.oauthService ?? (strict ? undefined : options.shared?.oauthService);
51
+ const allowedMcpTransports = strict
52
+ ? (options.allowedMcpTransports ?? ["http", "stdio"]).filter((kind) => kind === "http")
53
+ : options.allowedMcpTransports;
36
54
  // 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;
55
+ // loaded (its roots include the plugin skill dirs below). A process-wide registry is scanned
56
+ // once for every session; a session-level one is scanned here.
57
+ const registry = sharedSkills ?? (manager !== undefined ? new SkillRegistry() : undefined);
39
58
  // `includeDefaultRoots` because the plugin dirs ADD to the project/user `.agents/skills` roots —
40
59
  // 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();
60
+ const skills = sharedSkills !== undefined
61
+ ? skillsCapability({ registry: sharedSkills, scan: false })
62
+ : manager !== undefined
63
+ ? skillsCapability({ registry, roots: manager.skillRoots(), includeDefaultRoots: true })
64
+ : skillsCapability();
44
65
  const capabilities = [
45
66
  goalCapability(),
46
67
  workflowCapability(),
@@ -51,9 +72,40 @@ export function defaultCapabilities(options = {}) {
51
72
  compactionCapability({ maxContextTokens: options.maxContextTokens ?? 200_000 }),
52
73
  ];
53
74
  // 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 } : {}));
75
+ const sessionMcp = options.sessionMcpServers ?? {};
76
+ const mcpOptions = {
77
+ ...(oauthService !== undefined ? { oauthService } : {}),
78
+ ...(allowedMcpTransports !== undefined ? { allowedTransports: allowedMcpTransports } : {}),
79
+ ...(options.mcpCredentials !== undefined ? { credentials: options.mcpCredentials } : {}),
80
+ };
81
+ // Everything this session would connect that is not private to it: the directory's servers
82
+ // last, so they win over a same-named plugin server the way they did as a session overlay.
83
+ const shareable = {
84
+ ...(options.mcpServers ?? {}),
85
+ ...(manager?.mcpServerConfigs() ?? {}),
86
+ ...(options.workspaceMcp?.servers ?? {}),
87
+ };
88
+ if (strict) {
89
+ // The process environment has no tenant boundary: a token read from it is every tenant's.
90
+ for (const [name, config] of Object.entries({ ...shareable, ...sessionMcp })) {
91
+ if (config.bearerTokenEnvVar !== undefined) {
92
+ throw new Error(`MCP server "${name}" reads its token from the process environment (bearerTokenEnvVar), which every tenant on this host shares; hand it out per session with mcpCredentials instead.`);
93
+ }
94
+ }
95
+ }
96
+ const pooled = sharedServers === undefined && !isolated ? options.workspaceMcp : undefined;
97
+ if (pooled !== undefined && Object.keys(shareable).length > 0) {
98
+ // Borrowed per directory: the capability views the pool's set, `sessionMcp` is its overlay.
99
+ capabilities.push(workspaceMcpCapability({ pool: pooled.pool, workDir: pooled.workDir, servers: shareable, overlay: sessionMcp, overlayOptions: mcpOptions }));
100
+ }
101
+ else {
102
+ // With process-shared connections, the capability is a VIEW over them and `configs` is this
103
+ // session's overlay. Without, everything this session sees is its own — session servers last,
104
+ // so the same name still resolves to the session's.
105
+ const mcpConfigs = sharedServers !== undefined ? sessionMcp : { ...shareable, ...sessionMcp };
106
+ if (sharedServers !== undefined || Object.keys(mcpConfigs).length > 0) {
107
+ capabilities.push(mcpServersCapability(mcpConfigs, { ...mcpOptions, ...(sharedServers !== undefined ? { sharedServers } : {}) }));
108
+ }
57
109
  }
58
110
  // Plugins: load the manager + render the plugin's session-start skill from the shared registry
59
111
  // (skills opens before this, so by injection time the registry holds the plugin skills).
@@ -98,6 +150,11 @@ class MutableResponder {
98
150
  isLiveApprover() {
99
151
  return this.approvalHandler !== undefined;
100
152
  }
153
+ /** Same rule for questions: no handler ⇒ nobody attached ⇒ AskUserQuestion suspends durably
154
+ * (the question is on the paused run's pending list) instead of being dismissed unanswered. */
155
+ isLiveQuestioner() {
156
+ return this.questionHandler !== undefined;
157
+ }
101
158
  async requestApproval(request, options) {
102
159
  if (this.approvalHandler)
103
160
  return this.approvalHandler(request, options);
@@ -133,22 +190,36 @@ export class HarnessSession {
133
190
  responder;
134
191
  onClosed;
135
192
  runContext;
136
- currentRun;
137
193
  /** Barrier gate (docs/architecture.md §5.5): while set, no NEW run starts on this
138
194
  * session — prompt/resume park at it, promptStream starts lazily behind it. The in-flight
139
195
  * run is never touched. */
140
196
  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();
197
+ /**
198
+ * Every run this facade has begun and not yet seen settle, by its controller. More than one
199
+ * entry means runs are queued behind the core's run lock (`prompt` while a turn is in flight):
200
+ * "busy" is this set being non-empty, `cancel()` aborts ALL of them, and the barrier's
201
+ * quiescence is ALL of them settling — tracking only the last-begun run would abort the
202
+ * queued one and leave the running one untouched.
203
+ */
204
+ runs = new Map();
145
205
  lastRunInterrupted = false;
146
- closed = false;
147
- deliveryCounter = 0;
206
+ /** open → closing (runs being drained, resources going down) → closed. Never goes back. */
207
+ lifecycle = "open";
208
+ /** The one close in flight (or finished): every `close()` call returns THIS promise. */
209
+ closing;
210
+ /** Resolves the moment close() begins — what wakes a caller parked at the barrier gate so it
211
+ * can fail with the closed error instead of hanging on a gate nobody will release. */
212
+ notifyClosing;
213
+ closingNotified = new Promise((resolve) => {
214
+ this.notifyClosing = resolve;
215
+ });
148
216
  /** A wake is already scheduled on the microtask queue; more enqueues fold into it. */
149
217
  wakeScheduled = false;
150
218
  /** Per-session turn cap; the Runner's own config is the fallback. */
151
219
  maxTurns;
220
+ /** Builds the capability an extension attached to THIS session runs as — bound to the
221
+ * session's harness reach and its per-session params. */
222
+ mountExtension;
152
223
  constructor(args) {
153
224
  this.core = args.core;
154
225
  this.id = args.core.id;
@@ -162,6 +233,7 @@ export class HarnessSession {
162
233
  this.maxTurns = args.maxTurns;
163
234
  this.lastRunInterrupted = args.interrupted ?? false;
164
235
  this.onClosed = args.onClosed;
236
+ this.mountExtension = args.mountExtension;
165
237
  // Nothing drains a queue that fills while the session sits idle — the run loop's
166
238
  // turn-boundary drain only exists while a run is in flight. Without this, a background
167
239
  // task settling minutes after its spawning turn ended (the normal case: spawning to the
@@ -173,7 +245,7 @@ export class HarnessSession {
173
245
  * Start a turn to consume queued follow-ups, once the current synchronous work is done.
174
246
  *
175
247
  * 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
248
+ * wake, and a settle that lands in a finishing run's own stack sees its run already
177
249
  * cleared. Re-checked at fire time because either can change in between — and re-checked
178
250
  * again after every run, since the queue may have filled during one whose drain had passed.
179
251
  */
@@ -191,7 +263,7 @@ export class HarnessSession {
191
263
  return;
192
264
  // A run in flight drains at its own turn boundary; an interrupted one must be resumed,
193
265
  // not talked over.
194
- if (this.currentRun !== undefined)
266
+ if (this.hasActiveRuns())
195
267
  return;
196
268
  // Either channel counts. A "steering" item is an interruption of work in flight, but with
197
269
  // nothing in flight there is nothing to interrupt — it is simply the next input, and the
@@ -203,6 +275,22 @@ export class HarnessSession {
203
275
  void this.runPrompt([]).catch(() => undefined);
204
276
  });
205
277
  }
278
+ /** Not open: closing or closed. Every run entry point refuses from here on. */
279
+ get closed() {
280
+ return this.lifecycle !== "open";
281
+ }
282
+ /**
283
+ * Park at the barrier gate until released — or until this session starts closing, in which
284
+ * case fail with the closed error rather than wait on a gate no coordinator will release.
285
+ * Re-checked in a loop: a new hold can land between a release and this task resuming.
286
+ */
287
+ async parkAtGate() {
288
+ while (this.runGate !== undefined) {
289
+ await Promise.race([this.runGate.released, this.closingNotified]);
290
+ if (this.closed)
291
+ throw new Error(`session "${this.id}" is closed`);
292
+ }
293
+ }
206
294
  /** Run options every run on this session carries — currently the session's turn cap. */
207
295
  get runOverrides() {
208
296
  return this.maxTurns !== undefined ? { maxTurns: this.maxTurns } : {};
@@ -215,7 +303,7 @@ export class HarnessSession {
215
303
  return {
216
304
  state: this.closed
217
305
  ? "closed"
218
- : this.currentRun !== undefined
306
+ : this.hasActiveRuns()
219
307
  ? "running"
220
308
  : this.lastRunInterrupted
221
309
  ? "interrupted"
@@ -238,16 +326,26 @@ export class HarnessSession {
238
326
  }
239
327
  // ── Run driving (the methods core Session does not have) ──
240
328
  beginRun(controller) {
241
- this.currentRun = controller;
242
- this.runSettled = new Promise((resolve) => {
243
- this.runSettles.set(controller, resolve);
329
+ let resolve;
330
+ const settled = new Promise((r) => {
331
+ resolve = r;
244
332
  });
333
+ this.runs.set(controller, { settled, resolve });
245
334
  }
246
335
  endRun(controller) {
247
- if (this.currentRun === controller)
248
- this.currentRun = undefined;
249
- this.runSettles.get(controller)?.();
250
- this.runSettles.delete(controller);
336
+ this.runs.get(controller)?.resolve();
337
+ this.runs.delete(controller);
338
+ // Input can arrive after the last drain while this run still counts as active. Every
339
+ // entry point must recheck on exit; the wake itself respects pauses, gates and other runs.
340
+ this.scheduleIdleWake();
341
+ }
342
+ /** A run is in flight or queued behind the core's run lock. */
343
+ hasActiveRuns() {
344
+ return this.runs.size > 0;
345
+ }
346
+ /** Settles when every run begun so far has settled (trivially, when idle). */
347
+ allRunsSettled() {
348
+ return Promise.all([...this.runs.values()].map((run) => run.settled)).then(() => undefined);
251
349
  }
252
350
  /**
253
351
  * Barrier support (docs/architecture.md §5.5): refuse NEW runs until released and
@@ -264,7 +362,7 @@ export class HarnessSession {
264
362
  });
265
363
  const gate = { released, release: releaseGate };
266
364
  this.runGate = gate;
267
- const quiescent = this.currentRun !== undefined ? (this.runSettled ?? Promise.resolve()) : Promise.resolve();
365
+ const quiescent = this.allRunsSettled();
268
366
  return {
269
367
  quiescent,
270
368
  release: () => {
@@ -283,10 +381,11 @@ export class HarnessSession {
283
381
  async runPrompt(input, inputOrigin) {
284
382
  if (this.closed)
285
383
  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;
384
+ // Barrier gate: park until released (a hold is "wait", not "fail") — unless the session
385
+ // closes meanwhile, which is the one thing that turns the wait into a failure. Ungated, the
386
+ // run is registered SYNCHRONOUSLY: a cancel() or close() on the next line must find it.
387
+ if (this.runGate !== undefined)
388
+ await this.parkAtGate();
290
389
  const controller = new AbortController();
291
390
  this.beginRun(controller);
292
391
  try {
@@ -309,141 +408,61 @@ export class HarnessSession {
309
408
  }
310
409
  finally {
311
410
  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
411
  }
316
412
  }
317
413
  /**
318
414
  * 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.
415
+ * `main/<agentId>` for a subagent); undefined when nobody is there, or the session is paused
416
+ * on a question. The seam an out-of-process coordinator uses to address a specific subagent.
417
+ *
418
+ * The receipt's `journaled` settles once the `steer.queued` record is on disk — the moment a
419
+ * caller may answer "accepted" to someone else and mean it survives a crash. A running frame
420
+ * drains the message at its next boundary; an idle root frame is woken to take it.
321
421
  */
322
- steerTo(address, content, origin) {
323
- if (this.closed || this.lastRunInterrupted)
324
- return false;
422
+ steerTo(address, content, origin, options) {
423
+ if (this.closed)
424
+ return undefined;
325
425
  // 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);
426
+ // A resume keeps the prior interruption flag until it settles, but is already running.
427
+ if (this.hasActiveRuns())
428
+ return this.core.steerTo(address, content, origin, options);
429
+ if (this.lastRunInterrupted)
430
+ return undefined;
328
431
  // Idle: only the root frame can be woken. A subagent whose frame has ended is not a teammate
329
432
  // waiting for mail — it is a finished delegation, and only the parent that delegated it can
330
433
  // decide to continue it (`Agent(resume=...)`). Its store, capabilities, permissions and
331
434
  // lifetime are all the parent's; there is no independent agent here to start.
332
435
  if (address !== DEFAULT_ADDRESS)
333
- return false;
436
+ return undefined;
334
437
  // 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;
438
+ // than synthesizing a prompt here) is what gives the message its `steer.queued` record and
439
+ // a `steerId` on its journal record, same as every other producer.
440
+ return this.core.steer.steer(content, origin, options);
339
441
  }
340
442
  /**
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.
443
+ * Put a message whose `steer.queued` record ALREADY exists back on the bus.
349
444
  *
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).
445
+ * For whoever holds a reopened session and finds, in its log, accepted messages no turn ever
446
+ * consumed — a `steer.queued` with no `message.appended` carrying its `steerId`. That happens
447
+ * when acceptance and execution are different processes (a managed API node writes the record,
448
+ * a worker runs it later, possibly on another machine) or when the process that accepted a
449
+ * message died before a turn drained it. Nothing is journaled again: the record and its
450
+ * `steer.queued` event exist from the first acceptance. Run state is read NOW, not then — a
451
+ * running turn drains it at its next boundary, an idle session is woken to take it.
354
452
  */
355
- async deliver(input, options) {
453
+ requeue(item) {
356
454
  if (this.closed)
357
455
  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);
456
+ return this.core.steer.requeue(item);
401
457
  }
402
458
  /**
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.
459
+ * Settles when no run is in flight. Yields once first, so a run that an enqueue on the
460
+ * previous line scheduled (the idle wake fires from a microtask) is counted as in flight
461
+ * rather than missed. A run that starts afterwards is not waited for.
413
462
  */
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 };
463
+ async whenIdle() {
464
+ await Promise.resolve();
465
+ await this.allRunsSettled();
447
466
  }
448
467
  /**
449
468
  * Resume a durably-interrupted run. When a `prompt` returns `status:"interrupted"` — which
@@ -465,15 +484,17 @@ export class HarnessSession {
465
484
  * copy see a "stale interruption state" error; re-fetching the persisted state clears it.
466
485
  */
467
486
  async resume(answers) {
487
+ if (this.closed)
488
+ throw new Error(`session "${this.id}" is closed`);
468
489
  // 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)
490
+ // the call parks here until release rather than failing; closing meanwhile does fail it).
491
+ if (this.runGate !== undefined)
492
+ await this.parkAtGate();
493
+ if (this.hasActiveRuns())
473
494
  throw new Error(`session "${this.id}" already has an active run`);
474
495
  const controller = new AbortController();
475
496
  // 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
497
+ // immediately observe `running`, and a concurrent steer joins this resumed turn instead
477
498
  // of seeing stale interrupted state while the control record is being read.
478
499
  this.beginRun(controller);
479
500
  try {
@@ -509,6 +530,8 @@ export class HarnessSession {
509
530
  }
510
531
  /** Streaming variant: iterate events, await `.completed` for the result. */
511
532
  promptStream(input) {
533
+ if (this.closed)
534
+ throw new Error(`session "${this.id}" is closed`);
512
535
  const controller = new AbortController();
513
536
  // Ungated: start synchronously, byte-identical to the pre-barrier behavior. Gated: the
514
537
  // handle returns immediately but the underlying run starts only after release (lazy start —
@@ -518,8 +541,7 @@ export class HarnessSession {
518
541
  this.beginRun(controller);
519
542
  const start = (async () => {
520
543
  if (gated) {
521
- while (this.runGate !== undefined)
522
- await this.runGate.released;
544
+ await this.parkAtGate();
523
545
  this.beginRun(controller);
524
546
  }
525
547
  return this.runner.runStream(this.agent, input, {
@@ -580,26 +602,30 @@ export class HarnessSession {
580
602
  };
581
603
  }
582
604
  /**
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.
605
+ * Inject a user message into the in-flight (or next) turn. The receipt's `steerId` is the
606
+ * correlation key: a `steer.queued` event carries it now, and the consuming `message.appended`
607
+ * event's `origin.steerId` matches it when the model actually sees the message; `journaled`
608
+ * settles once the `steer.queued` record is durable.
586
609
  */
587
610
  steer(input) {
588
- return this.core.steer.steer(input, { kind: "user" }).steerId;
611
+ if (this.closed)
612
+ throw new Error(`session "${this.id}" is closed`);
613
+ return this.core.steer.steer(input, { kind: "user" });
589
614
  }
590
615
  /**
591
616
  * 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.
617
+ * Returns the receipt (see `steer`), or null when the session has no active run — use
618
+ * `prompt` instead in that case.
594
619
  */
595
620
  followUp(input) {
596
- if (this.currentRun === undefined)
621
+ if (!this.hasActiveRuns())
597
622
  return null;
598
- return this.core.steer.followUp(input).steerId;
623
+ return this.core.steer.followUp(input);
599
624
  }
600
- /** Abort the current run. */
625
+ /** Abort every run this session has accepted: the one in flight AND any queued behind it. */
601
626
  cancel() {
602
- this.currentRun?.abort();
627
+ for (const controller of [...this.runs.keys()])
628
+ controller.abort();
603
629
  }
604
630
  /** Subscribe to this session's event stream. Returns an unsubscribe fn. */
605
631
  onEvent(listener) {
@@ -643,82 +669,103 @@ export class HarnessSession {
643
669
  setPermissionMode(mode) {
644
670
  return this.core.setPermissionMode(mode);
645
671
  }
646
- createGoal(...args) {
647
- return this.core.createGoal(...args);
672
+ /** The model this session runs on, as last set; undefined means the agent's own. */
673
+ get modelSetting() {
674
+ return this.core.modelSetting;
648
675
  }
649
- getGoal() {
650
- return this.core.getGoal();
676
+ /** The thinking level this session runs at, as last set; undefined means the model's default. */
677
+ get thinkingSetting() {
678
+ return this.core.thinkingSetting;
651
679
  }
652
- setPlanMode(...args) {
653
- return this.core.setPlanMode(...args);
680
+ /** The permission mode in force right now — what a status line reads instead of tracking it. */
681
+ get permissionMode() {
682
+ return this.core.permissionModeSetting;
654
683
  }
655
- getPlan() {
656
- return this.core.getPlan();
684
+ // ── capability services ───────────────────────────────────────────────────────────────
685
+ // The same accessors the core `Session` exposes, forwarded. There used to be one wrapper
686
+ // method per operation here, mirroring one facade method per operation there — two layers
687
+ // that had to be kept in step by hand every time a capability grew an operation. Forwarding
688
+ // the SERVICE means a new operation needs no change in either layer.
689
+ get goal() {
690
+ return this.core.goal;
657
691
  }
658
- compact(...args) {
659
- return this.core.compact(...args);
692
+ get plan() {
693
+ return this.core.plan;
660
694
  }
661
- listSkills() {
662
- return this.core.listSkills();
695
+ get skills() {
696
+ return this.core.skills;
663
697
  }
664
- activateSkill(...args) {
665
- return this.core.activateSkill(...args);
698
+ get plugins() {
699
+ return this.core.plugins;
666
700
  }
667
- listPlugins() {
668
- return this.core.listPlugins();
701
+ get compaction() {
702
+ return this.core.compaction;
669
703
  }
670
- installPlugin(...args) {
671
- return this.core.installPlugin(...args);
704
+ /** PROBE tier: absent without the background capability — see `Session.background`. */
705
+ get background() {
706
+ return this.core.background;
672
707
  }
673
- listBackgroundTasks(...args) {
674
- return this.core.listBackgroundTasks(...args);
708
+ /** PROBE tier: absent without an MCP capability. */
709
+ get mcp() {
710
+ return this.core.mcp;
675
711
  }
676
712
  // ── Conversation log (flat, linear) — read the record stream for transcript rendering ──
677
713
  getRecords(...args) {
678
714
  return this.core.getRecords(...args);
679
715
  }
680
- listSubagents(...args) {
681
- return this.core.listSubagents(...args);
682
- }
683
- reconcileSubagents(...args) {
684
- return this.core.reconcileSubagents(...args);
716
+ getAuditRecords() {
717
+ return this.core.getAuditRecords();
685
718
  }
686
719
  // ── context introspection — where the model's window is spent, as of the last turn ──
687
720
  getContextBreakdown() {
688
721
  return this.core.getContextBreakdown();
689
722
  }
723
+ /** Where this session's tools run. A host uses it to run a command in the same place the agent
724
+ * does — one shell, one filesystem, one working directory. */
725
+ get environment() {
726
+ return this.core.environment;
727
+ }
728
+ /** The current todo list (the `TodoList` tool's latest state); empty without the todo capability. */
729
+ getTodos() {
730
+ return this.core.service("todo")?.get() ?? [];
731
+ }
732
+ /** Every slash command this session answers to — the static registry plus the ones its
733
+ * capabilities and extensions contribute — for a UI's palette and autocompletion. */
734
+ listCommands() {
735
+ return sharedCommands()
736
+ .list(this.core)
737
+ .map((command) => ({ name: command.name, aliases: command.aliases ?? [], description: command.description }));
738
+ }
690
739
  // ── extensions — hot attach/detach on the live session (host API; never a model tool) ──
691
740
  /**
692
741
  * Add an extension mid-session. Applied at the next quiet point: immediately when idle,
693
742
  * at the run's stop boundary when one is in flight — its tools appear at the next run's
694
743
  * assembly. The change is journaled; it does NOT survive `resumeSession` (re-attach, or
695
744
  * 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.
745
+ * with a `harness` half is accepted only once registered — its service must already exist.
697
746
  */
698
- attachExtension(definition) {
699
- return this.extensionRuntime().attach(definition);
747
+ async attachExtension(definition) {
748
+ await this.core.attachCapability(this.mountExtension(definition));
700
749
  }
701
750
  /** Remove an extension mid-session: `session.end("detach")` fires, then its scope unwinds. */
702
- detachExtension(extensionId) {
703
- return this.extensionRuntime().detach(extensionId);
751
+ async detachExtension(extensionId) {
752
+ if (this.extensionRuntime(extensionId) === undefined)
753
+ throw new Error(`unknown extension "${extensionId}"`);
754
+ await this.core.detachCapability(extensionId);
704
755
  }
705
- /** Ids of currently attached extensions; empty when the session has no extensions capability. */
756
+ /** Ids of currently attached extensions, in mount order. */
706
757
  attachedExtensionIds() {
707
- try {
708
- return this.extensionRuntime().attachedIds();
709
- }
710
- catch {
711
- return [];
712
- }
758
+ return this.attachedExtensions().map((extension) => extension.id);
713
759
  }
714
- /** Currently attached extensions with what each `uses`; empty without an extensions capability. */
760
+ /** Currently attached extensions with what each `uses`. */
715
761
  attachedExtensions() {
716
- try {
717
- return this.extensionRuntime().attachedExtensions();
718
- }
719
- catch {
720
- return [];
762
+ const out = [];
763
+ for (const capability of this.core.capabilities) {
764
+ const runtime = extensionRuntimeOf(capability);
765
+ if (runtime !== undefined)
766
+ out.push({ id: runtime.id, uses: runtime.definition.uses ?? [] });
721
767
  }
768
+ return out;
722
769
  }
723
770
  /** Run a slash command against this session: the static registry plus capability-contributed
724
771
  * dynamic commands (extensions' `registerCommand`). Commands are the SERIALIZABLE control
@@ -731,108 +778,82 @@ export class HarnessSession {
731
778
  * is absent, detached, or exposed nothing. The generic replacement for per-feature session
732
779
  * facade methods (e.g. cron's — see `cronExtension`). */
733
780
  extensionHandle(extensionId) {
781
+ return this.extensionRuntime(extensionId)?.exposedHandle();
782
+ }
783
+ extensionRuntime(extensionId) {
784
+ const capability = this.core.capabilities.find((open) => open.name === extensionId);
785
+ return capability === undefined ? undefined : extensionRuntimeOf(capability);
786
+ }
787
+ /**
788
+ * Close the session and release its resources. Order: stop accepting runs, abort the ones
789
+ * accepted (in flight and queued) and wait for them to settle — their tools' finally blocks,
790
+ * their journal writes — then detach the projection and take the core down. Every call
791
+ * returns the SAME promise, so nobody returns while the teardown is still running.
792
+ *
793
+ * The wait for runs is bounded (`CLOSE_RUN_WAIT_MS`, 5s): a run that ignores its abort signal
794
+ * is logged and left behind, and the teardown continues — the deadline caps the wait for
795
+ * runs, not the whole close (flushes and disposes carry their own deadlines).
796
+ */
797
+ close() {
798
+ if (this.closing !== undefined)
799
+ return this.closing;
800
+ this.lifecycle = "closing";
801
+ this.notifyClosing();
802
+ this.closing = this.runClose();
803
+ return this.closing;
804
+ }
805
+ async runClose() {
734
806
  try {
735
- return this.extensionRuntime().exposedHandle(extensionId);
807
+ const settled = this.allRunsSettled();
808
+ this.cancel();
809
+ let timer;
810
+ const timedOut = await Promise.race([
811
+ settled.then(() => false),
812
+ new Promise((resolve) => {
813
+ timer = setTimeout(() => resolve(true), CLOSE_RUN_WAIT_MS);
814
+ }),
815
+ ]);
816
+ clearTimeout(timer);
817
+ if (timedOut) {
818
+ this.core.logger.log("warn", "session close: runs still settling after the deadline; continuing teardown", {
819
+ sessionId: this.id,
820
+ runs: this.runs.size,
821
+ deadlineMs: CLOSE_RUN_WAIT_MS,
822
+ });
823
+ }
824
+ this.projection.detach();
825
+ await this.core.close();
826
+ await this.onClosed(this.id);
736
827
  }
737
- catch {
738
- return undefined;
828
+ finally {
829
+ this.lifecycle = "closed";
739
830
  }
740
831
  }
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
832
  }
818
833
  /** Manages a fleet of in-process sessions over a shared engine config. */
819
834
  export class Harness {
820
835
  options;
821
836
  runner;
822
- repo;
823
837
  /** Concrete tools the default profile's tool NAMES resolve against (keyed by schema name). */
824
838
  toolPalette;
825
839
  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();
840
+ /** Opens in flight by session id (`resumeSession`): concurrent callers share ONE open — the
841
+ * whole of it, reconcile and failure cleanup included — instead of each building an instance. */
842
+ opening = new Map();
843
+ /** The one close in flight (or finished): every `close()` call returns THIS promise. */
844
+ closing;
845
+ /** The process-lived objects this harness runs on (`HarnessOptions.harness`), plus defaults. */
846
+ parts = {};
847
+ /** Process-level extension services by name (a facade over `scope`). The host replaces
848
+ * providers here (`services.replace`); sessions only ever see handles (`ctx.shared`, `ctx.services`). */
849
+ services;
829
850
  /** The one in-flight reshape barrier (mutex: a second barrier operation rejects). Sessions
830
851
  * appearing while it holds are gated at birth — see the tail of `openFromStore`. */
831
852
  activeBarrier;
832
853
  /** File-extension manager (both tiers) — present only when the host opted in with
833
854
  * `extensionDir`. NOT the Codex-compatible skill/MCP plugin system (`pluginManager`). */
834
855
  extensions;
835
- /** Service ids published by by-value `create` halves, in creation order — unregistered (and
856
+ /** Service ids published by by-value `harness` halves, in creation order — unregistered (and
836
857
  * disposed) by `close()`, newest first. */
837
858
  createdServices = [];
838
859
  /** The by-value definitions this harness currently mounts into newborn sessions, in
@@ -843,12 +864,13 @@ export class Harness {
843
864
  valueDefs = new Map();
844
865
  /** Root of extensions' data folders (`host.dataDir` = `<root>/<id>`); absent ⇒ none handed out. */
845
866
  dataRoot;
846
- /** Settles once every by-value `create` half has published its service; sessions open after it. */
867
+ /** Settles once every by-value `harness` half has published its service; sessions open after it. */
847
868
  sharedReady;
848
- /** The extension whose `create` is running right now — its `createSession` must refuse. */
869
+ /** The extension whose `harness` is running right now — its `createSession` must refuse. */
849
870
  runningCreate;
850
871
  constructor(options) {
851
872
  this.options = options;
873
+ this.services = new ServiceRegistry();
852
874
  const agentTools = options.tools ?? [...filesystemTools(), askUserQuestionTool];
853
875
  this.toolPalette = Object.fromEntries(agentTools.map((tool) => [tool.schema.name, tool]));
854
876
  // The Agent/Workflow tools only appear when the run has subagents to spawn. Default the fleet
@@ -865,8 +887,6 @@ export class Harness {
865
887
  maxStepsPerTurn: options.maxStepsPerTurn,
866
888
  ...(options.maxConcurrentSubagents !== undefined ? { maxConcurrentSubagents: options.maxConcurrentSubagents } : {}),
867
889
  });
868
- // Injected repository (disk locally, Pg/Redis on a server); in-memory when omitted.
869
- this.repo = options.repository ?? new MemorySessionRepository();
870
890
  this.dataRoot = options.extensionDataDir ?? (options.extensionDir !== undefined ? join(options.extensionDir, ".data") : undefined);
871
891
  this.extensions = options.extensionDir === undefined
872
892
  ? undefined
@@ -897,18 +917,65 @@ export class Harness {
897
917
  throw new Error(`duplicate by-value extension id "${definition.id}" in createHarness({ extensions })`);
898
918
  this.valueDefs.set(definition.id, definition);
899
919
  }
900
- this.sharedReady = this.registerDefinitions(options.extensions);
920
+ // The host's process-tier registrations come first (extension `harness` halves may consume
921
+ // them); a synchronous hook keeps the whole chain synchronous, so a synchronous extension's
922
+ // service exists the moment `createHarness` returns.
923
+ const composed = options.harness?.();
924
+ const absorb = (parts) => {
925
+ this.parts = {
926
+ ...(parts ?? {}),
927
+ // Explicit options win over what the preset built, and both win over the defaults below.
928
+ ...(options.telemetry !== undefined ? { telemetry: options.telemetry } : {}),
929
+ ...(options.eventPublication !== undefined ? { eventPublication: options.eventPublication } : {}),
930
+ };
931
+ };
932
+ this.sharedReady = composed instanceof Promise
933
+ ? composed.then((parts) => {
934
+ absorb(parts);
935
+ return this.registerDefinitions(options.extensions);
936
+ })
937
+ : (absorb(composed), this.registerDefinitions(options.extensions));
901
938
  // Surfaced to whoever opens a session; never an unhandled rejection on its own.
902
939
  this.sharedReady.catch(() => undefined);
903
940
  }
904
941
  /**
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
942
+ * Which session-facing parts the `harness` hook built for the whole process — what a
943
+ * multi-tenant host must not have, since every session on the process would share them.
944
+ * Infrastructure (repository, logger, model runtime) is not listed: sharing it is the point.
945
+ */
946
+ async processSharedParts() {
947
+ await this.sharedReady;
948
+ const parts = [];
949
+ if (this.parts.mcpServers !== undefined)
950
+ parts.push("mcpServers");
951
+ if (this.parts.oauthService !== undefined)
952
+ parts.push("oauthService");
953
+ if (this.parts.skillRegistry !== undefined)
954
+ parts.push("skillRegistry");
955
+ return parts;
956
+ }
957
+ /** The session repository, once the `harness` hook has had its say. */
958
+ async repository() {
959
+ await this.sharedReady;
960
+ this.memoryRepository ??= new MemorySessionRepository();
961
+ return this.parts.sessionRepository ?? this.memoryRepository;
962
+ }
963
+ /** The fallback repository, built once so two opens share one in-memory store. */
964
+ memoryRepository;
965
+ /** The harness logger: the preset's, else the `AGENTS_LOG` env logger, else silent. */
966
+ get logger() {
967
+ this.defaultLogger ??= envLogger() ?? noopLogger;
968
+ return this.parts.logger ?? this.defaultLogger;
969
+ }
970
+ defaultLogger;
971
+ /**
972
+ * Run one by-value definition's `harness` half against a staging host: the service is COLLECTED,
973
+ * not registered, so a throwing `harness` publishes nothing. Stays synchronous when `harness` is
907
974
  * — what lets a synchronous extension's service exist the moment `createHarness` returns.
908
975
  * Shared by construction and `replaceExtension`.
909
976
  */
910
977
  stageValue(definition) {
911
- // The data folder exists before `create` runs (synchronously: `create` may be too).
978
+ // The data folder exists before `harness` runs (synchronously: `harness` may be too).
912
979
  const dataDir = this.dataRoot !== undefined ? join(this.dataRoot, definition.id) : undefined;
913
980
  if (dataDir !== undefined)
914
981
  mkdirSync(dataDir, { recursive: true });
@@ -931,6 +998,7 @@ export class Harness {
931
998
  }
932
999
  return this.createSession(sessionOptions);
933
1000
  },
1001
+ service: (name) => this.services.handle(name),
934
1002
  warn: (message) => console.warn(`[extensions] ${message}`),
935
1003
  });
936
1004
  return staged instanceof Promise ? staged.then(done, failed) : done(staged);
@@ -941,12 +1009,12 @@ export class Harness {
941
1009
  }
942
1010
  /**
943
1011
  * 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
1012
+ * (a consumer must come after its provider), then run its `harness` half if it has one and
945
1013
  * 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
1014
+ * every `harness` is — so `createHarness` returns with a synchronous extension's service
1015
+ * already registered; the first async `harness` turns the rest of the sequence into a promise
948
1016
  * chain that `openFromStore` awaits. Staging (`stageDefinition`) collects before registering,
949
- * so a throwing `create` publishes nothing.
1017
+ * so a throwing `harness` publishes nothing.
950
1018
  */
951
1019
  registerDefinitions(extensions) {
952
1020
  if (extensions === undefined)
@@ -957,7 +1025,7 @@ export class Harness {
957
1025
  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
1026
  }
959
1027
  }
960
- if (definition.create === undefined)
1028
+ if (definition.harness === undefined)
961
1029
  return;
962
1030
  const staged = this.stageValue(definition);
963
1031
  const publish = (result) => {
@@ -990,8 +1058,8 @@ export class Harness {
990
1058
  * session's capabilities.
991
1059
  *
992
1060
  * 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.
1061
+ * resolves the current frame's environment + cwd through the Session cache, so root/subagent/worktree
1062
+ * prompts stay isolated without coupling Agent lifetime to environment lifetime.
995
1063
  */
996
1064
  buildDefaultAgent(appendSystemPrompt, maxStepsPerTurn) {
997
1065
  const additional = appendSystemPrompt ?? this.options.appendSystemPrompt;
@@ -1008,36 +1076,91 @@ export class Harness {
1008
1076
  return this.activeSessions;
1009
1077
  }
1010
1078
  async createSession(opts = {}) {
1079
+ this.assertAcceptingSessions();
1011
1080
  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);
1081
+ const handle = await (await this.repository()).create({ id: opts.id, workDir, ownerKey: opts.ownerKey, title: opts.title });
1082
+ return this.trackOpen(handle.id, () => this.openFromStore(handle.id, handle.workDir, handle.store, opts));
1014
1083
  }
1015
- async resumeSession(id, opts = {}) {
1084
+ /**
1085
+ * Register an open in flight under its id for as long as it runs. Every path that builds a
1086
+ * session goes through here — create, resume, fork — so a `resumeSession` for an id that is
1087
+ * being created joins that open instead of building a twin over the same store, and
1088
+ * `closeSession` / `harness.close()` wait for it instead of closing its scope under it.
1089
+ */
1090
+ trackOpen(id, open) {
1091
+ const promise = open().finally(() => {
1092
+ if (this.opening.get(id) === promise)
1093
+ this.opening.delete(id);
1094
+ });
1095
+ this.opening.set(id, promise);
1096
+ return promise;
1097
+ }
1098
+ /**
1099
+ * The open session with this id, or reopen it from the store. Concurrent calls for one id share
1100
+ * a single open (`opening`): the second caller waits for the first's — reconcile included — and
1101
+ * receives the same instance, never a second one that a close would then unregister. An
1102
+ * instance that is closing is not handed out: the reopen waits for its close to finish first.
1103
+ */
1104
+ resumeSession(id, opts = {}) {
1105
+ // Not async (the in-flight/active fast paths return existing promises), so refuse by rejecting.
1106
+ if (this.closing !== undefined)
1107
+ return Promise.reject(new Error("harness is closed"));
1108
+ const inflight = this.opening.get(id);
1109
+ if (inflight !== undefined) {
1110
+ return hasOwnContext(opts)
1111
+ ? inflight.then((session) => {
1112
+ session.setContext(opts.context);
1113
+ return session;
1114
+ })
1115
+ : inflight;
1116
+ }
1016
1117
  const existing = this.activeSessions.get(id);
1017
- if (existing) {
1118
+ if (existing !== undefined && existing.status.state !== "closed") {
1018
1119
  if (hasOwnContext(opts))
1019
1120
  existing.setContext(opts.context);
1020
- return existing;
1121
+ return Promise.resolve(existing);
1021
1122
  }
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;
1123
+ return this.trackOpen(id, async () => {
1124
+ // `existing` here is one that is closing: wait for that close (its registration goes
1125
+ // with it) rather than open a twin beside it.
1126
+ if (existing !== undefined)
1127
+ await existing.close();
1128
+ const handle = await (await this.repository()).open(id);
1129
+ if (handle === undefined)
1130
+ throw new SessionRepositoryNotFoundError(id);
1131
+ const session = await this.openFromStore(handle.id, handle.workDir, handle.store, opts);
1132
+ // Reopening: orphaned background subagents (running from a dead process) → lost. A failure
1133
+ // here is an open failure: the session is torn down, not left registered and unreturned.
1134
+ try {
1135
+ await session.background?.reconcileSubagents() ?? [];
1136
+ }
1137
+ catch (error) {
1138
+ await session.close();
1139
+ throw error;
1140
+ }
1141
+ return session;
1142
+ });
1029
1143
  }
1030
1144
  /** Fork an existing session into a new one (copies its log + state) and open it. */
1031
1145
  async forkSession(sourceId, opts = {}) {
1032
- const handle = await this.repo.fork(sourceId, {
1146
+ this.assertAcceptingSessions();
1147
+ const handle = await (await this.repository()).fork(sourceId, {
1033
1148
  ...(opts.id !== undefined ? { id: opts.id } : {}),
1034
1149
  ...(opts.ownerKey !== undefined ? { ownerKey: opts.ownerKey } : {}),
1035
1150
  ...(opts.title !== undefined ? { title: opts.title } : {}),
1036
1151
  });
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;
1152
+ return this.trackOpen(handle.id, async () => {
1153
+ const session = await this.openFromStore(handle.id, handle.workDir, handle.store, opts);
1154
+ // A fork copies the subagent ledger; its background "running" rows have no live task → lost.
1155
+ try {
1156
+ await session.background?.reconcileSubagents() ?? [];
1157
+ }
1158
+ catch (error) {
1159
+ await session.close();
1160
+ throw error;
1161
+ }
1162
+ return session;
1163
+ });
1041
1164
  }
1042
1165
  /** The currently-open session with this id, or undefined (does not reopen — use resumeSession). */
1043
1166
  getSession(id) {
@@ -1046,15 +1169,22 @@ export class Harness {
1046
1169
  /** Read a session's durable handle without constructing a Session, opening capabilities,
1047
1170
  * reconciling background tasks, or subscribing Projection. Intended for read-only servers.
1048
1171
  * Soft-deleted sessions resolve only with `{ includeDeleted: true }` — audit/purge tooling. */
1049
- inspectSession(id, options) {
1050
- return this.repo.open(id, options);
1172
+ async inspectSession(id, options) {
1173
+ return (await this.repository()).open(id, options);
1051
1174
  }
1052
1175
  /** Read one catalog row without the O(N) `listSessions()` scan. */
1053
- getSessionSummary(id) {
1054
- return this.repo.get(id);
1176
+ async getSessionSummary(id) {
1177
+ return (await this.repository()).get(id);
1055
1178
  }
1056
- /** Close one open session by id (no-op if it isn't open). */
1179
+ /** Close one open session by id (no-op if it isn't open). An open in flight for this id is
1180
+ * waited for and then closed, so a close/delete never races a half-built session. */
1057
1181
  async closeSession(id) {
1182
+ const inflight = this.opening.get(id);
1183
+ if (inflight !== undefined) {
1184
+ const session = await inflight.catch(() => undefined);
1185
+ await session?.close();
1186
+ return;
1187
+ }
1058
1188
  await this.activeSessions.get(id)?.close();
1059
1189
  }
1060
1190
  /**
@@ -1063,30 +1193,72 @@ export class Harness {
1063
1193
  */
1064
1194
  async deleteSession(id, options) {
1065
1195
  await this.closeSession(id);
1066
- await this.repo.delete(id, options);
1196
+ await (await this.repository()).delete(id, options);
1067
1197
  }
1068
1198
  /** Undo a soft delete. No-op when the session is absent or was never deleted. */
1069
- restoreSession(id) {
1070
- return this.repo.restore(id);
1199
+ async restoreSession(id) {
1200
+ return (await this.repository()).restore(id);
1071
1201
  }
1072
1202
  async listSessions(filter) {
1073
- return this.repo.list(filter);
1203
+ return (await this.repository()).list(filter);
1204
+ }
1205
+ /** Set a session's title (an empty string clears it). Works on open and closed sessions alike. */
1206
+ async renameSession(id, title) {
1207
+ await (await this.repository()).rename(id, title.trim());
1074
1208
  }
1075
- async close() {
1209
+ /**
1210
+ * Close every open session, then the harness scope — which disposes everything registered
1211
+ * there in reverse order: by-value `harness` halves die with the harness that ran them (each
1212
+ * drained, then its dispose hook — default the instance's close()), then the host's own
1213
+ * registrations, then the defaults.
1214
+ */
1215
+ close() {
1216
+ if (this.closing !== undefined)
1217
+ return this.closing;
1218
+ this.closing = this.runClose();
1219
+ return this.closing;
1220
+ }
1221
+ async runClose() {
1222
+ // Opens in flight finish (or fail) first: closing the scope tree under a half-built session
1223
+ // would tear its scope out from under `Session.open`. New opens are refused from here on
1224
+ // (`assertAcceptingSessions`), so the set only shrinks.
1225
+ await Promise.allSettled([...this.opening.values()]);
1076
1226
  for (const session of [...this.activeSessions.values()])
1077
1227
  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
1228
  await this.sharedReady.catch(() => undefined);
1081
- for (const name of [...this.createdServices].reverse())
1082
- await this.services.unregister(name);
1229
+ // The `harness` halves' services, newest first: each is drained, then disposed (default, the
1230
+ // instance's own close()). A failure here is logged, never allowed to skip the rest.
1231
+ for (const name of [...this.createdServices].reverse()) {
1232
+ try {
1233
+ await this.services.unregister(name);
1234
+ }
1235
+ catch (error) {
1236
+ this.logger.log("warn", `service "${name}" unregister failed during harness close`, {
1237
+ service: name,
1238
+ error: error instanceof Error ? error.message : String(error),
1239
+ });
1240
+ }
1241
+ }
1083
1242
  this.createdServices.length = 0;
1243
+ // Last: the process-shared objects the preset opened (MCP connections, handles). Every
1244
+ // session is closed by now, so nothing is still reading them.
1245
+ try {
1246
+ await this.parts.close?.();
1247
+ }
1248
+ catch (error) {
1249
+ this.logger.log("warn", "harness parts close failed", { error: error instanceof Error ? error.message : String(error) });
1250
+ }
1251
+ }
1252
+ /** Every path that opens a session starts here: a closing harness opens nothing new. */
1253
+ assertAcceptingSessions() {
1254
+ if (this.closing !== undefined)
1255
+ throw new Error("harness is closed");
1084
1256
  }
1085
1257
  /**
1086
1258
  * Replace by-value extensions with new versions, as one coordinated act — the by-value twin of
1087
1259
  * `harness.extensions.reload()` (docs/architecture.md §5.5). Every session holding
1088
1260
  * 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
1261
+ * aborted); in that global quiet moment the old halves detach, each `harness` half re-runs and
1090
1262
  * its service is swapped, and the new halves attach; then everyone resumes. Sessions born from
1091
1263
  * the quiet moment on are born with the new definitions. On rendezvous timeout NOTHING changes:
1092
1264
  * the barrier lifts, `BarrierTimeout` names the stuck sessions, and the caller retries later
@@ -1098,7 +1270,7 @@ export class Harness {
1098
1270
  *
1099
1271
  * `services` swaps HOST-registered services (`createHarness({ services })`) in the same quiet
1100
1272
  * 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
1273
+ * their `replaceable` flag; an extension's own `harness` service swaps unconditionally, since
1102
1274
  * its owner is being replaced along with it.
1103
1275
  *
1104
1276
  * Implementation-only swaps need none of this — `harness.services.replace` (layer 1) lands on
@@ -1114,14 +1286,14 @@ export class Harness {
1114
1286
  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
1287
  }
1116
1288
  }
1117
- // A `create` half still publishing would race the swap.
1289
+ // A `harness` half still publishing would race the swap.
1118
1290
  await this.sharedReady;
1119
- // Every new `create` runs BEFORE the barrier: staging COLLECTS, so one that throws leaves the
1291
+ // Every new `harness` runs BEFORE the barrier: staging COLLECTS, so one that throws leaves the
1120
1292
  // world completely untouched (no session detached, no service swapped) — the same discipline
1121
1293
  // construction and the file manager use. Whatever a failed act staged is disposed below.
1122
1294
  const staged = new Map();
1123
1295
  for (const definition of definitions) {
1124
- if (definition.create !== undefined)
1296
+ if (definition.harness !== undefined)
1125
1297
  staged.set(definition.id, await this.stageValue(definition));
1126
1298
  }
1127
1299
  const published = new Set();
@@ -1160,7 +1332,7 @@ export class Harness {
1160
1332
  }
1161
1333
  catch (error) {
1162
1334
  // 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.
1335
+ // a `harness` that opened a pool must not leak it because the barrier never converged.
1164
1336
  for (const [id, entry] of staged) {
1165
1337
  if (!published.has(id) && entry.service !== undefined)
1166
1338
  await this.disposeStaged(id, entry.service.instance);
@@ -1169,7 +1341,7 @@ export class Harness {
1169
1341
  }
1170
1342
  }
1171
1343
  /**
1172
- * The service half of one replaced extension: publish what its `create` staged. `replace`
1344
+ * The service half of one replaced extension: publish what its `harness` staged. `replace`
1173
1345
  * rather than unregister + register, so consumers this barrier never gated — other extensions
1174
1346
  * holding a `uses` handle — see no gap; `force` because the owner is being replaced along with
1175
1347
  * it (see `ServiceRegistry.replace`).
@@ -1188,13 +1360,13 @@ export class Harness {
1188
1360
  }
1189
1361
  return;
1190
1362
  }
1191
- // The new version dropped its `create` half: its service goes away with it.
1363
+ // The new version dropped its `harness` half: its service goes away with it.
1192
1364
  if (previous >= 0) {
1193
1365
  await this.services.unregister(id, drain);
1194
1366
  this.createdServices.splice(previous, 1);
1195
1367
  }
1196
1368
  }
1197
- /** Dispose a `create` result that was staged but never published (default rule: its `close()`). */
1369
+ /** Dispose a `harness` result that was staged but never published (default rule: its `close()`). */
1198
1370
  async disposeStaged(id, instance) {
1199
1371
  try {
1200
1372
  const close = instance?.close;
@@ -1202,7 +1374,7 @@ export class Harness {
1202
1374
  await close.call(instance);
1203
1375
  }
1204
1376
  catch (error) {
1205
- console.warn(`[extensions] extension "${id}": disposing the unpublished create result failed: ${error instanceof Error ? error.message : String(error)}`);
1377
+ console.warn(`[extensions] extension "${id}": disposing the unpublished harness() result failed: ${error instanceof Error ? error.message : String(error)}`);
1206
1378
  }
1207
1379
  }
1208
1380
  /**
@@ -1239,20 +1411,33 @@ export class Harness {
1239
1411
  this.activeBarrier = undefined;
1240
1412
  }
1241
1413
  }
1242
- /** Resolve the capability set for one session — calling the factory fresh when one was given. */
1414
+ /** Resolve the capability set for one session — the `session` hook, called fresh every time. */
1243
1415
  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.
1416
+ // A session on its own environment (a sandbox) does not get the process's MCP connections or
1417
+ // skill scan: both describe the HARNESS's machine, not the one this session's tools run on.
1418
+ const base = this.options.session !== undefined
1419
+ ? await this.options.session(ctx)
1420
+ : defaultCapabilities({
1421
+ shared: this.parts,
1422
+ ownEnvironment: ctx.ownEnvironment,
1423
+ strictIsolation: ctx.strictIsolation,
1424
+ ...(ctx.mcpServers !== undefined ? { sessionMcpServers: ctx.mcpServers } : {}),
1425
+ ...(ctx.mcpCredentials !== undefined ? { mcpCredentials: ctx.mcpCredentials } : {}),
1426
+ });
1427
+ // Every definition registered on this harness: by value (`extensions`, harness halves
1428
+ // included — their per-session `session` is mounted here like any other), then from files.
1250
1429
  const extensions = [...this.valueDefs.values(), ...(this.extensions?.sessionDefinitions() ?? [])];
1430
+ // An extension is mounted under its id, so one named like a capability the session already
1431
+ // has would displace it — refused by name rather than resolved by order.
1432
+ const baseNames = new Set(base.map((capability) => capability.name));
1433
+ for (const definition of extensions) {
1434
+ if (baseNames.has(definition.id)) {
1435
+ throw new Error(`extension "${definition.id}" has the name of one of this session's capabilities; give it another id`);
1436
+ }
1437
+ }
1251
1438
  // Put extension transforms first. Shell PreToolUse remains in the staged permission policy,
1252
1439
  // 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];
1440
+ return [...extensionsCapability(extensions, { host: this.extensionHost(ctx.sessionId), params }), ...base];
1256
1441
  }
1257
1442
  /**
1258
1443
  * The harness-tier reach handed to a session's extensions: other sessions, and the model
@@ -1264,7 +1449,7 @@ export class Harness {
1264
1449
  */
1265
1450
  extensionHost(sessionId) {
1266
1451
  const self = this;
1267
- const runtime = this.options.modelRuntime;
1452
+ const runtime = this.parts.modelRuntime;
1268
1453
  return {
1269
1454
  sessionId,
1270
1455
  newSession: (options) => self.createSession({ ...(options?.title !== undefined ? { title: options.title } : {}) }),
@@ -1281,7 +1466,10 @@ export class Harness {
1281
1466
  throw new Error("unregisterProvider() requires the harness to be built with a `modelRuntime`");
1282
1467
  runtime.models.deleteProvider(id);
1283
1468
  },
1284
- services: self.services,
1469
+ services: {
1470
+ has: (name) => self.services.has(name),
1471
+ handle: (name) => self.services.handle(name),
1472
+ },
1285
1473
  isIdle: () => self.activeSessions.get(sessionId)?.status.state !== "running",
1286
1474
  waitForIdle: async () => {
1287
1475
  const session = self.activeSessions.get(sessionId);
@@ -1293,8 +1481,8 @@ export class Harness {
1293
1481
  };
1294
1482
  }
1295
1483
  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.
1484
+ // A session's `session` reaches a harness half through its service, so none opens before the
1485
+ // harness halves have registered them.
1298
1486
  await this.sharedReady;
1299
1487
  // Per-session extension params: given at create, persisted, and read back on every later
1300
1488
  // open (resume, fork) so a session keeps them without the caller repeating them.
@@ -1303,65 +1491,130 @@ export class Harness {
1303
1491
  await store.putState(EXTENSION_PARAMS_STATE_KEY, params);
1304
1492
  else
1305
1493
  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({
1494
+ await this.sharedReady;
1495
+ // From here the open is a transaction: the session is registered as active only once every
1496
+ // step has succeeded, and any failure tears down exactly what was built — the projection, the
1497
+ // core when it got that far — before rethrowing.
1498
+ let projection;
1499
+ let core;
1500
+ try {
1501
+ const events = new ListenerSink();
1502
+ const responder = new MutableResponder();
1503
+ // Environment: this call's override → the harness's default → a LocalEnvironment at the
1504
+ // session's own workDir. The session only OPERATES a caller-supplied environment.
1505
+ const environment = opts.environment ?? this.parts.environment ?? new LocalEnvironment(workDir);
1506
+ // ONE open-time log read, shared: every shard from its newest checkpoint on (each
1507
+ // checkpoint carries the history and lifecycle before it). The projection seeds from it,
1508
+ // and Session.open receives it as `preloadedLog` so its capability restore + context
1509
+ // pre-build fold the same records — same IO, and the same `Message` objects.
1510
+ // Attach still happens BEFORE Session.open: no events flow yet, so the seed cannot
1511
+ // race the subscription, and anything capabilities emit during open is already folded.
1512
+ let preloadedLog = await readSessionTails(store);
1513
+ // A fresh session has an empty log; anything else is a reopen. Telemetry is the only reader.
1514
+ const resumed = preloadedLog.length > 0;
1515
+ projection = await SessionProjection.attach({ id, store, events }, preloadedLog);
1516
+ const interrupted = await store.getState(INTERRUPTION_STATE_KEY) !== null;
1517
+ if (!interrupted) {
1518
+ const recoveryRecords = await closeOrphanedProjectionFrames(id, store, projection);
1519
+ if (recoveryRecords.length > 0)
1520
+ preloadedLog = [...preloadedLog, ...recoveryRecords];
1521
+ }
1522
+ const capabilities = await this.resolveCapabilities({
1329
1523
  sessionId: id,
1330
1524
  workDir,
1525
+ ownEnvironment: opts.environment !== undefined,
1526
+ strictIsolation: opts.strictIsolation === true,
1331
1527
  ...(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;
1528
+ ...(opts.mcpCredentials !== undefined ? { mcpCredentials: opts.mcpCredentials } : {}),
1529
+ }, params);
1530
+ // Everything the session runs on, by value. The repository owns the store's lifetime.
1531
+ core = await Session.open({
1532
+ capabilities,
1533
+ preloadedLog,
1534
+ resumed,
1535
+ sessionId: id,
1536
+ store,
1537
+ events,
1538
+ responder,
1539
+ environment,
1540
+ permissionOptions: opts.permission ?? this.options.permission ?? { mode: "yolo" },
1541
+ ...(this.parts.logger !== undefined ? { logger: this.parts.logger } : {}),
1542
+ ...(this.parts.tracing !== undefined ? { tracing: this.parts.tracing } : {}),
1543
+ ...(this.parts.telemetry !== undefined ? { telemetry: this.parts.telemetry } : {}),
1544
+ ...(opts.eventPublication ?? this.parts.eventPublication) !== undefined
1545
+ ? { eventPublication: (opts.eventPublication ?? this.parts.eventPublication) }
1546
+ : {},
1547
+ });
1548
+ const agent = opts.agent ??
1549
+ this.options.agent ??
1550
+ (await this.buildDefaultAgent(opts.appendSystemPrompt, opts.maxStepsPerTurn));
1551
+ const session = new HarnessSession({
1552
+ core,
1553
+ // The two caps land in different places: maxStepsPerTurn is an Agent property
1554
+ // (the Runner reads the agent's value first), maxTurns is a per-run option.
1555
+ agent,
1556
+ runner: this.runner,
1557
+ events,
1558
+ responder,
1559
+ projection,
1560
+ workDir,
1561
+ context: this.contextFor(opts),
1562
+ ...(opts.maxTurns ?? this.options.maxTurns) !== undefined
1563
+ ? { maxTurns: opts.maxTurns ?? this.options.maxTurns }
1564
+ : {},
1565
+ interrupted,
1566
+ mountExtension: (definition) => {
1567
+ if (params?.[definition.id] === false) {
1568
+ throw new Error(`extension "${definition.id}" is opted out for this session (params["${definition.id}"] is false)`);
1569
+ }
1570
+ return extensionCapability(definition, { host: this.extensionHost(id), params: params?.[definition.id], startReason: "attach" });
1571
+ },
1572
+ onClosed: (sid) => {
1573
+ // Only THIS instance's registration goes — a later open under the same id (a resume
1574
+ // that waited for this close) must keep its own.
1575
+ if (this.activeSessions.get(sid) === session)
1576
+ this.activeSessions.delete(sid);
1577
+ },
1578
+ });
1579
+ this.activeSessions.set(id, session);
1580
+ // Born gated: a session appearing while a reshape barrier holds (createSession,
1581
+ // resumeSession and openSession all land here) must not start runs mid-swap. Its
1582
+ // quiescent is trivially settled — it has no run — so it never delays the rendezvous.
1583
+ if (this.activeBarrier !== undefined)
1584
+ this.activeBarrier.holds.set(id, session.holdAtBoundary());
1585
+ return session;
1586
+ }
1587
+ catch (error) {
1588
+ await this.rollbackOpen(id, { projection, core }, error);
1589
+ throw error;
1590
+ }
1591
+ }
1592
+ /**
1593
+ * Undo a failed `openFromStore`, newest artifact first. Every step runs even if an earlier one
1594
+ * throws (a cleanup failure is logged, never allowed to mask the open's own error).
1595
+ */
1596
+ async rollbackOpen(id, built, cause) {
1597
+ const logger = this.parts.logger;
1598
+ const attempt = async (step, run) => {
1599
+ try {
1600
+ await run();
1601
+ }
1602
+ catch (error) {
1603
+ logger?.log("warn", `session open rollback: ${step} failed`, {
1604
+ sessionId: id,
1605
+ step,
1606
+ error: error instanceof Error ? error.message : String(error),
1607
+ cause: cause instanceof Error ? cause.message : String(cause),
1608
+ });
1609
+ }
1610
+ };
1611
+ await attempt("detach projection", () => built.projection?.detach());
1612
+ if (built.core !== undefined)
1613
+ await attempt("close core", () => built.core.close());
1614
+ }
1615
+ /** Every definition this harness mounts, by value then from files. */
1616
+ allDefinitions() {
1617
+ return [...this.valueDefs.values(), ...(this.extensions?.sessionDefinitions() ?? [])];
1365
1618
  }
1366
1619
  contextFor(options) {
1367
1620
  return hasOwnContext(options) ? options.context : this.options.context;
@@ -1378,7 +1631,7 @@ export class Harness {
1378
1631
  *
1379
1632
  * A worker that dies mid-turn journals no `turn.ended`; to every reader the turn is still
1380
1633
  * 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
1634
+ * but opening boots capabilities and an environment just to write two records. This does only the
1382
1635
  * repair: fold the log, and for every frame still inside a turn append `turn.ended` with
1383
1636
  * `reason: "failed"` (and `agent.ended` for a live sub-agent). Returns the events those records
1384
1637
  * project to, so a host that has live subscribers can hand them on — a record appended here
@@ -1389,7 +1642,7 @@ export class Harness {
1389
1642
  export async function closeOrphanedTurns(sessionId, store) {
1390
1643
  if ((await store.getState(INTERRUPTION_STATE_KEY)) !== null)
1391
1644
  return [];
1392
- const projection = await SessionProjection.attach({ id: sessionId, store, events: new ListenerSink() }, await readLog(store));
1645
+ const projection = await SessionProjection.attach({ id: sessionId, store, events: new ListenerSink() }, await readSessionTails(store));
1393
1646
  const records = await closeOrphanedProjectionFrames(sessionId, store, projection);
1394
1647
  const events = [];
1395
1648
  for (const record of records) {