operon-agents 0.1.0-alpha.5 → 0.1.0-alpha.6

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.d.ts CHANGED
@@ -19,6 +19,11 @@ export type QuestionHandler = (request: QuestionRequest, options?: {
19
19
  readonly signal?: AbortSignal;
20
20
  }) => Promise<QuestionResult> | QuestionResult;
21
21
  export type HarnessSessionState = "idle" | "running" | "interrupted" | "closed";
22
+ /** Test-only (exported via `operon-agents/internal`): shrink the close deadlines. */
23
+ export declare function setHarnessCloseTimeoutsForTest(ms: {
24
+ runSettle?: number;
25
+ scopeDispose?: number;
26
+ }): void;
22
27
  export type DeliveryMode = "auto" | "steer" | "follow_up";
23
28
  export interface DeliveryOptions {
24
29
  readonly source: string;
@@ -402,17 +407,27 @@ export declare class HarnessSession<TContext = unknown> {
402
407
  private readonly responder;
403
408
  private readonly onClosed;
404
409
  private runContext;
405
- private currentRun;
406
410
  /** Barrier gate (docs/architecture.md §5.5): while set, no NEW run starts on this
407
411
  * session — prompt/resume park at it, promptStream starts lazily behind it. The in-flight
408
412
  * run is never touched. */
409
413
  private runGate;
410
- /** Resolves when the latest run settles — the "arrived at the boundary" signal a barrier
411
- * coordinator awaits. Undefined while idle. */
412
- private runSettled;
413
- private readonly runSettles;
414
+ /**
415
+ * Every run this facade has begun and not yet seen settle, by its controller. More than one
416
+ * entry means runs are queued behind the core's run lock (`prompt` while a turn is in flight):
417
+ * "busy" is this set being non-empty, `cancel()` aborts ALL of them, and the barrier's
418
+ * quiescence is ALL of them settling — tracking only the last-begun run would abort the
419
+ * queued one and leave the running one untouched.
420
+ */
421
+ private readonly runs;
414
422
  private lastRunInterrupted;
415
- private closed;
423
+ /** open → closing (runs being drained, resources going down) → closed. Never goes back. */
424
+ private lifecycle;
425
+ /** The one close in flight (or finished): every `close()` call returns THIS promise. */
426
+ private closing;
427
+ /** Resolves the moment close() begins — what wakes a caller parked at the barrier gate so it
428
+ * can fail with the closed error instead of hanging on a gate nobody will release. */
429
+ private notifyClosing;
430
+ private readonly closingNotified;
416
431
  private deliveryCounter;
417
432
  /** A wake is already scheduled on the microtask queue; more enqueues fold into it. */
418
433
  private wakeScheduled;
@@ -435,11 +450,19 @@ export declare class HarnessSession<TContext = unknown> {
435
450
  * Start a turn to consume queued follow-ups, once the current synchronous work is done.
436
451
  *
437
452
  * Deferred to a microtask for two reasons: several enqueues in one stack fold into a single
438
- * wake, and a settle that lands in a finishing run's own stack sees `currentRun` already
453
+ * wake, and a settle that lands in a finishing run's own stack sees its run already
439
454
  * cleared. Re-checked at fire time because either can change in between — and re-checked
440
455
  * again after every run, since the queue may have filled during one whose drain had passed.
441
456
  */
442
457
  private scheduleIdleWake;
458
+ /** Not open: closing or closed. Every run entry point refuses from here on. */
459
+ private get closed();
460
+ /**
461
+ * Park at the barrier gate until released — or until this session starts closing, in which
462
+ * case fail with the closed error rather than wait on a gate no coordinator will release.
463
+ * Re-checked in a loop: a new hold can land between a release and this task resuming.
464
+ */
465
+ private parkAtGate;
443
466
  /** Run options every run on this session carries — currently the session's turn cap. */
444
467
  private get runOverrides();
445
468
  /** Current runtime-only application context supplied to Agent callbacks. */
@@ -452,6 +475,10 @@ export declare class HarnessSession<TContext = unknown> {
452
475
  setContext(context: TContext | undefined): void;
453
476
  private beginRun;
454
477
  private endRun;
478
+ /** A run is in flight or queued behind the core's run lock. */
479
+ private hasActiveRuns;
480
+ /** Settles when every run begun so far has settled (trivially, when idle). */
481
+ private allRunsSettled;
455
482
  /**
456
483
  * Barrier support (docs/architecture.md §5.5): refuse NEW runs until released and
457
484
  * report when this session reaches its run boundary. Installing the gate is synchronous — a
@@ -538,7 +565,7 @@ export declare class HarnessSession<TContext = unknown> {
538
565
  * active run — use `prompt` instead in that case.
539
566
  */
540
567
  followUp(input: string): string | null;
541
- /** Abort the current run. */
568
+ /** Abort every run this session has accepted: the one in flight AND any queued behind it. */
542
569
  cancel(): void;
543
570
  /** Subscribe to this session's event stream. Returns an unsubscribe fn. */
544
571
  onEvent(listener: AgentEventListener): () => void;
@@ -621,8 +648,18 @@ export declare class HarnessSession<TContext = unknown> {
621
648
  listMcpTools(...args: Parameters<Session["listMcpTools"]>): ReturnType<Session["listMcpTools"]>;
622
649
  listMcpServers(...args: Parameters<Session["listMcpServers"]>): ReturnType<Session["listMcpServers"]>;
623
650
  reconnectMcpServer(...args: Parameters<Session["reconnectMcpServer"]>): ReturnType<Session["reconnectMcpServer"]>;
624
- /** Close the session and release its resources. */
651
+ /**
652
+ * Close the session and release its resources. Order: stop accepting runs, abort the ones
653
+ * accepted (in flight and queued) and wait for them to settle — their tools' finally blocks,
654
+ * their journal writes — then detach the projection and take the core down. Every call
655
+ * returns the SAME promise, so nobody returns while the teardown is still running.
656
+ *
657
+ * The wait for runs is bounded (`CLOSE_RUN_WAIT_MS`, 5s): a run that ignores its abort signal
658
+ * is logged and left behind, and the teardown continues — the deadline caps the wait for
659
+ * runs, not the whole close (flushes and disposes carry their own deadlines).
660
+ */
625
661
  close(): Promise<void>;
662
+ private runClose;
626
663
  }
627
664
  /** Manages a fleet of in-process sessions over a shared engine config. */
628
665
  export declare class Harness<TContext = unknown> {
@@ -631,6 +668,11 @@ export declare class Harness<TContext = unknown> {
631
668
  /** Concrete tools the default profile's tool NAMES resolve against (keyed by schema name). */
632
669
  private readonly toolPalette;
633
670
  private readonly activeSessions;
671
+ /** Opens in flight by session id (`resumeSession`): concurrent callers share ONE open — the
672
+ * whole of it, reconcile and failure cleanup included — instead of each building an instance. */
673
+ private readonly opening;
674
+ /** The one close in flight (or finished): every `close()` call returns THIS promise. */
675
+ private closing;
634
676
  /** The harness-tier scope: every process-lived object, and the parent of every workspace scope. */
635
677
  readonly scope: Scope<"harness">;
636
678
  /** Workspace scopes by key, reference-counted by the sessions open under them. */
@@ -692,6 +734,19 @@ export declare class Harness<TContext = unknown> {
692
734
  private buildDefaultAgent;
693
735
  get sessions(): ReadonlyMap<string, HarnessSession<TContext>>;
694
736
  createSession(opts?: CreateSessionOptions<TContext>): Promise<HarnessSession<TContext>>;
737
+ /**
738
+ * Register an open in flight under its id for as long as it runs. Every path that builds a
739
+ * session goes through here — create, resume, fork — so a `resumeSession` for an id that is
740
+ * being created joins that open instead of building a twin over the same store, and
741
+ * `closeSession` / `harness.close()` wait for it instead of closing its scope under it.
742
+ */
743
+ private trackOpen;
744
+ /**
745
+ * The open session with this id, or reopen it from the store. Concurrent calls for one id share
746
+ * a single open (`opening`): the second caller waits for the first's — reconcile included — and
747
+ * receives the same instance, never a second one that a close would then unregister. An
748
+ * instance that is closing is not handed out: the reopen waits for its close to finish first.
749
+ */
695
750
  resumeSession(id: string, opts?: ResumeSessionOptions<TContext>): Promise<HarnessSession<TContext>>;
696
751
  /** Fork an existing session into a new one (copies its log + state) and open it. */
697
752
  forkSession(sourceId: string, opts?: ForkSessionOptions<TContext>): Promise<HarnessSession<TContext>>;
@@ -703,7 +758,8 @@ export declare class Harness<TContext = unknown> {
703
758
  inspectSession(id: string, options?: OpenSessionOptions): Promise<SessionHandle | undefined>;
704
759
  /** Read one catalog row without the O(N) `listSessions()` scan. */
705
760
  getSessionSummary(id: string): Promise<SessionSummary | undefined>;
706
- /** Close one open session by id (no-op if it isn't open). */
761
+ /** Close one open session by id (no-op if it isn't open). An open in flight for this id is
762
+ * waited for and then closed, so a close/delete never races a half-built session. */
707
763
  closeSession(id: string): Promise<void>;
708
764
  /**
709
765
  * Delete a session after releasing any open in-process handle. Soft by default — the durable
@@ -720,6 +776,9 @@ export declare class Harness<TContext = unknown> {
720
776
  * registrations, then the defaults.
721
777
  */
722
778
  close(): Promise<void>;
779
+ private runClose;
780
+ /** Every path that opens a session starts here: a closing harness opens nothing new. */
781
+ private assertAcceptingSessions;
723
782
  /**
724
783
  * Replace by-value extensions with new versions, as one coordinated act — the by-value twin of
725
784
  * `harness.extensions.reload()` (docs/architecture.md §5.5). Every session holding
@@ -779,6 +838,12 @@ export declare class Harness<TContext = unknown> {
779
838
  */
780
839
  private extensionHost;
781
840
  private openFromStore;
841
+ /**
842
+ * Undo a failed `openFromStore`, newest artifact first. Every step runs even if an earlier one
843
+ * throws (a cleanup failure is logged, never allowed to mask the open's own error), and the
844
+ * workspace hold this open took is always returned.
845
+ */
846
+ private rollbackOpen;
782
847
  /**
783
848
  * The workspace scope for `key`, composed on first use — the host's `workspace` hook, then
784
849
  * every registered extension's `workspace` half — and ref-counted by the sessions under it.
@@ -786,6 +851,8 @@ export declare class Harness<TContext = unknown> {
786
851
  private acquireWorkspace;
787
852
  /** Drop one session's hold; the last one out closes the workspace scope. */
788
853
  private releaseWorkspace;
854
+ /** Close options for a scope the harness tears down itself: per-service deadline, failures logged. */
855
+ private scopeCloseOptions;
789
856
  /**
790
857
  * A handle to a workspace-tier extension service in ONE workspace — how a host reaches a
791
858
  * `workspace` half's instance (`harness.services.handle` only answers for the harness tier).
package/dist/harness.js CHANGED
@@ -19,6 +19,19 @@ import { mkdirSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { assertOneSharedHalf, extensionsCapability, ExtensionRuntime, HarnessExtensionManager, ServiceRegistry, stageDefinition } from "./extensions/index.js";
21
21
  import { createExtensionCommandRegistry } from "operon-agents-core";
22
+ /** How long `HarnessSession.close()` waits for aborted runs to settle before continuing teardown. */
23
+ let CLOSE_RUN_WAIT_MS = 5_000;
24
+ /** Per-service dispose deadline when the harness tears a scope down itself — a workspace whose
25
+ * last session left, a session scope a failed open rolls back, the harness scope on close.
26
+ * Mirrors the core session's own close deadline: one hung MCP shutdown must not hang close(). */
27
+ let SCOPE_DISPOSE_TIMEOUT_MS = 5_000;
28
+ /** Test-only (exported via `operon-agents/internal`): shrink the close deadlines. */
29
+ export function setHarnessCloseTimeoutsForTest(ms) {
30
+ if (ms.runSettle !== undefined)
31
+ CLOSE_RUN_WAIT_MS = ms.runSettle;
32
+ if (ms.scopeDispose !== undefined)
33
+ SCOPE_DISPOSE_TIMEOUT_MS = ms.scopeDispose;
34
+ }
22
35
  /** Rendezvous budget for `replaceExtension` when the caller names none. */
23
36
  const DEFAULT_REPLACE_TIMEOUT_MS = 30_000;
24
37
  /** Session-store key holding the per-session extension params map (see `createSession({ params })`). */
@@ -153,17 +166,29 @@ export class HarnessSession {
153
166
  responder;
154
167
  onClosed;
155
168
  runContext;
156
- currentRun;
157
169
  /** Barrier gate (docs/architecture.md §5.5): while set, no NEW run starts on this
158
170
  * session — prompt/resume park at it, promptStream starts lazily behind it. The in-flight
159
171
  * run is never touched. */
160
172
  runGate;
161
- /** Resolves when the latest run settles — the "arrived at the boundary" signal a barrier
162
- * coordinator awaits. Undefined while idle. */
163
- runSettled;
164
- runSettles = new Map();
173
+ /**
174
+ * Every run this facade has begun and not yet seen settle, by its controller. More than one
175
+ * entry means runs are queued behind the core's run lock (`prompt` while a turn is in flight):
176
+ * "busy" is this set being non-empty, `cancel()` aborts ALL of them, and the barrier's
177
+ * quiescence is ALL of them settling — tracking only the last-begun run would abort the
178
+ * queued one and leave the running one untouched.
179
+ */
180
+ runs = new Map();
165
181
  lastRunInterrupted = false;
166
- closed = false;
182
+ /** open → closing (runs being drained, resources going down) → closed. Never goes back. */
183
+ lifecycle = "open";
184
+ /** The one close in flight (or finished): every `close()` call returns THIS promise. */
185
+ closing;
186
+ /** Resolves the moment close() begins — what wakes a caller parked at the barrier gate so it
187
+ * can fail with the closed error instead of hanging on a gate nobody will release. */
188
+ notifyClosing;
189
+ closingNotified = new Promise((resolve) => {
190
+ this.notifyClosing = resolve;
191
+ });
167
192
  deliveryCounter = 0;
168
193
  /** A wake is already scheduled on the microtask queue; more enqueues fold into it. */
169
194
  wakeScheduled = false;
@@ -193,7 +218,7 @@ export class HarnessSession {
193
218
  * Start a turn to consume queued follow-ups, once the current synchronous work is done.
194
219
  *
195
220
  * Deferred to a microtask for two reasons: several enqueues in one stack fold into a single
196
- * wake, and a settle that lands in a finishing run's own stack sees `currentRun` already
221
+ * wake, and a settle that lands in a finishing run's own stack sees its run already
197
222
  * cleared. Re-checked at fire time because either can change in between — and re-checked
198
223
  * again after every run, since the queue may have filled during one whose drain had passed.
199
224
  */
@@ -211,7 +236,7 @@ export class HarnessSession {
211
236
  return;
212
237
  // A run in flight drains at its own turn boundary; an interrupted one must be resumed,
213
238
  // not talked over.
214
- if (this.currentRun !== undefined)
239
+ if (this.hasActiveRuns())
215
240
  return;
216
241
  // Either channel counts. A "steering" item is an interruption of work in flight, but with
217
242
  // nothing in flight there is nothing to interrupt — it is simply the next input, and the
@@ -223,6 +248,22 @@ export class HarnessSession {
223
248
  void this.runPrompt([]).catch(() => undefined);
224
249
  });
225
250
  }
251
+ /** Not open: closing or closed. Every run entry point refuses from here on. */
252
+ get closed() {
253
+ return this.lifecycle !== "open";
254
+ }
255
+ /**
256
+ * Park at the barrier gate until released — or until this session starts closing, in which
257
+ * case fail with the closed error rather than wait on a gate no coordinator will release.
258
+ * Re-checked in a loop: a new hold can land between a release and this task resuming.
259
+ */
260
+ async parkAtGate() {
261
+ while (this.runGate !== undefined) {
262
+ await Promise.race([this.runGate.released, this.closingNotified]);
263
+ if (this.closed)
264
+ throw new Error(`session "${this.id}" is closed`);
265
+ }
266
+ }
226
267
  /** Run options every run on this session carries — currently the session's turn cap. */
227
268
  get runOverrides() {
228
269
  return this.maxTurns !== undefined ? { maxTurns: this.maxTurns } : {};
@@ -235,7 +276,7 @@ export class HarnessSession {
235
276
  return {
236
277
  state: this.closed
237
278
  ? "closed"
238
- : this.currentRun !== undefined
279
+ : this.hasActiveRuns()
239
280
  ? "running"
240
281
  : this.lastRunInterrupted
241
282
  ? "interrupted"
@@ -258,16 +299,23 @@ export class HarnessSession {
258
299
  }
259
300
  // ── Run driving (the methods core Session does not have) ──
260
301
  beginRun(controller) {
261
- this.currentRun = controller;
262
- this.runSettled = new Promise((resolve) => {
263
- this.runSettles.set(controller, resolve);
302
+ let resolve;
303
+ const settled = new Promise((r) => {
304
+ resolve = r;
264
305
  });
306
+ this.runs.set(controller, { settled, resolve });
265
307
  }
266
308
  endRun(controller) {
267
- if (this.currentRun === controller)
268
- this.currentRun = undefined;
269
- this.runSettles.get(controller)?.();
270
- this.runSettles.delete(controller);
309
+ this.runs.get(controller)?.resolve();
310
+ this.runs.delete(controller);
311
+ }
312
+ /** A run is in flight or queued behind the core's run lock. */
313
+ hasActiveRuns() {
314
+ return this.runs.size > 0;
315
+ }
316
+ /** Settles when every run begun so far has settled (trivially, when idle). */
317
+ allRunsSettled() {
318
+ return Promise.all([...this.runs.values()].map((run) => run.settled)).then(() => undefined);
271
319
  }
272
320
  /**
273
321
  * Barrier support (docs/architecture.md §5.5): refuse NEW runs until released and
@@ -284,7 +332,7 @@ export class HarnessSession {
284
332
  });
285
333
  const gate = { released, release: releaseGate };
286
334
  this.runGate = gate;
287
- const quiescent = this.currentRun !== undefined ? (this.runSettled ?? Promise.resolve()) : Promise.resolve();
335
+ const quiescent = this.allRunsSettled();
288
336
  return {
289
337
  quiescent,
290
338
  release: () => {
@@ -303,10 +351,11 @@ export class HarnessSession {
303
351
  async runPrompt(input, inputOrigin) {
304
352
  if (this.closed)
305
353
  throw new Error(`session "${this.id}" is closed`);
306
- // Barrier gate: park (never fail) until released. A loop, not an if — a new hold can land
307
- // between the release and this task resuming.
308
- while (this.runGate !== undefined)
309
- await this.runGate.released;
354
+ // Barrier gate: park until released (a hold is "wait", not "fail") — unless the session
355
+ // closes meanwhile, which is the one thing that turns the wait into a failure. Ungated, the
356
+ // run is registered SYNCHRONOUSLY: a cancel() or close() on the next line must find it.
357
+ if (this.runGate !== undefined)
358
+ await this.parkAtGate();
310
359
  const controller = new AbortController();
311
360
  this.beginRun(controller);
312
361
  try {
@@ -330,7 +379,7 @@ export class HarnessSession {
330
379
  finally {
331
380
  this.endRun(controller);
332
381
  // A follow-up can land after this run's last drain but before it settles — that enqueue
333
- // saw `currentRun` set and stood down, so the run itself has to look once on the way out.
382
+ // saw an active run and stood down, so the run itself has to look once on the way out.
334
383
  this.scheduleIdleWake();
335
384
  }
336
385
  }
@@ -343,7 +392,7 @@ export class HarnessSession {
343
392
  if (this.closed || this.lastRunInterrupted)
344
393
  return false;
345
394
  // A run in flight drains its own queues, so handing the message to the frame is enough.
346
- if (this.currentRun !== undefined)
395
+ if (this.hasActiveRuns())
347
396
  return this.core.steerTo(address, content, origin);
348
397
  // Idle: only the root frame can be woken. A subagent whose frame has ended is not a teammate
349
398
  // waiting for mail — it is a finished delegation, and only the parent that delegated it can
@@ -375,7 +424,7 @@ export class HarnessSession {
375
424
  async deliver(input, options) {
376
425
  if (this.closed)
377
426
  throw new Error(`session "${this.id}" is closed`);
378
- if (this.lastRunInterrupted && this.currentRun === undefined) {
427
+ if (this.lastRunInterrupted && !this.hasActiveRuns()) {
379
428
  throw new Error(`session "${this.id}" is interrupted; resume it before delivering new work`);
380
429
  }
381
430
  if (!options.source.trim())
@@ -438,7 +487,7 @@ export class HarnessSession {
438
487
  const channel = origin.kind === "external"
439
488
  ? origin.channel ?? "steering"
440
489
  : origin.kind === "user_follow_up" ? "follow_up" : "steering";
441
- if (this.currentRun !== undefined) {
490
+ if (this.hasActiveRuns()) {
442
491
  const receipt = this.core.steer.steer(input, origin);
443
492
  return {
444
493
  deliveryId,
@@ -491,11 +540,13 @@ export class HarnessSession {
491
540
  * copy see a "stale interruption state" error; re-fetching the persisted state clears it.
492
541
  */
493
542
  async resume(answers) {
543
+ if (this.closed)
544
+ throw new Error(`session "${this.id}" is closed`);
494
545
  // Barrier gate first (a coordinator holding the barrier should not be resumed against —
495
- // the call parks here until release rather than failing).
496
- while (this.runGate !== undefined)
497
- await this.runGate.released;
498
- if (this.currentRun !== undefined)
546
+ // the call parks here until release rather than failing; closing meanwhile does fail it).
547
+ if (this.runGate !== undefined)
548
+ await this.parkAtGate();
549
+ if (this.hasActiveRuns())
499
550
  throw new Error(`session "${this.id}" already has an active run`);
500
551
  const controller = new AbortController();
501
552
  // Set this BEFORE the first await (past the gate). A host returning 202 from resume can
@@ -535,6 +586,8 @@ export class HarnessSession {
535
586
  }
536
587
  /** Streaming variant: iterate events, await `.completed` for the result. */
537
588
  promptStream(input) {
589
+ if (this.closed)
590
+ throw new Error(`session "${this.id}" is closed`);
538
591
  const controller = new AbortController();
539
592
  // Ungated: start synchronously, byte-identical to the pre-barrier behavior. Gated: the
540
593
  // handle returns immediately but the underlying run starts only after release (lazy start —
@@ -544,8 +597,7 @@ export class HarnessSession {
544
597
  this.beginRun(controller);
545
598
  const start = (async () => {
546
599
  if (gated) {
547
- while (this.runGate !== undefined)
548
- await this.runGate.released;
600
+ await this.parkAtGate();
549
601
  this.beginRun(controller);
550
602
  }
551
603
  return this.runner.runStream(this.agent, input, {
@@ -571,6 +623,9 @@ export class HarnessSession {
571
623
  })
572
624
  .finally(() => {
573
625
  this.endRun(controller);
626
+ // Same as runPrompt's exit: input queued after this run's last drain (a background
627
+ // notification landing at agent.ended) needs a wake, or it sits until the next prompt.
628
+ this.scheduleIdleWake();
574
629
  });
575
630
  // Guard the floating promise so iterate-only callers don't trip an unhandled rejection
576
631
  // (mirrors runStream's own internal guard); awaiters still receive a real rejection.
@@ -619,13 +674,14 @@ export class HarnessSession {
619
674
  * active run — use `prompt` instead in that case.
620
675
  */
621
676
  followUp(input) {
622
- if (this.currentRun === undefined)
677
+ if (!this.hasActiveRuns())
623
678
  return null;
624
679
  return this.core.steer.followUp(input).steerId;
625
680
  }
626
- /** Abort the current run. */
681
+ /** Abort every run this session has accepted: the one in flight AND any queued behind it. */
627
682
  cancel() {
628
- this.currentRun?.abort();
683
+ for (const controller of [...this.runs.keys()])
684
+ controller.abort();
629
685
  }
630
686
  /** Subscribe to this session's event stream. Returns an unsubscribe fn. */
631
687
  onEvent(listener) {
@@ -833,15 +889,50 @@ export class HarnessSession {
833
889
  reconnectMcpServer(...args) {
834
890
  return this.core.reconnectMcpServer(...args);
835
891
  }
836
- /** Close the session and release its resources. */
837
- async close() {
838
- if (this.closed)
839
- return;
840
- this.closed = true;
841
- this.cancel();
842
- this.projection.detach();
843
- await this.core.close();
844
- await this.onClosed(this.id);
892
+ /**
893
+ * Close the session and release its resources. Order: stop accepting runs, abort the ones
894
+ * accepted (in flight and queued) and wait for them to settle — their tools' finally blocks,
895
+ * their journal writes — then detach the projection and take the core down. Every call
896
+ * returns the SAME promise, so nobody returns while the teardown is still running.
897
+ *
898
+ * The wait for runs is bounded (`CLOSE_RUN_WAIT_MS`, 5s): a run that ignores its abort signal
899
+ * is logged and left behind, and the teardown continues — the deadline caps the wait for
900
+ * runs, not the whole close (flushes and disposes carry their own deadlines).
901
+ */
902
+ close() {
903
+ if (this.closing !== undefined)
904
+ return this.closing;
905
+ this.lifecycle = "closing";
906
+ this.notifyClosing();
907
+ this.closing = this.runClose();
908
+ return this.closing;
909
+ }
910
+ async runClose() {
911
+ try {
912
+ const settled = this.allRunsSettled();
913
+ this.cancel();
914
+ let timer;
915
+ const timedOut = await Promise.race([
916
+ settled.then(() => false),
917
+ new Promise((resolve) => {
918
+ timer = setTimeout(() => resolve(true), CLOSE_RUN_WAIT_MS);
919
+ }),
920
+ ]);
921
+ clearTimeout(timer);
922
+ if (timedOut) {
923
+ this.core.logger.log("warn", "session close: runs still settling after the deadline; continuing teardown", {
924
+ sessionId: this.id,
925
+ runs: this.runs.size,
926
+ deadlineMs: CLOSE_RUN_WAIT_MS,
927
+ });
928
+ }
929
+ this.projection.detach();
930
+ await this.core.close();
931
+ await this.onClosed(this.id);
932
+ }
933
+ finally {
934
+ this.lifecycle = "closed";
935
+ }
845
936
  }
846
937
  }
847
938
  /** Manages a fleet of in-process sessions over a shared engine config. */
@@ -851,6 +942,11 @@ export class Harness {
851
942
  /** Concrete tools the default profile's tool NAMES resolve against (keyed by schema name). */
852
943
  toolPalette;
853
944
  activeSessions = new Map();
945
+ /** Opens in flight by session id (`resumeSession`): concurrent callers share ONE open — the
946
+ * whole of it, reconcile and failure cleanup included — instead of each building an instance. */
947
+ opening = new Map();
948
+ /** The one close in flight (or finished): every `close()` call returns THIS promise. */
949
+ closing;
854
950
  /** The harness-tier scope: every process-lived object, and the parent of every workspace scope. */
855
951
  scope;
856
952
  /** Workspace scopes by key, reference-counted by the sessions open under them. */
@@ -1070,36 +1166,91 @@ export class Harness {
1070
1166
  return this.activeSessions;
1071
1167
  }
1072
1168
  async createSession(opts = {}) {
1169
+ this.assertAcceptingSessions();
1073
1170
  const workDir = opts.workDir ?? this.options.workDir ?? process.cwd();
1074
1171
  const handle = await (await this.repository()).create({ id: opts.id, workDir, ownerKey: opts.ownerKey, title: opts.title });
1075
- return this.openFromStore(handle.id, handle.workDir, handle.store, opts);
1172
+ return this.trackOpen(handle.id, () => this.openFromStore(handle.id, handle.workDir, handle.store, opts));
1173
+ }
1174
+ /**
1175
+ * Register an open in flight under its id for as long as it runs. Every path that builds a
1176
+ * session goes through here — create, resume, fork — so a `resumeSession` for an id that is
1177
+ * being created joins that open instead of building a twin over the same store, and
1178
+ * `closeSession` / `harness.close()` wait for it instead of closing its scope under it.
1179
+ */
1180
+ trackOpen(id, open) {
1181
+ const promise = open().finally(() => {
1182
+ if (this.opening.get(id) === promise)
1183
+ this.opening.delete(id);
1184
+ });
1185
+ this.opening.set(id, promise);
1186
+ return promise;
1076
1187
  }
1077
- async resumeSession(id, opts = {}) {
1188
+ /**
1189
+ * The open session with this id, or reopen it from the store. Concurrent calls for one id share
1190
+ * a single open (`opening`): the second caller waits for the first's — reconcile included — and
1191
+ * receives the same instance, never a second one that a close would then unregister. An
1192
+ * instance that is closing is not handed out: the reopen waits for its close to finish first.
1193
+ */
1194
+ resumeSession(id, opts = {}) {
1195
+ // Not async (the in-flight/active fast paths return existing promises), so refuse by rejecting.
1196
+ if (this.closing !== undefined)
1197
+ return Promise.reject(new Error("harness is closed"));
1198
+ const inflight = this.opening.get(id);
1199
+ if (inflight !== undefined) {
1200
+ return hasOwnContext(opts)
1201
+ ? inflight.then((session) => {
1202
+ session.setContext(opts.context);
1203
+ return session;
1204
+ })
1205
+ : inflight;
1206
+ }
1078
1207
  const existing = this.activeSessions.get(id);
1079
- if (existing) {
1208
+ if (existing !== undefined && existing.status.state !== "closed") {
1080
1209
  if (hasOwnContext(opts))
1081
1210
  existing.setContext(opts.context);
1082
- return existing;
1211
+ return Promise.resolve(existing);
1083
1212
  }
1084
- const handle = await (await this.repository()).open(id);
1085
- if (handle === undefined)
1086
- throw new SessionRepositoryNotFoundError(id);
1087
- const session = await this.openFromStore(handle.id, handle.workDir, handle.store, opts);
1088
- // Reopening: orphaned background subagents (running from a dead process) → lost.
1089
- await session.reconcileSubagents();
1090
- return session;
1213
+ return this.trackOpen(id, async () => {
1214
+ // `existing` here is one that is closing: wait for that close (its registration and
1215
+ // workspace hold go with it) rather than open a twin beside it.
1216
+ if (existing !== undefined)
1217
+ await existing.close();
1218
+ const handle = await (await this.repository()).open(id);
1219
+ if (handle === undefined)
1220
+ throw new SessionRepositoryNotFoundError(id);
1221
+ const session = await this.openFromStore(handle.id, handle.workDir, handle.store, opts);
1222
+ // Reopening: orphaned background subagents (running from a dead process) → lost. A failure
1223
+ // here is an open failure: the session is torn down, not left registered and unreturned.
1224
+ try {
1225
+ await session.reconcileSubagents();
1226
+ }
1227
+ catch (error) {
1228
+ await session.close();
1229
+ throw error;
1230
+ }
1231
+ return session;
1232
+ });
1091
1233
  }
1092
1234
  /** Fork an existing session into a new one (copies its log + state) and open it. */
1093
1235
  async forkSession(sourceId, opts = {}) {
1236
+ this.assertAcceptingSessions();
1094
1237
  const handle = await (await this.repository()).fork(sourceId, {
1095
1238
  ...(opts.id !== undefined ? { id: opts.id } : {}),
1096
1239
  ...(opts.ownerKey !== undefined ? { ownerKey: opts.ownerKey } : {}),
1097
1240
  ...(opts.title !== undefined ? { title: opts.title } : {}),
1098
1241
  });
1099
- const session = await this.openFromStore(handle.id, handle.workDir, handle.store, opts);
1100
- // A fork copies the subagent ledger; its background "running" rows have no live task → lost.
1101
- await session.reconcileSubagents();
1102
- return session;
1242
+ return this.trackOpen(handle.id, async () => {
1243
+ const session = await this.openFromStore(handle.id, handle.workDir, handle.store, opts);
1244
+ // A fork copies the subagent ledger; its background "running" rows have no live task → lost.
1245
+ try {
1246
+ await session.reconcileSubagents();
1247
+ }
1248
+ catch (error) {
1249
+ await session.close();
1250
+ throw error;
1251
+ }
1252
+ return session;
1253
+ });
1103
1254
  }
1104
1255
  /** The currently-open session with this id, or undefined (does not reopen — use resumeSession). */
1105
1256
  getSession(id) {
@@ -1115,8 +1266,15 @@ export class Harness {
1115
1266
  async getSessionSummary(id) {
1116
1267
  return (await this.repository()).get(id);
1117
1268
  }
1118
- /** Close one open session by id (no-op if it isn't open). */
1269
+ /** Close one open session by id (no-op if it isn't open). An open in flight for this id is
1270
+ * waited for and then closed, so a close/delete never races a half-built session. */
1119
1271
  async closeSession(id) {
1272
+ const inflight = this.opening.get(id);
1273
+ if (inflight !== undefined) {
1274
+ const session = await inflight.catch(() => undefined);
1275
+ await session?.close();
1276
+ return;
1277
+ }
1120
1278
  await this.activeSessions.get(id)?.close();
1121
1279
  }
1122
1280
  /**
@@ -1140,12 +1298,27 @@ export class Harness {
1140
1298
  * drained, then its dispose hook — default the instance's close()), then the host's own
1141
1299
  * registrations, then the defaults.
1142
1300
  */
1143
- async close() {
1301
+ close() {
1302
+ if (this.closing !== undefined)
1303
+ return this.closing;
1304
+ this.closing = this.runClose();
1305
+ return this.closing;
1306
+ }
1307
+ async runClose() {
1308
+ // Opens in flight finish (or fail) first: closing the scope tree under a half-built session
1309
+ // would tear its scope out from under `Session.open`. New opens are refused from here on
1310
+ // (`assertAcceptingSessions`), so the set only shrinks.
1311
+ await Promise.allSettled([...this.opening.values()]);
1144
1312
  for (const session of [...this.activeSessions.values()])
1145
1313
  await session.close();
1146
1314
  await this.sharedReady.catch(() => undefined);
1147
1315
  this.createdServices.length = 0;
1148
- await this.scope.close();
1316
+ await this.scope.close(this.scopeCloseOptions());
1317
+ }
1318
+ /** Every path that opens a session starts here: a closing harness opens nothing new. */
1319
+ assertAcceptingSessions() {
1320
+ if (this.closing !== undefined)
1321
+ throw new Error("harness is closed");
1149
1322
  }
1150
1323
  /**
1151
1324
  * Replace by-value extensions with new versions, as one coordinated act — the by-value twin of
@@ -1401,88 +1574,126 @@ export class Harness {
1401
1574
  // The workspace scope (one per key, shared) and under it the session scope: what the harness
1402
1575
  // decides for this session goes in here; Session.open provides the defaults for the rest,
1403
1576
  // and from then on the session owns the scope.
1577
+ // From here the open is a transaction: the session is registered as active only once every
1578
+ // step has succeeded, and any failure tears down exactly what was built — projection, the
1579
+ // session scope (via the core when it got that far), the workspace hold — before rethrowing.
1580
+ // Half-open leftovers are what kept a workspace alive after its last real session closed.
1404
1581
  const workspace = await this.acquireWorkspace(workspaceKey, workDir);
1405
1582
  let scope;
1583
+ let projection;
1584
+ let core;
1406
1585
  try {
1407
1586
  scope = workspace.child("session");
1587
+ scope.register(T.SessionId, id);
1588
+ scope.register(T.StoreBackend, store, { owned: false }); // the repository owns the store's lifetime
1589
+ const events = new ListenerSink();
1590
+ scope.register(T.Events, events);
1591
+ const responder = new MutableResponder();
1592
+ scope.register(T.Responder, responder);
1593
+ // Machine: this call's override → the harness-level factory (resolved by Session.open) →
1594
+ // a LocalMachine at the session's own workDir. The session only OPERATES a caller-supplied
1595
+ // machine; the default one is its own.
1596
+ if (opts.machine !== undefined) {
1597
+ if (typeof opts.machine === "function")
1598
+ scope.register(T.SessionMachineFactory, opts.machine);
1599
+ else
1600
+ scope.register(T.Machine, opts.machine, { owned: false });
1601
+ }
1602
+ else if (!workspace.has(T.WorkspaceMachineFactory) && !this.scope.has(T.MachineFactory)) {
1603
+ scope.provide(T.Machine, () => new LocalMachine(workDir));
1604
+ }
1605
+ scope.register(T.PermissionOptions, opts.permission ?? this.options.permission ?? { mode: "yolo" });
1606
+ if (opts.eventPublication !== undefined)
1607
+ scope.register(T.SessionEventPublication, opts.eventPublication);
1608
+ // ONE open-time log read, shared: the projection seeds from it, and Session.open
1609
+ // receives it as `preloadedLog` so its capability restore + context pre-build fold the
1610
+ // same records — same IO, and the same `Message` objects (no second parsed copy).
1611
+ // Attach still happens BEFORE Session.open: no events flow yet, so the seed cannot
1612
+ // race the subscription, and anything capabilities emit during open is already folded.
1613
+ let preloadedLog = await readLog(store);
1614
+ // A fresh session has an empty log; anything else is a reopen. Telemetry is the only reader.
1615
+ const resumed = preloadedLog.length > 0;
1616
+ projection = await SessionProjection.attach({ id, store, events }, preloadedLog);
1617
+ const interrupted = await store.getState(INTERRUPTION_STATE_KEY) !== null;
1618
+ if (!interrupted) {
1619
+ const recoveryRecords = await closeOrphanedProjectionFrames(id, store, projection);
1620
+ if (recoveryRecords.length > 0)
1621
+ preloadedLog = [...preloadedLog, ...recoveryRecords];
1622
+ }
1623
+ const capabilities = await this.resolveCapabilities(scope, {
1624
+ sessionId: id,
1625
+ workDir,
1626
+ ownMachine: opts.machine !== undefined,
1627
+ ...(opts.mcpServers !== undefined ? { mcpServers: opts.mcpServers } : {}),
1628
+ }, params);
1629
+ core = await Session.open(scope, { capabilities, preloadedLog, resumed });
1630
+ const agent = opts.agent ??
1631
+ this.options.agent ??
1632
+ (await this.buildDefaultAgent(opts.appendSystemPrompt, opts.maxStepsPerTurn));
1633
+ const session = new HarnessSession({
1634
+ core,
1635
+ // The two caps land in different places: maxStepsPerTurn is an Agent property
1636
+ // (the Runner reads the agent's value first), maxTurns is a per-run option.
1637
+ agent,
1638
+ runner: this.runner,
1639
+ events,
1640
+ responder,
1641
+ projection,
1642
+ workDir,
1643
+ context: this.contextFor(opts),
1644
+ ...(opts.maxTurns ?? this.options.maxTurns) !== undefined
1645
+ ? { maxTurns: opts.maxTurns ?? this.options.maxTurns }
1646
+ : {},
1647
+ interrupted,
1648
+ onClosed: async (sid) => {
1649
+ // Only THIS instance's registration goes — a later open under the same id (a resume
1650
+ // that waited for this close) must keep its own. The workspace hold is this
1651
+ // instance's regardless and is always returned.
1652
+ if (this.activeSessions.get(sid) === session)
1653
+ this.activeSessions.delete(sid);
1654
+ await this.releaseWorkspace(workspaceKey);
1655
+ },
1656
+ });
1657
+ this.activeSessions.set(id, session);
1658
+ // Born gated: a session appearing while a reshape barrier holds (createSession,
1659
+ // resumeSession and openSession all land here) must not start runs mid-swap. Its
1660
+ // quiescent is trivially settled — it has no run — so it never delays the rendezvous.
1661
+ if (this.activeBarrier !== undefined)
1662
+ this.activeBarrier.holds.set(id, session.holdAtBoundary());
1663
+ return session;
1408
1664
  }
1409
1665
  catch (error) {
1410
- await this.releaseWorkspace(workspaceKey);
1666
+ await this.rollbackOpen(id, workspaceKey, { projection, core, scope }, error);
1411
1667
  throw error;
1412
1668
  }
1413
- scope.register(T.SessionId, id);
1414
- scope.register(T.StoreBackend, store, { owned: false }); // the repository owns the store's lifetime
1415
- const events = new ListenerSink();
1416
- scope.register(T.Events, events);
1417
- const responder = new MutableResponder();
1418
- scope.register(T.Responder, responder);
1419
- // Machine: this call's override → the harness-level factory (resolved by Session.open) →
1420
- // a LocalMachine at the session's own workDir. The session only OPERATES a caller-supplied
1421
- // machine; the default one is its own.
1422
- if (opts.machine !== undefined) {
1423
- if (typeof opts.machine === "function")
1424
- scope.register(T.SessionMachineFactory, opts.machine);
1425
- else
1426
- scope.register(T.Machine, opts.machine, { owned: false });
1427
- }
1428
- else if (!workspace.has(T.WorkspaceMachineFactory) && !this.scope.has(T.MachineFactory)) {
1429
- scope.provide(T.Machine, () => new LocalMachine(workDir));
1430
- }
1431
- scope.register(T.PermissionOptions, opts.permission ?? this.options.permission ?? { mode: "yolo" });
1432
- if (opts.eventPublication !== undefined)
1433
- scope.register(T.SessionEventPublication, opts.eventPublication);
1434
- // ONE open-time log read, shared: the projection seeds from it, and Session.open
1435
- // receives it as `preloadedLog` so its capability restore + context pre-build fold the
1436
- // same records — same IO, and the same `Message` objects (no second parsed copy).
1437
- // Attach still happens BEFORE Session.open: no events flow yet, so the seed cannot
1438
- // race the subscription, and anything capabilities emit during open is already folded.
1439
- let preloadedLog = await readLog(store);
1440
- // A fresh session has an empty log; anything else is a reopen. Telemetry is the only reader.
1441
- const resumed = preloadedLog.length > 0;
1442
- const projection = await SessionProjection.attach({ id, store, events }, preloadedLog);
1443
- const interrupted = await store.getState(INTERRUPTION_STATE_KEY) !== null;
1444
- if (!interrupted) {
1445
- const recoveryRecords = await closeOrphanedProjectionFrames(id, store, projection);
1446
- if (recoveryRecords.length > 0)
1447
- preloadedLog = [...preloadedLog, ...recoveryRecords];
1448
- }
1449
- const capabilities = await this.resolveCapabilities(scope, {
1450
- sessionId: id,
1451
- workDir,
1452
- ownMachine: opts.machine !== undefined,
1453
- ...(opts.mcpServers !== undefined ? { mcpServers: opts.mcpServers } : {}),
1454
- }, params);
1455
- const core = await Session.open(scope, { capabilities, preloadedLog, resumed });
1456
- const agent = opts.agent ??
1457
- this.options.agent ??
1458
- (await this.buildDefaultAgent(opts.appendSystemPrompt, opts.maxStepsPerTurn));
1459
- const session = new HarnessSession({
1460
- core,
1461
- // The two caps land in different places: maxStepsPerTurn is an Agent property
1462
- // (the Runner reads the agent's value first), maxTurns is a per-run option.
1463
- agent,
1464
- runner: this.runner,
1465
- events,
1466
- responder,
1467
- projection,
1468
- workDir,
1469
- context: this.contextFor(opts),
1470
- ...(opts.maxTurns ?? this.options.maxTurns) !== undefined
1471
- ? { maxTurns: opts.maxTurns ?? this.options.maxTurns }
1472
- : {},
1473
- interrupted,
1474
- onClosed: async (sid) => {
1475
- this.activeSessions.delete(sid);
1476
- await this.releaseWorkspace(workspaceKey);
1477
- },
1478
- });
1479
- this.activeSessions.set(id, session);
1480
- // Born gated: a session appearing while a reshape barrier holds (createSession,
1481
- // resumeSession and openSession all land here) must not start runs mid-swap. Its
1482
- // quiescent is trivially settled — it has no run — so it never delays the rendezvous.
1483
- if (this.activeBarrier !== undefined)
1484
- this.activeBarrier.holds.set(id, session.holdAtBoundary());
1485
- return session;
1669
+ }
1670
+ /**
1671
+ * Undo a failed `openFromStore`, newest artifact first. Every step runs even if an earlier one
1672
+ * throws (a cleanup failure is logged, never allowed to mask the open's own error), and the
1673
+ * workspace hold this open took is always returned.
1674
+ */
1675
+ async rollbackOpen(id, workspaceKey, built, cause) {
1676
+ const logger = this.scope.get(T.Logger);
1677
+ const attempt = async (step, run) => {
1678
+ try {
1679
+ await run();
1680
+ }
1681
+ catch (error) {
1682
+ logger?.log("warn", `session open rollback: ${step} failed`, {
1683
+ sessionId: id,
1684
+ step,
1685
+ error: error instanceof Error ? error.message : String(error),
1686
+ cause: cause instanceof Error ? cause.message : String(cause),
1687
+ });
1688
+ }
1689
+ };
1690
+ await attempt("detach projection", () => built.projection?.detach());
1691
+ // The core owns the scope once it opened; before that the scope is ours to close.
1692
+ if (built.core !== undefined)
1693
+ await attempt("close core", () => built.core.close());
1694
+ else if (built.scope !== undefined)
1695
+ await attempt("close scope", () => built.scope.close(this.scopeCloseOptions(id)));
1696
+ await attempt("release workspace", () => this.releaseWorkspace(workspaceKey));
1486
1697
  }
1487
1698
  /**
1488
1699
  * The workspace scope for `key`, composed on first use — the host's `workspace` hook, then
@@ -1520,7 +1731,21 @@ export class Harness {
1520
1731
  if (entry.refs > 0)
1521
1732
  return;
1522
1733
  this.workspaces.delete(key);
1523
- await entry.scope.close();
1734
+ await entry.scope.close(this.scopeCloseOptions(undefined, key));
1735
+ }
1736
+ /** Close options for a scope the harness tears down itself: per-service deadline, failures logged. */
1737
+ scopeCloseOptions(sessionId, workspaceKey) {
1738
+ return {
1739
+ disposeTimeoutMs: SCOPE_DISPOSE_TIMEOUT_MS,
1740
+ onDisposeError: (name, error) => {
1741
+ this.scope.get(T.Logger)?.log("warn", `service "${name}" dispose failed/timed out`, {
1742
+ service: name,
1743
+ ...(sessionId !== undefined ? { sessionId } : {}),
1744
+ ...(workspaceKey !== undefined ? { workspaceKey } : {}),
1745
+ error: error instanceof Error ? error.message : String(error),
1746
+ });
1747
+ },
1748
+ };
1524
1749
  }
1525
1750
  /**
1526
1751
  * A handle to a workspace-tier extension service in ONE workspace — how a host reaches a
@@ -6,4 +6,5 @@
6
6
  * No stability guarantees — prefer the main `operon-agents` surface.
7
7
  */
8
8
  export * from "operon-agents-core/internal";
9
+ export { setHarnessCloseTimeoutsForTest } from "./harness.ts";
9
10
  //# sourceMappingURL=internal.d.ts.map
package/dist/internal.js CHANGED
@@ -6,4 +6,5 @@
6
6
  * No stability guarantees — prefer the main `operon-agents` surface.
7
7
  */
8
8
  export * from "operon-agents-core/internal";
9
+ export { setHarnessCloseTimeoutsForTest } from "./harness.js";
9
10
  //# sourceMappingURL=internal.js.map
package/dist/local.js CHANGED
@@ -56,11 +56,13 @@ export async function localHarnessOptions(options) {
56
56
  await workspace?.(scope, ctx);
57
57
  // Skills follow the workspace's EXECUTION machine, not the host's disk: the catalog the
58
58
  // model sees must be the one whose scripts its Bash can reach. A remote workspace
59
- // registers its machine above and the scan runs through it. A machine FACTORY (one
59
+ // registers its machine above and the scan runs through it; absent that, the harness's
60
+ // default (`T.MachineFactory`, read through the parent chain) is what sessions here will
61
+ // execute on — the same precedence `Session.open` resolves. A machine FACTORY (one
60
62
  // machine per session) has no single filesystem to scan — no shared registry then; each
61
63
  // session scans through its own `T.Machine` (`defaultCapabilities` without `T.SkillRegistry`).
62
64
  if (!scope.hasLocal(T.SkillRegistry)) {
63
- const workspaceMachine = scope.get(T.WorkspaceMachineFactory) ?? new LocalMachine(ctx.workDir);
65
+ const workspaceMachine = scope.get(T.WorkspaceMachineFactory) ?? scope.get(T.MachineFactory) ?? new LocalMachine(ctx.workDir);
64
66
  if (typeof workspaceMachine !== "function") {
65
67
  const registry = new SkillRegistry();
66
68
  await loadSkillRoots(workspaceMachine, registry, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "operon-agents",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0-alpha.6",
4
4
  "description": "Public SDK entrypoint for building agents.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -72,7 +72,7 @@
72
72
  "dependencies": {
73
73
  "posthog-node": "^5.51.6",
74
74
  "zod": "^4.4.3",
75
- "operon-agents-core": "0.1.0-alpha.5"
75
+ "operon-agents-core": "0.1.0-alpha.6"
76
76
  },
77
77
  "devDependencies": {
78
78
  "@earendil-works/pi-ai": "0.84.1"