@junghanacs/entwurf 0.45.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/AGENTS.md +5 -3
  2. package/BASELINE.md +110 -1
  3. package/CHANGELOG.md +249 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/ENTWURF-CLI.json +1229 -0
  6. package/ENTWURF.json +63 -15
  7. package/FAQ.md +4 -4
  8. package/README.md +132 -188
  9. package/VERIFY.md +18 -11
  10. package/demo/README.md +1 -1
  11. package/docs/acp-backend-rail.md +1 -1
  12. package/docs/external-mcp-host.md +4 -4
  13. package/docs/setup-clean-host.md +94 -22
  14. package/mcp/entwurf-bridge/dist/ENTWURF.json +63 -15
  15. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +19 -8
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +4 -4
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +483 -18
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -10
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +613 -33
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-resume-call.js +497 -0
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +3 -1
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +50 -5
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +7 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-location.js +347 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-owner-fence.js +1132 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +1 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +18 -11
  28. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +30 -15
  29. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +40 -7
  30. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +1 -27
  31. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +1 -1
  32. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  33. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +1 -1
  34. package/mcp/entwurf-bridge/src/index.ts +28 -8
  35. package/mcp/entwurf-bridge/tsconfig.build.json +7 -1
  36. package/native/win32-x64/manifest.json +1 -1
  37. package/package.json +16 -20
  38. package/pi/pi-durable/bootstrap.mjs +179 -6
  39. package/pi/pi-durable/carrier/runtime.js +327 -57
  40. package/pi/pi-durable/carrier/session-name.js +11 -0
  41. package/pi/pi-durable/carrier/sessions.js +96 -20
  42. package/pi/pi-durable/carrier/subagent.js +5 -2
  43. package/pi/pi-durable/carrier/tui.js +60 -22
  44. package/pi/pi-durable/overlay/upstream-pin.json +26 -19
  45. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +73 -69
  46. package/pi-extensions/lib/entwurf-v2-contract.ts +4 -4
  47. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +710 -38
  48. package/pi-extensions/lib/fresh-call-dispatch.ts +64 -12
  49. package/pi-extensions/lib/herdr-fresh-call.ts +919 -40
  50. package/pi-extensions/lib/herdr-resume-call.ts +810 -0
  51. package/pi-extensions/lib/meta-session.ts +13 -8
  52. package/pi-extensions/lib/mux-resume-call.ts +77 -4
  53. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +9 -2
  54. package/pi-extensions/lib/pi-durable-location.ts +446 -0
  55. package/pi-extensions/lib/pi-durable-owner-fence.ts +1461 -0
  56. package/pi-extensions/lib/pi-durable-runtime.ts +4 -0
  57. package/pi-extensions/lib/resume-launch-identity.ts +31 -12
  58. package/pi-extensions/meta-bridge-pi-durable.ts +30 -16
  59. package/run.sh +552 -699
  60. package/scripts/check-claude-launch.ts +341 -0
  61. package/scripts/check-entwurf-v2-contract.ts +4 -4
  62. package/scripts/check-entwurf-v2-surface.ts +2 -2
  63. package/scripts/check-fresh-call-dispatch.ts +70 -9
  64. package/scripts/check-fresh-cut-gate.sh +23 -9
  65. package/scripts/check-gate-qualification.ts +19 -14
  66. package/scripts/check-herdr-activation.ts +301 -23
  67. package/scripts/check-herdr-fresh-call.ts +161 -25
  68. package/scripts/check-herdr-plugin-build.ts +173 -1
  69. package/scripts/check-herdr-plugin.ts +28 -2
  70. package/scripts/check-herdr-runtime-bootstrap.ts +136 -0
  71. package/scripts/check-install-surface.ts +1236 -6
  72. package/scripts/check-meta-doctor-oracle.sh +1 -1
  73. package/scripts/check-meta-facts.ts +20 -1
  74. package/scripts/check-mux-resume-call.ts +28 -0
  75. package/scripts/check-peer-facts.ts +39 -2
  76. package/scripts/cli-help.mjs +222 -0
  77. package/scripts/dev-bin.sh +39 -2
  78. package/scripts/herdr-activation.mjs +28 -6
  79. package/scripts/herdr-operator-link.mjs +389 -0
  80. package/scripts/herdr-plugin-activate.mjs +5 -0
  81. package/scripts/herdr-plugin-deactivate.mjs +18 -5
  82. package/scripts/herdr-rails.mjs +43 -7
  83. package/scripts/herdr-runtime.mjs +27 -0
  84. package/scripts/meta-bridge-fresh-cut.ts +1 -27
  85. package/scripts/meta-bridge-prune.ts +1 -1
  86. package/scripts/meta-facts.ts +1 -1
  87. package/scripts/mutants/agent-guidance.json +4 -4
  88. package/scripts/mutants/claude-launch.json +142 -0
  89. package/scripts/mutants/cli-help-source.json +420 -0
  90. package/scripts/mutants/control-send-receipt.json +1 -1
  91. package/scripts/mutants/dev-gate-dispatch.json +109 -0
  92. package/scripts/mutants/fresh-call-dispatch.json +38 -2
  93. package/scripts/mutants/fresh-cut.json +15 -0
  94. package/scripts/mutants/herdr-activation.json +128 -0
  95. package/scripts/mutants/herdr-fresh-call.json +278 -5
  96. package/scripts/mutants/herdr-operator-link.json +318 -0
  97. package/scripts/mutants/herdr-plugin-build.json +55 -0
  98. package/scripts/mutants/herdr-plugin.json +17 -0
  99. package/scripts/mutants/herdr-resume-call.json +288 -0
  100. package/scripts/mutants/herdr-runtime-bootstrap.json +48 -0
  101. package/scripts/mutants/meta-facts.json +15 -0
  102. package/scripts/mutants/mux-resume-call.json +63 -2
  103. package/scripts/mutants/pack-install.json +4 -4
  104. package/scripts/mutants/peer-facts.json +15 -0
  105. package/scripts/mutants/pi-durable.json +1167 -3
  106. package/scripts/mutants/pi-floor.json +22 -5
  107. package/scripts/mutants/source-install.json +35 -0
  108. package/scripts/mutants/v2-surface.json +2 -2
  109. package/scripts/mutants/v2-visible-resume.json +255 -6
  110. package/scripts/peer-facts.ts +1 -1
  111. package/scripts/smoke-agy-install-state.sh +66 -0
  112. package/scripts/smoke-meta-prune.sh +23 -0
  113. package/scripts/smoke-omp-fresh-live.ts +12 -3
  114. package/scripts/smoke-omp-receive-live.ts +7 -1
  115. package/scripts/smoke-pi-durable-fresh-live.ts +12 -2
  116. package/team.json.example +39 -0
