@automatalabs/acp-agents 0.35.3 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
+ import { WorkflowError, WorkflowErrorCode } from "@automatalabs/shared-types";
2
+ import { CustomAcpBackend } from "./backends/custom.js";
1
3
  import { isChildCleanupError, } from "./acp-client.js";
2
4
  import { mapThrownError } from "./errors-map.js";
3
- import { appendPromptImages, mergeTurnMeta, validatePromptImages, } from "./prompt.js";
5
+ import { appendPromptImages, buildRunPrompt, mergeTurnMeta, validatePromptImages, } from "./prompt.js";
4
6
  /** A held-open multi-turn ACP session backed by a dedicated agent process. Only one prompt may
5
7
  * be in flight at a time; hosts that want queued turns should serialize calls themselves so
6
8
  * cancellation, permissions, and turn text stay attributable to a single active turn. If the
@@ -20,8 +22,12 @@ export class InteractiveSession {
20
22
  signal;
21
23
  label;
22
24
  subscriptions = new Set();
25
+ /** The re-attach arm's release watchers (see `waitForRelease`). */
26
+ releaseWatchers = new Set();
23
27
  cwd;
24
28
  keepSession;
29
+ /** The session's structured-output contract (see `InteractiveSessionOptions.schema`). */
30
+ schema;
25
31
  removeAbort;
26
32
  promptInFlight = false;
27
33
  releasePromise;