@@ -1,20 +1,31 @@
1
-
2
1
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
3
- import { clampThinkingLevel, getSupportedThinkingLevels, } from "@earendil-works/pi-ai";
2
+ import {
3
+ clampThinkingLevel,
4
+ cleanupSessionResources,
5
+ getSupportedThinkingLevels,
6
+
7
+ } from "@earendil-works/pi-ai";
4
8
  import {
5
9
 
6
10
 
7
11
 
8
12
 
13
+
9
14
 
15
+
10
16
 
11
17
 
12
18
  Harness,
13
19
 
20
+ LiveDoc,
14
21
 
22
+
15
23
  ROOT_CONVERSATION_ID,
16
24
 
17
25
 
26
+
27
+
28
+
18
29
  } from "@earendil-works/pi-durable";
19
30
  import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
20
31
  import { ModelRuntime } from "entwurf-pi-dist:core/model-runtime.js";
@@ -26,7 +37,7 @@ import {
26
37
  ExecutionEnvs,
27
38
  findInitialAgentModel,
28
39
  } from "./harness-setup.js";
29
- import { selectSession } from "./sessions.js";
40
+ import { selectSession } from "./sessions.js";
30
41
  import { Subagent } from "./subagent.js";
31
42
 
32
43
  const context = BACKGROUND_CONTEXT;
@@ -53,8 +64,19 @@ const context = BACKGROUND_CONTEXT;
53
64
  /** Everything the TUI renders. Plain values; no Harness objects cross this boundary. */
54
65
 
55
66
 
56
-
67
+
68
+
69
+
70
+
71
+
57
72
 
73
+
74
+
75
+
76
+
77
+
78
+
79
+
58
80
 
59
81
 
60
82
 
@@ -67,6 +89,11 @@ const context = BACKGROUND_CONTEXT;
67
89
 
68
90
 
69
91
 
92
+
93
+
94
+
95
+
96
+
70
97
 
71
98
 
72
99
 
@@ -96,6 +123,8 @@ const context = BACKGROUND_CONTEXT;
96
123
 
97
124
 
98
125
 
126
+
127
+
99
128
 
100
129
 
101
130
 
@@ -144,7 +173,42 @@ function titleOf(entry ) {
144
173
  return { title: text.replace(/\s+/g, " ").trim() };
145
174
  }
146
175
 
147
- export async function openDurable(options = {}) {
176
+ /** What failed, for an owner end's `cause`. */
177
+ function causeOf(error ) {
178
+ return error instanceof Error ? error.message : String(error);
179
+ }
180
+
181
+ /** The shown conversation's active transcript and the newest entry ID in it. */
182
+
183
+
184
+
185
+
186
+
187
+ /** A read's active transcript and the watch attached right after the read. */
188
+
189
+
190
+
191
+
192
+
193
+ /** The newest entry ID of `entries`; a head marker comes first, so the last entry need not be the newest. */
194
+ function newestOf(entries ) {
195
+ return entries.reduce((newest, entry) => Math.max(newest, entry.id), Number.NEGATIVE_INFINITY);
196
+ }
197
+
198
+ /** What the TUI renders: the watched documents with the active transcript as items. */
199
+ function transcriptOf(view , entries ) {
200
+ return {
201
+ conversation: view.conversation,
202
+ docs: view.docs,
203
+ items: entries.map((entry) => ({ kind: "entry" , entry })),
204
+ };
205
+ }
206
+
207
+ export async function openDurable(options ) {
208
+ // The type requires it; a JS caller is refused here, before the session root is created.
209
+ if (options.fence === undefined) {
210
+ throw new Error("fence-required: an owned durable open takes the owner fence before its native lease");
211
+ }
148
212
  if (options.model !== undefined && (options.continueSession || options.session !== undefined)) {
149
213
  throw new Error("An explicit model applies only to a new session, not to a continued one");
150
214
  }
@@ -152,12 +216,16 @@ export async function openDurable(options = {})
152
216
  throw new Error("A chosen session and the newest one are two different sessions; name one");
153
217
  }
154
218
  const location = await selectSession(
219
+ options.fence,
155
220
  options.cwd ?? process.cwd(),
156
221
  options.continueSession ?? false,
157
222
  options.session,
158
223
  );
224
+ const ticket = location.ticket;
159
225
  const envs = new ExecutionEnvs(location.cwd);
160
226
  let harness ;
227
+ // The storage handle Harness.open is handed: if Harness.open itself rejects, this is what was opened.
228
+ let backendHandle ;
161
229
  try {
162
230
  const modelRuntime = await ModelRuntime.create();
163
231
  const settingsManager = SettingsManager.create(location.cwd);
@@ -167,19 +235,25 @@ export async function openDurable(options = {})
167
235
  registry.install(Subagent);
168
236
  for (const extension of options.extensions ?? []) registry.install(extension);
169
237
 
170
- const pendingReports = [];
171
- let report = (error) => pendingReports.push(error);
238
+ // SDK 1.5 hands one tagged `Report` (what happened by `kind`, plus its `error`), not pi 1.4's (error, info).
239
+ const pendingReports = [];
240
+ let report = (reported) => pendingReports.push(reported);
241
+ // Recorded BEFORE the call: once the open is attempted, no later outcome proves that nothing was opened.
242
+ ticket.storageOpenAttempted = true;
243
+ const backend = await openNodeSqliteStorage(location.database);
244
+ backendHandle = backend;
172
245
  harness = await Harness.open(
173
- await openNodeSqliteStorage(location.database),
246
+ backend,
174
247
  {
175
248
  models: modelRuntime,
176
249
  registry,
177
250
  settings,
178
251
  env: envs.env,
179
- onReport: (error) => report(error),
252
+ onReport: (reported) => report(reported),
180
253
  },
181
254
  context,
182
255
  );
256
+ await ticket.storageOpened();
183
257
  const initial = location.created
184
258
  ? await findInitialAgentModel(settingsManager, modelRuntime, options.model)
185
259
  : undefined;
@@ -220,7 +294,23 @@ export async function openDurable(options = {})
220
294
  cursor = page.next;
221
295
  } while (cursor !== undefined);
222
296
  let current = root;
223
- let conversation = await root.viewState(context);
297
+ // The shown conversation's transcript is pi 1.4's active one (#155 D1). One official read gives the active range
298
+ // (`Conversation.context().entries`); the watch attached right after it starts at the next entry ID, so what is
299
+ // committed between the two is its first value. Nothing here derives an active range: when one changes, it is
300
+ // read again.
301
+ const seed = async (conversation ) => {
302
+ const read = await conversation.context(context);
303
+ const newest = newestOf(read.entries);
304
+ const attached = await conversation.watch(
305
+ context,
306
+ Number.isFinite(newest) ? { from: (newest + 1) } : undefined,
307
+ );
308
+ return { watch: attached, shown: { entries: read.entries, newest } };
309
+ };
310
+ const first = await seed(root);
311
+ // The shown watch: the first one until `install` makes it so, then each one a switch or a new read installs.
312
+ let watch ;
313
+ let shown = first.shown;
224
314
  const models = () =>
225
315
  modelRuntime.getAvailableSnapshot().map((model) => ({
226
316
  provider: model.provider,
@@ -231,7 +321,8 @@ export async function openDurable(options = {})
231
321
 
232
322
  let state = {
233
323
  session: { id: location.id, directory: location.directory, cwd: location.cwd },
234
- conversation: conversation.value,
324
+ conversation: first.watch.value,
325
+ transcript: transcriptOf(first.watch.value, shown.entries),
235
326
  conversations: summaries,
236
327
  models: models(),
237
328
  notices: [],
@@ -254,13 +345,16 @@ export async function openDurable(options = {})
254
345
  update({ notices: [...state.notices, { id: nextNotice++, level, message }].slice(-20) });
255
346
  };
256
347
  const fail = (error ) => notice("error", error instanceof Error ? error.message : String(error));
257
- report = (error) => {
258
- const message = error instanceof Error ? error.message : String(error);
259
- // A failing Session runs its close listeners first and reports the cause right after: that report names it.
260
- if (state.failure !== undefined && state.failure.error === undefined) update({ failure: { error: message } });
261
- else notice("warning", message);
348
+ report = (reported) => {
349
+ // The error the SDK reported, never the Report itself: that would read `[object Object]`.
350
+ const message = causeOf(reported.error);
351
+ // A failing Session runs its close listeners first and reports the cause right after, as `storage_failed`:
352
+ // that report names the failure the close listener recorded.
353
+ if (reported.kind === "storage_failed" && state.failure !== undefined && state.failure.error === undefined) {
354
+ update({ failure: { error: message } });
355
+ } else notice(reported.kind === "storage_failed" ? "error" : "warning", `${reported.kind}: ${message}`);
262
356
  };
263
- for (const error of pendingReports) report(error);
357
+ for (const reported of pendingReports) report(reported);
264
358
  const failedListeners = new Set ();
265
359
  // The Session closes itself when its Storage fails. Its `closed` settles only after every running task returned,
266
360
  // which a task ignoring its abort can delay without end, so the failure is shown from the close listener itself.
@@ -275,29 +369,124 @@ export async function openDurable(options = {})
275
369
  }
276
370
  }
277
371
  });
278
- let unsubscribe = conversation.subscribe((value) => update({ conversation: value }));
279
- // Subagents appear as their conversations are created. A commit listener only records; it calls no Session API.
280
- const unsubscribeCommits = harness.subscribeCommits((publication) => {
281
- let conversations = state.conversations;
282
- for (const change of publication.changes) {
283
- if (change.type === "conversation") {
284
- conversations = [...conversations, { id: change.value.id, label: label(change.value.id) }];
285
- } else if (change.type === "entry" && change.value.kind === "pi.user") {
286
- const id = change.value.conversationId;
287
- conversations = conversations.map((summary) =>
288
- summary.id === id && summary.title === undefined ? { ...summary, ...titleOf(change.value) } : summary,
289
- );
372
+ let closing ;
373
+ // A watch that ends by itself while the Session runs leaves its part of the screen frozen: say so rather than look
374
+ // healthy (#155). Ending at close, at a failure the Session already shows, or by being replaced, is no news.
375
+ const ended = (part , end ) => {
376
+ if (closing !== undefined || state.failure !== undefined) return;
377
+ if (end.kind !== "failed" && end.kind !== "listener_error") return;
378
+ update({ stalled: { ...state.stalled, [part]: causeOf(end.error) } });
379
+ };
380
+ // The watch whose entries wait: its conversation is being read again, or that read failed. A failed read holds
381
+ // the transcript where it was, said on screen, until a switch installs a new watch; there is no retry.
382
+ let held ;
383
+ // The shown conversation read again, one read at a time. It is installed only while `trigger` is still the shown
384
+ // watch: a switch made meanwhile wins, and the late read is dropped (#155 D1).
385
+ const reseed = (trigger ) => {
386
+ held = trigger;
387
+ const conversation = current;
388
+ void seed(conversation).then(
389
+ (next) => {
390
+ if (watch !== trigger) {
391
+ void next.watch.stop();
392
+ return;
393
+ }
394
+ install(conversation, next);
395
+ },
396
+ (error) => {
397
+ // A read refused by the close or the Session's own failure is no news, as `ended` has it.
398
+ if (watch !== trigger || closing !== undefined || state.failure !== undefined) return;
399
+ update({ stalled: { ...state.stalled, conversation: causeOf(error) } });
400
+ },
401
+ );
402
+ };
403
+ // One value of the shown watch. Entries past the newest one shown join the transcript in ID order. A head marker
404
+ // among them (a compaction or a reset) or a window starting past the newest one shown (it dropped entries the
405
+ // transcript lacks) is a new active range: read again, nothing appended until it lands.
406
+ const advance = (view ) => {
407
+ if (held !== undefined && held === watch) {
408
+ update({ conversation: view });
409
+ return;
410
+ }
411
+ const fresh = view.entries.filter((entry) => entry.id > shown.newest);
412
+ const restarts = fresh.some((entry) => entry.head !== undefined);
413
+ const skipped = view.before !== undefined && view.before > shown.newest;
414
+ if (restarts || skipped) {
415
+ update({ conversation: view });
416
+ reseed(watch);
417
+ return;
418
+ }
419
+ // A value without new entries (a document change, such as a streaming answer's progress) leaves the
420
+ // transcript as it is.
421
+ if (fresh.length === 0) {
422
+ update({ conversation: view });
423
+ return;
424
+ }
425
+ shown = { entries: [...shown.entries, ...fresh], newest: newestOf(fresh) };
426
+ update({ conversation: view, transcript: transcriptOf(view, shown.entries) });
427
+ };
428
+ // Every later value of the shown watch; a replaced watch's late frames are dropped.
429
+ const follow = (handle ) => {
430
+ handle.start(async (call) => {
431
+ if (handle !== watch) return;
432
+ if (call.kind === "failed") {
433
+ notice("warning", `The conversation view could not update: ${causeOf(call.error)}`);
434
+ return;
290
435
  }
436
+ advance(call.value);
437
+ });
438
+ void handle.closed.then((end) => {
439
+ if (handle === watch) ended("conversation", end);
440
+ });
441
+ };
442
+ // Show `conversation` from a read: its active entries, then the watch's first value, which holds what was
443
+ // committed between the read and the attach and is taken exactly once here, then the watch's later values.
444
+ const install = (conversation , next ) => {
445
+ // The close and the Session's own failure end what is shown, as a switch does: a read landing after either
446
+ // is let go, its watch stopped (the Session may already have ended it), and nothing is patched or started.
447
+ if (closing !== undefined || state.failure !== undefined) {
448
+ void next.watch.stop();
449
+ return;
291
450
  }
292
- if (conversations !== state.conversations) update({ conversations });
451
+ const previous = watch;
452
+ current = conversation;
453
+ watch = next.watch;
454
+ shown = next.shown;
455
+ void previous?.stop();
456
+ // A new watch replaces a stalled one; the task graph's state is its own.
457
+ const stalled = state.stalled?.tasks === undefined ? undefined : { tasks: state.stalled.tasks };
458
+ update({ conversation: next.watch.value, transcript: transcriptOf(next.watch.value, shown.entries), stalled });
459
+ advance(next.watch.value);
460
+ follow(next.watch);
461
+ };
462
+ install(root, first);
463
+ // Subagents appear as their conversations are created. A commit observer only records; it calls no Session API.
464
+ // Its end decides nothing: SDK 1.5 ends it `failed` ("session closed") at a normal close as well (#155).
465
+ const commits = harness.observeCommits({
466
+ advance: (publication) => {
467
+ let conversations = state.conversations;
468
+ for (const change of publication.changes) {
469
+ if (change.type === "conversation") {
470
+ conversations = [...conversations, { id: change.value.id, label: label(change.value.id) }];
471
+ } else if (change.type === "entry" && change.value.kind === "pi.user") {
472
+ const id = change.value.conversationId;
473
+ conversations = conversations.map((summary) =>
474
+ summary.id === id && summary.title === undefined
475
+ ? { ...summary, ...titleOf(change.value) }
476
+ : summary,
477
+ );
478
+ }
479
+ }
480
+ if (conversations !== state.conversations) update({ conversations });
481
+ },
482
+ end: () => {},
293
483
  });
294
484
 
295
- let tasks ;
296
- let unsubscribeTasks = () => {};
485
+ let tasks ;
297
486
  const closeTasks = () => {
298
- unsubscribeTasks();
299
- tasks?.dispose();
487
+ const graph = tasks;
300
488
  tasks = undefined;
489
+ void graph?.stop();
301
490
  };
302
491
 
303
492
  let queue = Promise.resolve();
@@ -306,15 +495,21 @@ export async function openDurable(options = {})
306
495
  queue = queue.then(operation).catch(fail);
307
496
  return queue;
308
497
  };
309
- const watchAnswer = (submission ) => {
310
- void submission.wait(context).then((settled) => {
311
- if (settled.status === "unanswered" && settled.reason !== "aborted") {
498
+ // A run that ended without an answer stays on screen as its conversation's `pi.live.runFailure` (SDK 1.5), so an
499
+ // input that run settled adds no notice of its own; one settled without such a run (stale, say) still does.
500
+ const watchAnswer = (submission , conversation ) => {
501
+ void submission
502
+ .wait(context)
503
+ .then(async (settled) => {
504
+ if (settled.status !== "unanswered" || settled.reason === "aborted") return;
505
+ const live = await opened.snapshot(LiveDoc, conversation.id, context);
506
+ if (live?.runFailure?.inputs.includes(submission.id)) return;
312
507
  notice(
313
508
  "error",
314
509
  `No answer: ${settled.reason}${settled.detail === undefined ? "" : ` ${JSON.stringify(settled.detail)}`}`,
315
510
  );
316
- }
317
- }, fail);
511
+ })
512
+ .catch(fail);
318
513
  };
319
514
  const agentModel = () => {
320
515
  const ref = agentOf(state.conversation).model;
@@ -329,7 +524,8 @@ export async function openDurable(options = {})
329
524
  if (state.failure !== undefined) {
330
525
  throw new Error("The session failed and takes no input. Close it, then reopen it with --continue.");
331
526
  }
332
- watchAnswer(await current.submit({ type: "input", content: text, whenBusy }, context));
527
+ const target = current;
528
+ watchAnswer(await target.submit({ type: "input", content: text, whenBusy }, context), target);
333
529
  }),
334
530
  compact: (instructions) =>
335
531
  command(async () => {
@@ -383,12 +579,8 @@ export async function openDurable(options = {})
383
579
  command(async () => {
384
580
  const next = await opened.conversation(id, context);
385
581
  if (next === undefined) throw new Error(`Conversation ${id} does not exist`);
386
- const nextState = await next.viewState(context);
387
- unsubscribe();
388
- conversation.dispose();
389
- current = next;
390
- conversation = nextState;
391
- unsubscribe = nextState.subscribe((value) => update({ conversation: value }));
582
+ // The same read as the open: the whole active transcript, whatever this process showed of it before.
583
+ install(next, await seed(next));
392
584
  }),
393
585
  };
394
586
 
@@ -399,14 +591,17 @@ export async function openDurable(options = {})
399
591
  }
400
592
  if (initial?.fallbackMessage !== undefined) notice("info", initial.fallbackMessage);
401
593
  // The task graph is observed until close; its panel starts open and /tasks hides it.
402
- const graph = await opened.taskGraph(context);
594
+ const graph = await opened.watchTaskGraph(context);
403
595
  tasks = graph;
404
596
  update({ tasks: graph.value });
405
- unsubscribeTasks = graph.subscribe((value) => update({ tasks: value }));
597
+ graph.start(async (call) => {
598
+ if (call.kind === "value") update({ tasks: call.value });
599
+ else notice("warning", `The task graph could not update: ${causeOf(call.error)}`);
600
+ });
601
+ void graph.closed.then((end) => ended("tasks", end));
406
602
  // Recovered work from an interrupted turn continues now.
407
603
  harness.resume();
408
604
 
409
- let closing ;
410
605
  return {
411
606
  view: {
412
607
  current: () => state,
@@ -435,24 +630,99 @@ export async function openDurable(options = {})
435
630
  closing ??= (async () => {
436
631
  // First: the Session runs its close listeners inside its own close call.
437
632
  unsubscribeClose();
438
- unsubscribe();
439
- unsubscribeCommits();
440
- conversation.dispose();
633
+ void watch?.stop();
634
+ commits.detach();
441
635
  closeTasks();
636
+ // The resource end the fence records (#157 B3). Storage is `closed` only when the first actual close
637
+ // resolved, no failure was seen before it, and the Session's own terminal result is not `failed`.
638
+ const failures = [];
639
+ let storage = "failed";
640
+ let envCleanup ;
641
+ let lease = "released";
442
642
  try {
443
643
  // Close writes no outcome: a running turn resumes with --continue.
444
644
  await opened.close(context);
445
- await envs.cleanup(context);
645
+ // SDK 1.5 tells the end by `kind`; pi 1.4's `reason` is absent there, and a type-stripped carrier would
646
+ // read it as undefined and call every end closed (#155).
647
+ const end = await opened.closed;
648
+ if (state.failure === undefined && end.kind !== "failed") storage = "closed";
649
+ try {
650
+ await envs.cleanup(context);
651
+ } catch (error) {
652
+ envCleanup = "failed";
653
+ failures.push(error);
654
+ }
655
+ } catch (error) {
656
+ failures.push(error);
446
657
  } finally {
447
- await location.release();
658
+ // The provider connections the turns left open for reuse: pi-ai keeps a Codex WebSocket per provider
659
+ // session on a five-minute idle timer, and pi's AgentSession releases them on dispose. Without the same
660
+ // release here a closed host stays alive until that timer (#157 S3 LIVE-R1). No id: the provider session
661
+ // ids are docs of the storage this process alone opened, and a host process holds this one Session.
662
+ try {
663
+ cleanupSessionResources();
664
+ } catch (error) {
665
+ failures.push(error);
666
+ }
667
+ try {
668
+ await location.release();
669
+ } catch (error) {
670
+ lease = "failed";
671
+ failures.push(error);
672
+ }
673
+ }
674
+ const failure = failures[0] ?? (storage === "closed" ? undefined : "the session failed before close");
675
+ const record = {
676
+ storage,
677
+ lease,
678
+ ...(envCleanup === undefined ? {} : { envCleanup }),
679
+ ...(failure === undefined ? {} : { cause: causeOf(failure) }),
680
+ };
681
+ // An env cleanup failure is the caller's error, not the resource's: the end stays `closed`.
682
+ try {
683
+ if (storage === "closed" && lease === "released") await ticket.closed(record);
684
+ else await ticket.closeFailed(record);
685
+ } catch (error) {
686
+ failures.push(error);
448
687
  }
688
+ if (failures.length === 1) throw failures[0];
689
+ if (failures.length > 1) throw new AggregateError(failures, "The durable session did not close cleanly");
449
690
  })();
450
691
  return closing;
451
692
  },
452
693
  };
453
694
  } catch (error) {
454
- await harness?.close(context).catch(() => {});
455
- await location.release().catch(() => {});
456
- throw error;
695
+ // An init failure (#157 B3). Only a storage open that was never attempted, with the lease verifiably released,
696
+ // ends `closed`; once attempted, neither a settled Harness close nor a released lease proves the resource ended.
697
+ let storage = ticket.storageOpenAttempted ? "unknown" : "never-opened";
698
+ // The init error first, then every cleanup failure: none is dropped.
699
+ const failures = [error];
700
+ // The first close of what was opened: the Harness once it opened, else the storage handle Harness.open was
701
+ // handed and rejected. A close that settles is no verified close here; one that rejects is a failed storage.
702
+ const closable = harness ?? backendHandle;
703
+ if (closable !== undefined) {
704
+ try {
705
+ await closable.close(context);
706
+ } catch (closeError) {
707
+ storage = "failed";
708
+ failures.push(closeError);
709
+ }
710
+ }
711
+ let lease = "released";
712
+ try {
713
+ await location.release();
714
+ } catch (releaseError) {
715
+ lease = "failed";
716
+ failures.push(releaseError);
717
+ }
718
+ const ending = { storage, lease, cause: failures.map(causeOf).join("; ") };
719
+ try {
720
+ if (storage === "never-opened" && lease === "released") await ticket.closed(ending);
721
+ else await ticket.closeFailed(ending);
722
+ } catch (fenceError) {
723
+ failures.push(fenceError);
724
+ }
725
+ if (failures.length === 1) throw error;
726
+ throw new AggregateError(failures, `${causeOf(error)}; its cleanup or owner end failed too`);
457
727
  }
458
728
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The durable session name grammar (#157 G3 B-L1), alone in its own file: no imports and no I/O, so the
3
+ * bootstrap can hold a `--session` value to it before an operator module prepares the environment, without
4
+ * loading the session store, the SDK config or a lock library first. sessions.ts selects by this same predicate.
5
+ */
6
+ const SESSION_NAME = /^\d{13}-[0-9a-f-]{36}$/u;
7
+
8
+ /** Whether `name` has the exact shape of a session directory name: `<13-digit ms>-<uuid>`. */
9
+ export function isSessionName(name ) {
10
+ return SESSION_NAME.test(name);
11
+ }