@@ -38,6 +44,7 @@ export class InteractiveSession {
38
44
  this.label = deps.label;
39
45
  this.cwd = deps.cwd;
40
46
  this.keepSession = deps.keepSession;
47
+ this.schema = deps.schema;
41
48
  this.sessionId = deps.session.sessionId;
42
49
  this.backendId = deps.connection.backendId;
43
50
  if (deps.signal) {
@@ -63,6 +70,32 @@ export class InteractiveSession {
63
70
  get text() {
64
71
  return this.session.text;
65
72
  }
73
+ /** The latest turn's assistant text (turn-segmented, like `run()`'s
74
+ * no-schema result path). Added for the REPL broker's result shaping;
75
+ * additive passthrough to `SessionHandle`. */
76
+ currentTurnText() {
77
+ return this.session.currentTurnText();
78
+ }
79
+ /** The latest turn's FINAL assistant message (the schema-extraction
80
+ * source `run()` uses; prose extraction over the whole turn would
81
+ * resurrect the first-JSON-wins bug for schema-shaped progress
82
+ * messages). Added for the REPL broker's structured-output ladder;
83
+ * additive passthrough to `SessionHandle`. */
84
+ finalMessageText() {
85
+ return this.session.finalMessageText();
86
+ }
87
+ /** This session's structured-output contract (set at open via
88
+ * `InteractiveSessionOptions.schema`), or undefined for plain sessions. */
89
+ get outputSchema() {
90
+ return this.schema;
91
+ }
92
+ /** Claude's raw `structured_output` for the latest turn, if any (the
93
+ * native structured channel the runner's ladder tries first). Added
94
+ * for the REPL broker's structured-output ladder; additive passthrough
95
+ * to `SessionHandle`. */
96
+ rawStructuredOutput() {
97
+ return this.session.rawStructuredOutput();
98
+ }
66
99
  /** Message/tool history accumulated in this session's retained log. */
67
100
  get history() {
68
101
  return this.session.history;
@@ -70,7 +103,27 @@ export class InteractiveSession {
70
103
  /** Send one prompt turn. A concurrent prompt on the same InteractiveSession is rejected with a
71
104
  * clear host-side error; queueing is deliberately left to the host so turn boundaries remain
72
105
  * explicit. Per-turn images are appended only to this prompt, and SessionHandle.prompt()
73
- * performs capability adaptation before sending. */
106
+ * performs capability adaptation before sending.
107
+ *
108
+ * The `onHandoff` option is the host's explicit handoff acknowledgment: it fires exactly
109
+ * once the prompt has passed every preflight check (released session, aborted signal,
110
+ * prompt-in-flight, image validation) AND the underlying ACP session/prompt request has
111
+ * actually been invoked — the call below runs synchronously through request construction
112
+ * and the wire send, so by the time the acknowledgment fires the payload is on the wire:
113
+ * the point of no return. A host that records a "delivered" marker for the prompt (the
114
+ * REPL broker's queued-steer delivery marker) MUST record it here rather than when the
115
+ * returned promise is created: an async pre-handoff rejection (released session, aborted
116
+ * signal, or prompt-in-flight) never reaches this line, and a marker recorded before it
117
+ * would make a restore skip a turn that was never delivered. The acknowledgment firing
118
+ * AFTER the invocation is the crash-boundary contract (review regression: it used to
119
+ * fire BEFORE, so a crash in that interval left a durable "delivered" marker on a prompt
120
+ * the backend never received — and a restore then skipped a never-delivered turn): a
121
+ * crash before the acknowledgment leaves the prompt undelivered-in-the-store and a
122
+ * restore re-issues it (at-least-once); a crash after it would replay a turn that is
123
+ * already on the wire, which the marker's host prevents. A throwing callback aborts the
124
+ * turn — its error propagates through the normal mapping — but the backend prompt is
125
+ * ALREADY invoked at that point, so the turn is the host's delivery-failure path, never
126
+ * a not-sent turn. */
74
127
  async prompt(content, opts = {}) {
75
128
  if (this.releasePromise)
76
129
  throw new Error("InteractiveSession has been released");
@@ -81,9 +134,34 @@ export class InteractiveSession {
81
134
  validatePromptImages(opts.images, this.label);
82
135
  this.promptInFlight = true;
83
136
  try {
84
- const turnContent = appendPromptImages(content, opts.images);
85
- const promptMeta = mergeTurnMeta(opts.promptMeta, this.backend.promptMeta(undefined));
86
- const response = await this.session.prompt(turnContent, promptMeta);
137
+ // Same request shaping as run(): a schema-bearing generic backend whose agent may
138
+ // ignore the `_meta` forward gets the contract stated in-band; the backend-computed
139
+ // turn meta (e.g. Codex's outputSchema forward) merges UNDER the user meta so the
140
+ // schema channel is never clobbered.
141
+ const shaped = typeof content === "string" && this.schema !== undefined && this.backend.embedSchemaInPrompt
142
+ ? buildRunPrompt(content, {}, this.schema, this.backend)
143
+ : content;
144
+ const turnContent = appendPromptImages(shaped, opts.images);
145
+ const promptMeta = mergeTurnMeta(opts.promptMeta, this.backend.promptMeta(this.schema));
146
+ // The handoff acknowledgment (see the method docs above): fires only after the
147
+ // underlying ACP session/prompt request has been invoked — the call below runs
148
+ // synchronously through request construction and the wire send, so the payload is
149
+ // on the wire before the acknowledgment (review crash-boundary regression: the
150
+ // acknowledgment used to fire BEFORE the invocation, so a crash in that interval
151
+ // left a durable host "delivered" marker on a prompt the backend never received).
152
+ const responsePromise = this.session.prompt(turnContent, promptMeta);
153
+ try {
154
+ opts.onHandoff?.();
155
+ }
156
+ catch (error) {
157
+ // The prompt is already on the wire: the marker write failed (or the host's
158
+ // acknowledgment threw) AFTER the hand-off, so the turn is a delivery failure,
159
+ // not a not-sent turn. The abandoned response must not become an unhandled
160
+ // rejection in the host process.
161
+ responsePromise.catch(() => { });
162
+ throw error;
163
+ }
164
+ const response = await responsePromise;
87
165
  return {
88
166
  stopReason: response.stopReason,
89
167
  text: this.session.currentTurnText(),
@@ -156,6 +234,400 @@ export class InteractiveSession {
156
234
  return;
157
235
  await this.session.cancel();
158
236
  }
237
+ /**
238
+ * The loaded session's founding-turn completion — the re-attach arm's task
239
+ * source (phase D of the REPL orchestrator roadmap; the broker drives this
240
+ * on a session re-opened with `runner.loadSession()` after a daemon
241
+ * restart). Resolves with the turn that was in flight at the backend when
242
+ * the session was loaded, so a re-attached call's continuation fires
243
+ * exactly once, through the same record → settle → consume pump as a live
244
+ * call.
245
+ *
246
+ * **The authoritative-completion seam** (the spec-owed decision,
247
+ * documented here). Completion evidence comes from TWO channels, by
248
+ * backend class:
249
+ *
250
+ * 1. **The `_session/loaded_turn` vendor extension** (the steering-
251
+ * extension precedent; advertised at initialize
252
+ * (`_meta.loadedTurn.supported === true`), served by the in-repo
253
+ * `@automatalabs/pi-acp` and `@automatalabs/codex-acp`):
254
+ * `session/load` obliges the agent to replay the entire persisted
255
+ * conversation before resolving (the runner marks the LOAD BOUNDARY
256
+ * synchronously after the response), and the seam then asks the
257
+ * backend `_session/loaded_turn/query` whether the founding turn is
258
+ * still running RIGHT NOW. The backend answers with one of three
259
+ * terminal classifications:
260
+ *
261
+ * - **`running`** — the founding turn is still executing at the
262
+ * backend. The seam KEEPS THE LOADED SESSION ATTACHED and waits
263
+ * for the `_session/loaded_turn/ended` notification — the turn's
264
+ * authoritative terminal marker (a quiet gap is only a
265
+ * progress-stream gap, never terminal evidence; the notification
266
+ * fires when the turn ends, carrying the stop reason or the
267
+ * error). It absorbs the turn's live `session/update` stream
268
+ * meanwhile, so a completion settles with the turn's REAL
269
+ * accumulated text. The wait is bounded by
270
+ * `LOADED_TURN_MAX_WAIT_MS` (default 15 min;
271
+ * `AGENTPRISM_ACP_LOADED_TURN_MAX_WAIT_MS` — the "never hang
272
+ * unobserved" backstop); a bound expiry rejects with the
273
+ * `LoadedTurnStillRunningError` (the broker re-arms the wait on
274
+ * the still-attached session — the notification may still arrive
275
+ * later).
276
+ * - **`completed`** — no turn is running, and the founding turn
277
+ * observably completed while this host was down: the replay's
278
+ * trailing assistant message is its FINAL message, so the seam
279
+ * resolves with it IMMEDIATELY (`{ stopReason: "end_turn", text }`
280
+ * — the stop reason is synthesized because the protocol's replay
281
+ * carries none; the text is the turn's real accumulated outcome,
282
+ * and the broker's result-shaping ladder reads the same
283
+ * transcript). A backend that answers `completed` while the
284
+ * replay does NOT end with an assistant message contradicts
285
+ * itself — the final message is not in the replay, so the seam
286
+ * rejects with the safe-re-issue class.
287
+ * - **`interrupted`** — no turn is running, and the founding turn
288
+ * ended without a terminal assistant message (it was
289
+ * interrupted/failed/abandoned while the host was down). Its
290
+ * outcome is not observable, but nothing is running at the
291
+ * backend, so the seam rejects with the SAFE-RE-ISSUE class (the
292
+ * broker re-issues under the same call id — no duplication
293
+ * possible).
294
+ *
295
+ * A QUERY FAILURE (the capability gate or a wire error) is NOT the
296
+ * missing extension: the seam falls THROUGH to the observation path
297
+ * below instead of classifying unobservable (phase-F review round
298
+ * 2 — the loaded session may still be executing, and a
299
+ * possibly-running call is never released-and-re-issued).
300
+ *
301
+ * 2. **The observation path — backends WITHOUT the extension (the
302
+ * built-in claude and opencode backends today), and extension
303
+ * backends whose query failed.** The authoritative observation is
304
+ * the loaded session's OWN stream plus its replay, under the
305
+ * CONNECTION-DEATH CONTRACT (live-verified against the current
306
+ * built-in servers): every built-in ACP server terminates its
307
+ * sessions' in-flight turns when the client connection closes —
308
+ * claude-agent-acp and pi-acp exit on connection close and cancel
309
+ * their turns (`connection.closed.then(shutdown)` → teardown →
310
+ * cancel + kill), `opencode acp` exits on stdin EOF, and codex-acp
311
+ * ends/kills the codex process — and their persisted transcripts
312
+ * contain only COMPLETED messages. So after a daemon crash the
313
+ * founding turn is NEVER still running at the backend, and the
314
+ * replay's trailing content is authoritative: an assistant message
315
+ * is the turn's terminal message (completed while down); anything
316
+ * else means the turn died mid-way (interrupted — nothing running,
317
+ * safe to re-issue). The one caveat is the in-flight-wire race —
318
+ * content still streaming when the load response resolves — so the
319
+ * seam first runs a bounded POST-LOAD CONTINUATION WATCH
320
+ * (`LOADED_TURN_OBSERVE_MS`, default 1 s;
321
+ * `AGENTPRISM_ACP_LOADED_TURN_OBSERVE_MS`): any CONTENT update
322
+ * applied after the load boundary is LIVE CONTINUATION — the
323
+ * authoritative still-running signal — and flips the classification
324
+ * to the keep-attached wait (below). No content within the window
325
+ * → classify from the replay (completed / interrupted) — but ONLY
326
+ * on a VERIFIED BUILT-IN backend (`connectionDeathVerified`: the
327
+ * four built-in instances; a custom registry entry's
328
+ * connection-death behavior is not live-verified, so its quiet
329
+ * window is NOT terminal evidence — phase-F review round 3: the
330
+ * replay classification used to apply to every extension-less
331
+ * backend and every query failure, so a durable custom backend
332
+ * could have a still-running turn settled from stale/partial
333
+ * replay or re-issued, violating the no-duplicate invariant). The
334
+ * window
335
+ * is the spec-owed concrete decision replacing the rejected
336
+ * quiet-grace heuristic: the grace is bounded AND the classification
337
+ * rests on the connection-death contract, never on a quiet gap
338
+ * alone (phase-D review round 3 rejected the unbounded settle-from-
339
+ * trailing-chunk guess; a still-running turn's quiet parks are
340
+ * never settled here — a park produces no content, but for the
341
+ * built-ins no turn can be running at restore in the first place).
342
+ *
343
+ * **The keep-attached still-running wait** (both channels): the
344
+ * loaded session stays attached, the turn's live stream is absorbed,
345
+ * and the seam waits for the terminal state — the `_session/loaded_turn/ended`
346
+ * notification when the backend pushes one (an extension backend, or
347
+ * a seam-less backend that sends it anyway), the max-wait bound (the
348
+ * "never hang unobserved" backstop), or the session's release. A
349
+ * bound expiry rejects with the `LoadedTurnStillRunningError`, and
350
+ * the broker RE-ARMS the wait on the still-attached session (phase-F
351
+ * review round 2: a possibly-running call is never re-issued — the
352
+ * re-issue arm is reserved for observably-dead calls); a cancel or
353
+ * the broker's drain settles it.
354
+ *
355
+ * The unconditional arms stay: a handle that was never load-marked
356
+ * (not produced by the runner's `loadSession` path), and a transcript
357
+ * with no user message at all (the recorded session never received its
358
+ * prompt — nothing reached the backend), both reject immediately with
359
+ * the safe-re-issue class. A released/dead session mid-wait rejects
360
+ * through the same plain class (a dead process means the backend turn
361
+ * died with it — re-issue is safe; the broker's own teardown releases
362
+ * are handled by the broker's drain state).
363
+ */
364
+ async awaitCurrentTurn() {
365
+ if (this.releasePromise) {
366
+ throw new Error("InteractiveSession has been released");
367
+ }
368
+ const boundary = this.session.loadBoundaryState();
369
+ if (!boundary.marked) {
370
+ throw new Error("the loaded session's founding-turn boundary was not recorded at load " +
371
+ "— its completion is not observable over the ACP protocol; re-issue is the honest fallback");
372
+ }
373
+ if (!boundary.hasUserMessage) {
374
+ throw new Error("the loaded session's transcript shows no user message — the founding turn never reached the backend " +
375
+ "(its outcome is unobservable; re-issue is the honest fallback)");
376
+ }
377
+ const capabilities = this.connection.capabilities;
378
+ if (capabilities?.supportsLoadedTurnTerminalState === true) {
379
+ // Subscribe to the ended channel BEFORE the query: a turn that ends
380
+ // between the query response and this wait must not be missed (the
381
+ // notification is recorded on the session state, first-wins).
382
+ let status;
383
+ try {
384
+ status = (await this.connection.queryLoadedTurn(this.sessionId, this.label)).status;
385
+ }
386
+ catch (error) {
387
+ // The wire query failed (the capability gate or a wire error):
388
+ // the authoritative answer is unavailable, so the terminal state
389
+ // is classified by the OBSERVATION path instead (the same path a
390
+ // seam-less backend takes — the post-load continuation watch plus
391
+ // the replay probe under the connection-death contract). Phase-F
392
+ // review round 2: a query failure must NOT push the broker to
393
+ // release-and-re-issue — the loaded session may still be
394
+ // executing, and a possibly-running call is never re-issued.
395
+ return this.observeLoadedTurn(boundary, error);
396
+ }
397
+ if (status === "completed") {
398
+ if (boundary.trailingContentKind !== "assistant-message") {
399
+ // The backend claims the founding turn completed, but its final
400
+ // assistant message is not the replay's last content event: the
401
+ // outcome is not in the transcript. Nothing is running (the
402
+ // backend said so), so the safe-re-issue class applies.
403
+ throw new Error(`the loaded session's backend reported the founding turn completed, but the replayed transcript ` +
404
+ `does not end with an assistant message — the turn's final outcome is not in the replay; re-issue ` +
405
+ `is the honest fallback`);
406
+ }
407
+ // Completed-while-down: the replay's trailing assistant message IS
408
+ // the founding turn's final message (the backend's authoritative
409
+ // answer, not a quiet-gap guess). Settle from the transcript.
410
+ return { stopReason: "end_turn", text: this.session.loadedTurnText() };
411
+ }
412
+ if (status === "interrupted") {
413
+ throw new Error(`the loaded session's founding turn ended without a terminal assistant message (it was interrupted ` +
414
+ `or failed while this host was down) and no turn is running at the backend — its outcome is not ` +
415
+ `observable over the ACP protocol; re-issue is the honest fallback (nothing is running to duplicate)`);
416
+ }
417
+ // status === "running": the authoritative terminal wait. The turn is
418
+ // still executing at the backend; the loaded session stays attached
419
+ // and the seam waits for the `_session/loaded_turn/ended` notification
420
+ // (absorbing the turn's live update stream — the settle text is the
421
+ // accumulated transcript at the notification, the turn's REAL outcome).
422
+ return this.waitForRunningLoadedTurn(loadedTurnMaxWaitMs(), `the query classified the loaded session's founding turn running`);
423
+ }
424
+ // No extension (the built-in claude and opencode backends today) — or
425
+ // the query failed and the catch already fell through: the
426
+ // observation path classifies the founding turn's terminal state
427
+ // (see `observeLoadedTurn`).
428
+ return this.observeLoadedTurn(boundary, undefined);
429
+ }
430
+ /**
431
+ * The observation path — backends WITHOUT the `_session/loaded_turn`
432
+ * extension (the built-in claude and opencode backends today), and
433
+ * extension backends whose query failed (see `awaitCurrentTurn`'s
434
+ * doc for the full semantics — the connection-death contract, the
435
+ * post-load continuation watch, and the replay probe). Never settles
436
+ * a quiet gap, never re-issues a possibly-running turn: the
437
+ * classification is authoritative ONLY for the VERIFIED BUILT-INS
438
+ * (`connectionDeathVerified`) because their ACP servers terminate
439
+ * in-flight turns when the client connection closes (live-verified),
440
+ * and their replay holds only completed messages. A CUSTOM backend
441
+ * (a registered registry entry — its connection-death behavior is
442
+ * NOT live-verified) that stays quiet through the window is NOT
443
+ * classified from the replay: its turn may still be running at the
444
+ * backend, so the seam keeps the loaded session attached and waits
445
+ * for the authoritative terminal state instead (phase-F review round
446
+ * 3: the quiet-window-plus-replay classification used to apply to
447
+ * every extension-less backend and every query failure, so a durable
448
+ * custom backend could have a still-running turn settled from stale
449
+ * replay or re-issued — the no-duplicate invariant requires the
450
+ * verified assumption to be restricted to the verified backends).
451
+ */
452
+ async observeLoadedTurn(boundary, queryFailure) {
453
+ // The post-load continuation watch: the load response resolves after
454
+ // the replay completes, so any CONTENT update applied after the
455
+ // boundary is live continuation — the authoritative still-running
456
+ // signal. A content update that ALREADY arrived (applied between the
457
+ // load response and this call) skips the window.
458
+ if (!boundary.sawPostLoadContentUpdate) {
459
+ const contentArrived = await this.waitForPostLoadContent(loadedTurnObserveMs());
460
+ if (this.releasePromise) {
461
+ throw new Error("InteractiveSession has been released");
462
+ }
463
+ if (!contentArrived && this.connectionDeathVerified) {
464
+ // The stream stayed quiet through the observation window: classify
465
+ // the founding turn's terminal state from the REPLAY (authoritative
466
+ // under the connection-death contract — the replay's trailing
467
+ // assistant message is a COMPLETED, persisted message, and no
468
+ // live continuation followed the load).
469
+ if (boundary.trailingContentKind === 'assistant-message') {
470
+ return { stopReason: "end_turn", text: this.session.loadedTurnText() };
471
+ }
472
+ throw new Error(`the loaded session's founding turn ended without a terminal assistant message (its replayed ` +
473
+ `transcript's trailing content is not an assistant message) and no live continuation followed the ` +
474
+ `load — the turn was interrupted, failed, or abandoned while this host was down, and nothing is ` +
475
+ `running at the backend; re-issue is the honest fallback (no duplication possible)`);
476
+ }
477
+ }
478
+ // Live continuation was observed — or the window stayed quiet on an
479
+ // UNVERIFIED backend (a custom registry entry, whose connection-death
480
+ // behavior is not live-verified): either way the turn may still be
481
+ // running at the backend. Keep the loaded session attached and wait
482
+ // for the authoritative terminal state (the ended notification, the
483
+ // max-wait bound — the re-armable still-running rejection the broker
484
+ // re-arms on the attached session). Never settle a quiet gap and
485
+ // never re-issue a possibly-running turn.
486
+ return this.waitForRunningLoadedTurn(loadedTurnMaxWaitMs(), queryFailure === undefined
487
+ ? this.connectionDeathVerified
488
+ ? `live content followed the load response on ${this.backendId}`
489
+ : `the loaded session's stream stayed quiet after the load on ${this.backendId} (a custom backend's ` +
490
+ `connection-death behavior is not live-verified — the quiet window is not terminal evidence)`
491
+ : this.connectionDeathVerified
492
+ ? `live content followed the load response on ${this.backendId} (_session/loaded_turn/query failed: ` +
493
+ `${thrownMessageOf(queryFailure)})`
494
+ : `the loaded session's stream stayed quiet after the load on ${this.backendId} (a custom backend's ` +
495
+ `connection-death behavior is not live-verified — the quiet window is not terminal evidence; ` +
496
+ `_session/loaded_turn/query failed: ${thrownMessageOf(queryFailure)})`);
497
+ }
498
+ /** Is this session's backend one of the four built-ins whose ACP
499
+ * servers' connection-death behavior is LIVE-VERIFIED (claude,
500
+ * codex, opencode, pi — every built-in server terminates its
501
+ * sessions' in-flight turns when the client connection closes, so a
502
+ * restored session's replay holds only completed messages)? The
503
+ * observation path's quiet-window-plus-replay classification is
504
+ * authoritative ONLY for these; a CUSTOM backend (a registered
505
+ * registry entry — even one that shadows a built-in name) can keep a
506
+ * turn running while quiet, so its quiet window degrades to the
507
+ * keep-attached still-running wait (phase-F review round 3: the
508
+ * verified assumption must be restricted to the verified built-ins).
509
+ * Instance-based: a custom backend registered under a built-in name
510
+ * is a `CustomAcpBackend` and is never counted as verified. */
511
+ get connectionDeathVerified() {
512
+ return !(this.backend instanceof CustomAcpBackend);
513
+ }
514
+ /** The post-load continuation watch: resolve true on the first CONTENT
515
+ * update applied after the load boundary (live-continuation evidence —
516
+ * the authoritative still-running signal), false when the observation
517
+ * window elapses without one (or the session is released — the caller
518
+ * re-checks `releasePromise` before classifying). Bookkeeping updates
519
+ * (usage, mode, available commands — claude emits an
520
+ * `available_commands_update` right after every load) never count:
521
+ * the flag flips only on content updates. */
522
+ waitForPostLoadContent(observeMs) {
523
+ return new Promise((resolve) => {
524
+ if (this.releasePromise) {
525
+ resolve(false);
526
+ return;
527
+ }
528
+ if (this.session.loadBoundaryState().sawPostLoadContentUpdate) {
529
+ resolve(true);
530
+ return;
531
+ }
532
+ let finished = false;
533
+ const finish = (result) => {
534
+ if (finished)
535
+ return;
536
+ finished = true;
537
+ clearTimeout(timer);
538
+ offUpdate();
539
+ releaseWatcher();
540
+ resolve(result);
541
+ };
542
+ const timer = setTimeout(() => finish(false), observeMs);
543
+ const offUpdate = this.session.subscribeUpdates(() => {
544
+ if (this.session.loadBoundaryState().sawPostLoadContentUpdate)
545
+ finish(true);
546
+ });
547
+ const releaseWatcher = () => {
548
+ this.releaseWatchers.delete(releaseWatcher);
549
+ finish(false);
550
+ };
551
+ this.releaseWatchers.add(releaseWatcher);
552
+ });
553
+ }
554
+ /**
555
+ * The keep-attached still-running wait (the extension's `running` arm
556
+ * AND the observation path's live-continuation arm): the loaded
557
+ * session stays attached, the turn's live stream is absorbed, and the
558
+ * seam waits for the terminal state — the `_session/loaded_turn/ended`
559
+ * notification when the backend pushes one, the max-wait bound (the
560
+ * "never hang unobserved" backstop), or the session's release —
561
+ * whichever comes first (no polling: a long still-running turn is
562
+ * observed with zero busy work). A bound expiry rejects with the
563
+ * re-armable `LoadedTurnStillRunningError`: the broker re-arms the
564
+ * wait on the still-attached session — a later ended notification — or
565
+ * a cancel — still settles the call (phase-F review round 2: a
566
+ * possibly-running call is never re-issued).
567
+ */
568
+ async waitForRunningLoadedTurn(maxWaitMs, context) {
569
+ const start = Date.now();
570
+ for (;;) {
571
+ if (this.releasePromise) {
572
+ throw new Error("InteractiveSession has been released while awaiting the loaded session's founding turn");
573
+ }
574
+ const ended = this.session.loadedTurnEndedState();
575
+ if (ended !== null) {
576
+ return this.loadedTurnEndedResult(ended);
577
+ }
578
+ const elapsed = Date.now() - start;
579
+ if (elapsed >= maxWaitMs) {
580
+ // The "never hang unobserved" backstop: the turn is STILL running
581
+ // and its terminal state has not become observable. Never settle a
582
+ // quiet gap and never re-issue a possibly-running turn — reject
583
+ // with the still-running class (the broker keeps the loaded
584
+ // session attached, warns guest-visibly, and re-arms the seam so
585
+ // a later notification — or a cancel — still settles the call).
586
+ throw new LoadedTurnStillRunningError(`the loaded session's founding turn is still running at the backend (${context}), and its terminal ` +
587
+ `state has not become observable within ${maxWaitMs} ms of the load — settling a quiet gap could ` +
588
+ `durably record partial output, and re-issuing could duplicate the running turn; the broker keeps ` +
589
+ `the loaded session attached and re-arms the wait on it`, true);
590
+ }
591
+ // Wait for the ended notification, the max-wait expiry, or the
592
+ // session's release — whichever comes first (no polling: a long
593
+ // still-running turn is observed with zero busy work).
594
+ await Promise.race([
595
+ this.nextLoadedTurnEnded(),
596
+ sleep(maxWaitMs - elapsed),
597
+ this.waitForRelease(),
598
+ ]);
599
+ }
600
+ }
601
+ /** The `_session/loaded_turn/ended` resolution: the turn that was running
602
+ * at load ended. A turn that ended with an ERROR is a definite
603
+ * rejection (never settled as success — `LoadedTurnFailedError`, the
604
+ * settle-as-rejection class the broker records and delivers); a turn
605
+ * that ended with a response resolves with its stop reason (the
606
+ * notification's, restricted to the ACP vocabulary — a server-specific
607
+ * reason the seam does not speak synthesizes `end_turn`, exactly like
608
+ * the completed-while-down arm) and the accumulated text. */
609
+ loadedTurnEndedResult(ended) {
610
+ if (ended.error !== undefined) {
611
+ throw new LoadedTurnFailedError(`the loaded session's founding turn failed at the backend: ${ended.error.message}`);
612
+ }
613
+ const stopReason = ended.stopReason ?? "end_turn";
614
+ return {
615
+ stopReason: LOADED_TURN_STOP_REASONS.has(stopReason) ? stopReason : "end_turn",
616
+ text: this.session.loadedTurnText(),
617
+ };
618
+ }
619
+ /** Resolve on the session's next `_session/loaded_turn/ended`
620
+ * notification (the re-attach arm's authoritative terminal wait —
621
+ * zero polling; the subscription is one-shot and removed the moment it
622
+ * fires, and a notification that already arrived fires immediately). */
623
+ nextLoadedTurnEnded() {
624
+ return new Promise((resolve) => {
625
+ const off = this.session.subscribeLoadedTurnEnded(() => {
626
+ off();
627
+ resolve();
628
+ });
629
+ });
630
+ }
159
631
  /** Subscribe to runner events for THIS ACP session only. Events from other one-shot or
160
632
  * interactive sessions on the same runner are filtered out by sessionId. The returned
161
633
  * unsubscribe thunk and every still-live subscription are removed automatically on release. */
@@ -178,12 +650,66 @@ export class InteractiveSession {
178
650
  this.subscriptions.add(off);
179
651
  return off;
180
652
  }
653
+ /** Resolve when the session is released (or immediately when it
654
+ * already is) — the re-attach arm's release watch, so a session that
655
+ * dies or is disposed while the seam waits unblocks the wait instead
656
+ * of parking it until the max-wait expiry. */
657
+ waitForRelease() {
658
+ return new Promise((resolve) => {
659
+ if (this.releasePromise) {
660
+ resolve();
661
+ return;
662
+ }
663
+ const watcher = () => {
664
+ this.releaseWatchers.delete(watcher);
665
+ resolve();
666
+ };
667
+ this.releaseWatchers.add(watcher);
668
+ });
669
+ }
181
670
  /** Release the ACP session and close the dedicated process. Idempotent. Session close is
182
671
  * best-effort and bounded by SessionHandle; process disposal mirrors pool teardown. */
183
672
  release() {
184
673
  this.releasePromise ??= this.doRelease();
185
674
  return this.releasePromise;
186
675
  }
676
+ /** The loaded session's recorded founding-turn terminal state (the
677
+ * `_session/loaded_turn/ended` notification, when the backend pushed
678
+ * one — a seam-less backend that sends it anyway), or null when the
679
+ * turn has not ended (yet). The broker's non-re-armable settlement
680
+ * wait reads this surface instead of re-invoking a seam that can
681
+ * never observe the terminal state. */
682
+ loadedTurnEndedState() {
683
+ return this.session.loadedTurnEndedState();
684
+ }
685
+ /** Watch the loaded-turn-ended channel: the listener fires when the
686
+ * `_session/loaded_turn/ended` notification arrives (and immediately
687
+ * for a notification that already arrived). Returns the unsubscribe
688
+ * thunk. The broker's non-re-armable settlement wait's observability
689
+ * surface (the same channel the seam's own wait subscribes to). */
690
+ subscribeLoadedTurnEnded(listener) {
691
+ return this.session.subscribeLoadedTurnEnded(listener);
692
+ }
693
+ /** Resolve when the session is released (its dedicated process died or
694
+ * was disposed); never resolves on a live session. The broker's
695
+ * non-re-armable settlement wait's release watch — a released
696
+ * session means the backend turn died with the process (the
697
+ * safe-re-issue class). */
698
+ released() {
699
+ if (this.releasePromise !== undefined)
700
+ return this.releasePromise;
701
+ return new Promise((resolve) => {
702
+ if (this.releasePromise !== undefined) {
703
+ resolve();
704
+ return;
705
+ }
706
+ const watcher = () => {
707
+ this.releaseWatchers.delete(watcher);
708
+ resolve();
709
+ };
710
+ this.releaseWatchers.add(watcher);
711
+ });
712
+ }
187
713
  /** The re-attach handle for this session — persist it, then re-open later with
188
714
  * `runner.loadSession()`/`resumeSession()` (`backendId` doubles as the `model` routing spec).
189
715
  * Reopen flags mirror the connected agent's advertised persistence; an agent that persists
@@ -208,6 +734,13 @@ export class InteractiveSession {
208
734
  async doRelease() {
209
735
  this.removeAbort?.();
210
736
  this.removeAbort = undefined;
737
+ // Wake the re-attach arm's release watchers FIRST: an awaiting
738
+ // `awaitCurrentTurn` must unblock the moment the session is released
739
+ // (its loop re-checks `releasePromise` and rejects).
740
+ for (const watcher of [...this.releaseWatchers]) {
741
+ this.releaseWatchers.delete(watcher);
742
+ watcher();
743
+ }
211
744
  let childFailure;
212
745
  try {
213
746
  await this.session.release({ keepOpen: this.keepSession });
@@ -236,3 +769,127 @@ export class InteractiveSession {
236
769
  this.subscriptions.clear();
237
770
  }
238
771
  }
772
+ /** The loaded-session founding-turn max wait: the re-attach arm's
773
+ * "never hang unobserved" backstop (see `awaitCurrentTurn`). A turn the
774
+ * backend classifies as `running` (or the observation path sees live
775
+ * content from) is waited out up to this bound for its terminal state —
776
+ * the `_session/loaded_turn/ended` notification, or the end of the
777
+ * live stream — then the seam rejects with the
778
+ * `LoadedTurnStillRunningError` (the broker keeps the loaded session
779
+ * attached and re-arms the wait on it, so a later notification still
780
+ * settles the call). Default 15 min;
781
+ * `AGENTPRISM_ACP_LOADED_TURN_MAX_WAIT_MS` overrides (clamped to
782
+ * >= 1 ms). */
783
+ function loadedTurnMaxWaitMs() {
784
+ const env = process.env.AGENTPRISM_ACP_LOADED_TURN_MAX_WAIT_MS;
785
+ if (env !== undefined) {
786
+ const parsed = Number.parseInt(env, 10);
787
+ if (Number.isFinite(parsed) && parsed >= 1)
788
+ return parsed;
789
+ }
790
+ return 15 * 60 * 1000;
791
+ }
792
+ /** The observation path's post-load continuation window (see
793
+ * `observeLoadedTurn`): how long the seam watches the loaded session's
794
+ * stream for live continuation before classifying the founding turn's
795
+ * terminal state from the replay. The window absorbs content still in
796
+ * flight when the load response resolved (the replay completes before
797
+ * the response, so this is only the wire race and the first live chunk
798
+ * of a turn that is running after all); under the connection-death
799
+ * contract no built-in turn can be running at restore, so the window is
800
+ * short. Default 1 s; `AGENTPRISM_ACP_LOADED_TURN_OBSERVE_MS`
801
+ * overrides (clamped to >= 1 ms). */
802
+ function loadedTurnObserveMs() {
803
+ const env = process.env.AGENTPRISM_ACP_LOADED_TURN_OBSERVE_MS;
804
+ if (env !== undefined) {
805
+ const parsed = Number.parseInt(env, 10);
806
+ if (Number.isFinite(parsed) && parsed >= 1)
807
+ return parsed;
808
+ }
809
+ return 1000;
810
+ }
811
+ /** The ACP stop-reason vocabulary the loaded-turn ended notification may
812
+ * carry (a server-specific reason the seam does not speak synthesizes
813
+ * `end_turn`, exactly like the completed-while-down arm). */
814
+ const LOADED_TURN_STOP_REASONS = new Set([
815
+ "end_turn",
816
+ "max_tokens",
817
+ "max_turn_requests",
818
+ "refusal",
819
+ "cancelled",
820
+ ]);
821
+ /** The re-attach arm's duplicate-risk rejection: the loaded session's
822
+ * founding turn MAY STILL BE RUNNING at the backend and its terminal
823
+ * state is unobservable — a `running`-classified turn produced no
824
+ * terminal notification within the max-wait bound (the observation
825
+ * path's live-continuation arm included). The host must NEVER settle
826
+ * partial output (a quiet gap is only a progress-stream gap) and NEVER
827
+ * re-issue a possibly-running call: the broker KEEPS THE LOADED SESSION
828
+ * ATTACHED and re-arms the seam on it — the doc's second reconciliation
829
+ * arm, re-attach to a still-running task — for every form of this
830
+ * rejection (phase-F review round 2: the old non-re-armable form
831
+ * pushed the broker to release the loaded session and re-issue the
832
+ * call, which could duplicate a still-running backend turn; re-issue
833
+ * is now reserved for the observably-dead classes). A later terminal
834
+ * notification — or a cancel — still settles the call. The marker
835
+ * property is structural, so third-party adapter seams can throw the
836
+ * same class of rejection; `rearmable` is retained for compatibility
837
+ * with those seams (the broker re-arms both forms). */
838
+ export class LoadedTurnStillRunningError extends Error {
839
+ rearmable;
840
+ loadedTurnStillRunning = true;
841
+ constructor(message, rearmable) {
842
+ super(message);
843
+ this.rearmable = rearmable;
844
+ this.name = "LoadedTurnStillRunningError";
845
+ }
846
+ }
847
+ /** The re-attach arm's settle-as-rejection class: the loaded session's
848
+ * founding turn RAN and FAILED at the backend (the `_session/loaded_turn/
849
+ * ended` notification carried its error). A definite outcome — the host
850
+ * records and settles it as a rejection, exactly like a live prompt that
851
+ * rejects; it is never re-issued (the task already ran to a terminal
852
+ * state) and never settled as success (partial text is not an outcome).
853
+ * Marker property is structural, like `LoadedTurnStillRunningError`. */
854
+ export class LoadedTurnFailedError extends WorkflowError {
855
+ loadedTurnFailed = true;
856
+ constructor(message) {
857
+ super(message, WorkflowErrorCode.AGENT_EXECUTION_ERROR, { recoverable: false });
858
+ }
859
+ }
860
+ /** Is this a loaded-turn still-running rejection (the broker's
861
+ * never-settle-a-quiet-gap classification)? Structural marker, so
862
+ * third-party adapter seams can throw the same class. */
863
+ export function isLoadedTurnStillRunningError(error) {
864
+ return (error instanceof LoadedTurnStillRunningError ||
865
+ (typeof error === "object" &&
866
+ error !== null &&
867
+ error.loadedTurnStillRunning === true));
868
+ }
869
+ /** Is this a loaded-turn failed-at-backend rejection (the broker's
870
+ * settle-as-rejection classification)? Structural marker, so third-party
871
+ * adapter seams can throw the same class. */
872
+ export function isLoadedTurnFailedError(error) {
873
+ return (error instanceof LoadedTurnFailedError ||
874
+ (typeof error === "object" &&
875
+ error !== null &&
876
+ error.loadedTurnFailed === true));
877
+ }
878
+ /** Normalize any thrown value into a message (the query-failure arm of
879
+ * the seam's degradation message). */
880
+ function thrownMessageOf(error) {
881
+ if (error instanceof Error)
882
+ return error.message;
883
+ if (typeof error === "string")
884
+ return error;
885
+ try {
886
+ return JSON.stringify(error);
887
+ }
888
+ catch {
889
+ return String(error);
890
+ }
891
+ }
892
+ /** Timer sleep (the re-attach arm's wait primitive). */
893
+ function sleep(ms) {
894
+ return new Promise((resolve) => setTimeout(resolve, ms));
895
+ }