@junghanacs/entwurf 0.41.2 → 0.50.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 (122) hide show
  1. package/AGENTS.md +4 -2
  2. package/BASELINE.md +110 -1
  3. package/CHANGELOG.md +328 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/ENTWURF-CLI.json +1229 -0
  6. package/ENTWURF.json +284 -0
  7. package/FAQ.md +4 -4
  8. package/README.md +125 -184
  9. package/VERIFY.md +2 -2
  10. package/docs/external-mcp-host.md +3 -3
  11. package/docs/setup-clean-host.md +37 -12
  12. package/mcp/entwurf-bridge/dist/ENTWURF.json +284 -0
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +46 -196
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/agent-guidance.js +299 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +21 -25
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +498 -29
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -10
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +613 -33
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-resume-call.js +497 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +3 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +23 -20
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +68 -21
  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/remote-pair-send.js +7 -4
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +18 -11
  29. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -60
  30. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +40 -7
  31. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +1 -27
  32. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +1 -1
  33. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  34. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +1 -1
  35. package/mcp/entwurf-bridge/src/index.ts +149 -327
  36. package/mcp/entwurf-bridge/tsconfig.build.json +7 -1
  37. package/native/win32-x64/manifest.json +1 -1
  38. package/package.json +8 -4
  39. package/pi/pi-durable/bootstrap.mjs +179 -6
  40. package/pi/pi-durable/carrier/runtime.js +108 -9
  41. package/pi/pi-durable/carrier/session-name.js +11 -0
  42. package/pi/pi-durable/carrier/sessions.js +96 -20
  43. package/pi/pi-durable/overlay/upstream-pin.json +11 -5
  44. package/pi-extensions/lib/agent-guidance.ts +341 -0
  45. package/pi-extensions/lib/entwurf-v2-surface.ts +25 -31
  46. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +725 -54
  47. package/pi-extensions/lib/fresh-call-dispatch.ts +64 -12
  48. package/pi-extensions/lib/herdr-fresh-call.ts +919 -40
  49. package/pi-extensions/lib/herdr-resume-call.ts +810 -0
  50. package/pi-extensions/lib/meta-session.ts +13 -8
  51. package/pi-extensions/lib/mux-fresh-call.ts +23 -26
  52. package/pi-extensions/lib/mux-resume-call.ts +95 -23
  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/remote-pair-send.ts +7 -4
  58. package/pi-extensions/lib/resume-launch-identity.ts +31 -12
  59. package/pi-extensions/meta-bridge-pi-durable.ts +52 -79
  60. package/run.sh +417 -654
  61. package/scripts/build-bridge.sh +10 -0
  62. package/scripts/check-capability-bundle-reach.ts +86 -0
  63. package/scripts/check-claude-launch.ts +341 -0
  64. package/scripts/check-entwurf-self-address.ts +3 -3
  65. package/scripts/check-entwurf-v2-surface.ts +94 -98
  66. package/scripts/check-fresh-call-dispatch.ts +70 -9
  67. package/scripts/check-fresh-cut-gate.sh +23 -9
  68. package/scripts/check-gate-qualification.ts +24 -17
  69. package/scripts/check-herdr-activation.ts +301 -23
  70. package/scripts/check-herdr-fresh-call.ts +161 -25
  71. package/scripts/check-herdr-plugin-build.ts +173 -1
  72. package/scripts/check-herdr-plugin.ts +28 -2
  73. package/scripts/check-herdr-runtime-bootstrap.ts +136 -0
  74. package/scripts/check-install-surface.ts +1236 -6
  75. package/scripts/check-meta-doctor-oracle.sh +1 -1
  76. package/scripts/check-meta-facts.ts +20 -1
  77. package/scripts/check-mux-resume-call.ts +48 -3
  78. package/scripts/check-peer-facts.ts +39 -2
  79. package/scripts/cli-help.mjs +222 -0
  80. package/scripts/dev-bin.sh +39 -2
  81. package/scripts/herdr-activation.mjs +28 -6
  82. package/scripts/herdr-operator-link.mjs +389 -0
  83. package/scripts/herdr-plugin-activate.mjs +5 -0
  84. package/scripts/herdr-plugin-deactivate.mjs +18 -5
  85. package/scripts/herdr-rails.mjs +43 -7
  86. package/scripts/herdr-runtime.mjs +27 -0
  87. package/scripts/meta-bridge-fresh-cut.ts +1 -27
  88. package/scripts/meta-bridge-prune.ts +1 -1
  89. package/scripts/meta-facts.ts +1 -1
  90. package/scripts/mutants/agent-guidance.json +160 -0
  91. package/scripts/mutants/bridge-boot-resume.json +6 -8
  92. package/scripts/mutants/capability-bundle-reach.json +124 -0
  93. package/scripts/mutants/claude-launch.json +142 -0
  94. package/scripts/mutants/cli-help-source.json +420 -0
  95. package/scripts/mutants/control-send-receipt.json +21 -4
  96. package/scripts/mutants/dev-gate-dispatch.json +109 -0
  97. package/scripts/mutants/fresh-call-dispatch.json +38 -2
  98. package/scripts/mutants/fresh-cut.json +15 -0
  99. package/scripts/mutants/herdr-activation.json +128 -0
  100. package/scripts/mutants/herdr-fresh-call.json +278 -5
  101. package/scripts/mutants/herdr-operator-link.json +318 -0
  102. package/scripts/mutants/herdr-plugin-build.json +55 -0
  103. package/scripts/mutants/herdr-plugin.json +17 -0
  104. package/scripts/mutants/herdr-resume-call.json +288 -0
  105. package/scripts/mutants/herdr-runtime-bootstrap.json +48 -0
  106. package/scripts/mutants/meta-facts.json +15 -0
  107. package/scripts/mutants/mux-fresh-call.json +14 -3
  108. package/scripts/mutants/mux-resume-call.json +89 -3
  109. package/scripts/mutants/peer-facts.json +15 -0
  110. package/scripts/mutants/pi-durable.json +996 -6
  111. package/scripts/mutants/remote-pair-send.json +21 -2
  112. package/scripts/mutants/self-address.json +10 -10
  113. package/scripts/mutants/source-install.json +35 -0
  114. package/scripts/mutants/v2-surface.json +50 -64
  115. package/scripts/mutants/v2-visible-resume.json +266 -6
  116. package/scripts/peer-facts.ts +1 -1
  117. package/scripts/smoke-agy-install-state.sh +66 -0
  118. package/scripts/smoke-meta-prune.sh +23 -0
  119. package/scripts/smoke-omp-fresh-live.ts +12 -3
  120. package/scripts/smoke-omp-receive-live.ts +7 -1
  121. package/scripts/smoke-pi-durable-fresh-live.ts +12 -2
  122. package/team.json.example +39 -0
@@ -1,6 +1,6 @@
1
1
  /**
2
- * entwurf-v2-visible-resume — stand a dormant pi or Claude Code citizen back up in a window the
3
- * operator can see, under the same garden id, and say only what was actually observed.
2
+ * entwurf-v2-visible-resume — stand a dormant pi, pi-durable or Claude Code citizen back up in a window
3
+ * (or herdr tab) the operator can see, under the same garden id, and say only what was actually observed.
4
4
  *
5
5
  * ── Two backends, one shape (#147 L2) ──
6
6
  *
@@ -19,7 +19,17 @@
19
19
  * timeout, a store write failure, a launch after the re-read) is invisible to this
20
20
  * proof. Back = a NEW live owner, in the pane this call opened, re-armed the marker
21
21
  * for the same session with a fresh binding the current record also carries (same
22
- * createdAt). The old marker read again is never success.
22
+ * createdAt). The old marker read again is never success. "In the pane" is the owner
23
+ * pid equal to that pane's direct child: tmux's pane pid, or on herdr (#157 stage 4)
24
+ * the `pane.process_info` report of the pane ONE layout.apply created.
25
+ *
26
+ * pi-durable dormant = THIS process could reserve the session's directory D (#157 B3): the
27
+ * owner-fence claim's holder is proven dead, or it closed clean, and the record and
28
+ * its C4 location agree — decided by `reservePiDurableSession` inside its own
29
+ * record-arm then S_D holds, under the per-target lock this module already holds as
30
+ * the outer scope. The launched child adopts that one reservation from its closed
31
+ * context. Back = its adoption write: the claim carries this reservation's generation
32
+ * as its `adoptedFrom` — an adoption, never readiness, liveness or a turn.
23
33
  *
24
34
  * Every other backend is the leaf's named refusal. The argv is a fixed table per backend; the caller
25
35
  * supplies only the target.
@@ -28,12 +38,20 @@
28
38
  *
29
39
  * surface (entwurf-control.ts · mcp bridge index.ts) composition root: supplies `launch`
30
40
  * └─ entwurf-v2-visible-resume (this file) v2 leaves ONLY: lock, record, socket
31
- * └─ deps.launch(argv, cwd) ── INJECTED SEAM ──→ mux-resume-call → placement leaf
41
+ * └─ deps.launch(argv, cwd) ── INJECTED SEAM ──→ fresh-call-dispatch (shared rail choice)
42
+ * ├→ mux-resume-call → placement leaf
43
+ * └→ herdr-resume-call (inside herdr: pi via agent
44
+ * start; pi-durable and Claude Code via ONE
45
+ * layout.apply)
32
46
  *
33
47
  * The seam is what keeps `docs/mux-launch-rail.md` §11 intact in both directions: v2 never
34
48
  * imports mux, mux never imports entwurf core. This module cannot open a window; it can only
35
49
  * ask whoever wired it to. That is deliberate — it means the delivery fence stays exactly where
36
- * `check-mux-launch` scans for it.
50
+ * `check-mux-launch` scans for it. The herdr rail (#157 G1a) reaches it through the same seam and
51
+ * the same fence: this module imports no herdr module either, and learns which rail ran only from
52
+ * the `rail` its launch handle carries. A herdr launch is two mutations (tab create, then agent
53
+ * start), so the seam may also answer with a failure AFTER something was created; that is its own
54
+ * result, never a refusal that says no window was opened.
37
55
  *
38
56
  * ── Why a resume is structurally SMALLER than a fresh call on identity, and LARGER on the lock ──
39
57
  *
@@ -63,8 +81,9 @@
63
81
  *
64
82
  * ── Two receipts, never merged ──
65
83
  *
66
- * `launch` says tmux made a window and was asked to start the runtime. `observation` says whether
67
- * the citizen came back under the same id (pi: its socket; Claude Code: its receiver marker).
84
+ * `launch` says tmux made a window (or, inside herdr, herdr made a tab) and was
85
+ * asked to start the runtime. `observation` says whether the citizen came back under the same id (pi:
86
+ * its socket; pi-durable: a child's adoption of the reservation; Claude Code: its receiver marker).
68
87
  * Neither says the resumed citizen did anything. This verb submits no prompt. For pi a resume ran
69
88
  * no turn (measured 2026-08-06, pi only — the transcript stayed byte-identical); for Claude Code
70
89
  * the runtime's own activity after resume (its SessionStart hook re-arms the doorbell, and unread
@@ -72,10 +91,13 @@
72
91
  * The socket-only description in the observation section above is the pi half; the Claude Code
73
92
  * half reads the receiver marker and the record instead.
74
93
  */
94
+ import path from "node:path";
75
95
  import { ACP_MODEL_NOT_CURATED_HINT, AcpModelNotCuratedError } from "./acp/model-ids.js";
96
+ import { ENTWURF_RESUME_REJECT_HINTS } from "./agent-guidance.js";
76
97
  import { buildResumePiArgs } from "./entwurf-resume-args.js";
77
98
  import { ENTWURF_V2_LOCK_DIR, acquireLock as realAcquireLock, releaseLock as realReleaseLock, } from "./entwurf-v2-lock.js";
78
- import { classifyMarkerOwner, metaReceiverMarkerPath, parseMetaReceiverMarker, probePidExistence, processStartKey, readStoreRecordFile, } from "./meta-session.js";
99
+ import { classifyMarkerOwner, defaultMetaMailboxDir, defaultMetaReceiversDir, defaultMetaSendersDir, defaultMetaSessionsDir, metaReceiverMarkerPath, parseMetaReceiverMarker, probePidExistence, processStartKey, readStoreRecordFile, } from "./meta-session.js";
100
+ import { observePiDurableReservation, PiDurableOwnerFenceError, reservePiDurableSession, withdrawPiDurableReservation, } from "./pi-durable-owner-fence.js";
79
101
  import { ResumeBackendUnsupportedError, resolveResumeLaunchIdentity, } from "./resume-launch-identity.js";
80
102
  import { isValidSessionId } from "./session-id.js";
81
103
  import { CONTROL_SOCKET_DIR, inspectTargetControlSocket, mapInspectionToLiveness, } from "./socket-discovery.js";
@@ -97,6 +119,19 @@ export function makeVisibleResumeDeps(launch, overrides = {}) {
97
119
  probeSocket: (socketPath) => probeSocketLiveness(socketPath),
98
120
  inspectReceiver: (gid) => inspectReceiverMarker(gid),
99
121
  launch,
122
+ // The four roots THIS process resolves now, handed to the reserve as given: a split shape is
123
+ // the reserve's own named refusal, never merged here.
124
+ reservePiDurable: (gid) => reservePiDurableSession({
125
+ gardenId: gid,
126
+ roots: {
127
+ sessionsDir: defaultMetaSessionsDir(),
128
+ mailboxDir: defaultMetaMailboxDir(),
129
+ sendersDir: defaultMetaSendersDir(),
130
+ receiversDir: defaultMetaReceiversDir(),
131
+ },
132
+ }),
133
+ withdrawPiDurable: (reservation) => withdrawPiDurableReservation(reservation),
134
+ observePiDurable: (reservation) => observePiDurableReservation(reservation),
100
135
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
101
136
  now: () => Date.now(),
102
137
  ...overrides,
@@ -218,7 +253,7 @@ export async function visibleResume(targetGardenId, deps) {
218
253
  return {
219
254
  ok: false,
220
255
  reason: err.reason,
221
- detail: `${targetGardenId} is a ${err.backend} citizen; only pi and Claude Code citizens can be reopened ` +
256
+ detail: `${targetGardenId} is a ${err.backend} citizen; only pi, pi-durable and Claude Code citizens can be reopened ` +
222
257
  `under the same garden id`,
223
258
  };
224
259
  }
@@ -226,6 +261,8 @@ export async function visibleResume(targetGardenId, deps) {
226
261
  }
227
262
  if (identity.backend === "claude-code")
228
263
  return await resumeClaudeCode(targetGardenId, identity, deps);
264
+ if (identity.backend === "pi-durable")
265
+ return await resumePiDurable(targetGardenId, deps);
229
266
  // Liveness preflight, still under the lock. A live citizen is addressed with
230
267
  // `entwurf_v2 fire-and-forget`, not reopened — and refusing here is also what stops two
231
268
  // windows existing for one garden id.
@@ -260,9 +297,14 @@ export async function visibleResume(targetGardenId, deps) {
260
297
  provider: identity.provider,
261
298
  model: identity.model,
262
299
  });
263
- const launched = deps.launch({ backend: "pi", cwd: identity.cwd, runtimeArgs });
264
- if (!launched.ok) {
265
- return { ok: false, reason: "launch-refused", detail: `${launched.reason} — ${launched.hint}` };
300
+ const launched = await deps.launch({ backend: "pi", cwd: identity.cwd, runtimeArgs });
301
+ if (!launched.ok)
302
+ return launchNotLaunched(launched);
303
+ // Only a pi-durable launch carries a layout handle (#157 B3). Handed to a pi resume, it is a
304
+ // wiring defect, not an outcome: whatever was opened stays as it is and the lock is released.
305
+ if (launched.handle.rail === "herdr-layout" || launched.handle.rail === "herdr-layout-claude") {
306
+ throw new Error(`entwurf-v2-visible-resume: the launcher handed a pi resume of ${targetGardenId} a ${launched.handle.rail} handle — ` +
307
+ `only a pi-durable or Claude Code resume launches through layout.apply. Nothing was observed or closed.`);
266
308
  }
267
309
  const observation = await observeSameGidSocket(targetGardenId, mapped.socketPath, deps);
268
310
  return {
@@ -275,6 +317,19 @@ export async function visibleResume(targetGardenId, deps) {
275
317
  release();
276
318
  }
277
319
  }
320
+ /**
321
+ * A launch that did not become a launch, kept in its own shape. A refusal created nothing and is
322
+ * `launch-refused`. A failure that carries what it created (herdr: after the tab create) is
323
+ * `launch-failed`, with the vendor code, recovery and coordinates passed through untouched — folding
324
+ * it into a refusal is what would print "No window was opened" over a tab that may be on screen.
325
+ * Neither is retried, and neither is followed by an observation.
326
+ */
327
+ function launchNotLaunched(launched, note = "") {
328
+ const detail = `${launched.reason} — ${launched.hint}${note}`;
329
+ return "failure" in launched
330
+ ? { ok: false, reason: "launch-failed", detail, failure: launched.failure }
331
+ : { ok: false, reason: "launch-refused", detail };
332
+ }
278
333
  /**
279
334
  * The Claude Code half, still under the caller's lock. Dormancy needs a DEAD owner verdict on the
280
335
  * receiver marker AND the binding: the record re-read AFTER that verdict must still name the same
@@ -356,26 +411,255 @@ async function resumeClaudeCode(targetGardenId, identity, deps) {
356
411
  `after the dead owner's, and its process is not proven gone`,
357
412
  };
358
413
  }
359
- const launched = deps.launch({
414
+ const launched = await deps.launch({
360
415
  backend: "claude-code",
361
416
  cwd: current.cwd,
362
417
  runtimeArgs: ["--resume", current.nativeSessionId],
363
418
  });
419
+ if (!launched.ok)
420
+ return launchNotLaunched(launched);
421
+ // The observation below joins the receiver marker's owner to the pane's DIRECT child: tmux's pane
422
+ // pid, or herdr's process report for the layout pane (#157 stage 4). A handle of any other rail
423
+ // carries no such pid, so it is a wiring defect, not an outcome: it fails loud rather than watching
424
+ // for a pid that does not exist. Whatever that launcher opened is left as it is; the lock is still
425
+ // released.
426
+ const handle = launched.handle;
427
+ if (handle.rail !== "tmux" && handle.rail !== "herdr-layout-claude") {
428
+ throw new Error(`entwurf-v2-visible-resume: the launcher handed a Claude Code resume of ${targetGardenId} a ${handle.rail} ` +
429
+ `handle — its observation joins the opened pane's direct child, which that rail does not carry. Nothing was ` +
430
+ `observed or closed.`);
431
+ }
432
+ const launch = { backend: "claude-code", targetGardenId, nativeSessionId: current.nativeSessionId, handle };
433
+ // herdr did not name the new pane's direct child: there is nothing to join, so no wait runs. The tab
434
+ // is open; nothing is retried or closed.
435
+ if (handle.rail === "herdr-layout-claude" && "unread" in handle.paneChild) {
436
+ return {
437
+ ok: true,
438
+ launch,
439
+ observation: { kind: "pane-process-unread", cause: handle.paneChild.unread, waitedMs: 0 },
440
+ };
441
+ }
442
+ const panePid = handle.rail === "tmux" ? handle.panePid : handle.paneChild.pid;
443
+ const observation = await observeReceiverReattached(targetGardenId, current, { ownerPid: marker.ownerPid, ownerStartKey: marker.ownerStartKey, attachIncarnation: current.attachIncarnation }, panePid, deps);
444
+ return { ok: true, launch, observation };
445
+ }
446
+ /**
447
+ * #157 B3: the adopt launch for ONE reservation — the managed verb with its closed context
448
+ * `{v:1, gardenId, metaRoot, sourceGeneration}` (no session, cwd, profile, model, prompt, module,
449
+ * nonce or callback rides it), the reservation's cwd, and the fixed five-name projection of its raw
450
+ * profile and its meta root. Pure: it reads nothing and writes nothing.
451
+ */
452
+ export function buildPiDurableAdoptLaunch(reservation) {
453
+ const { metaRoot } = reservation;
454
+ const context = {
455
+ v: 1,
456
+ gardenId: reservation.gardenId,
457
+ metaRoot,
458
+ sourceGeneration: reservation.generation,
459
+ };
460
+ return {
461
+ backend: "pi-durable",
462
+ cwd: reservation.cwd,
463
+ runtimeArgs: ["pi-durable", "--entwurf-adopt", JSON.stringify(context)],
464
+ piDurableAdoptEnv: {
465
+ PI_CODING_AGENT_DIR: reservation.profile,
466
+ ENTWURF_META_SESSIONS_DIR: path.join(metaRoot, "meta-sessions"),
467
+ ENTWURF_META_MAILBOX_DIR: path.join(metaRoot, "meta-mailbox"),
468
+ ENTWURF_META_SENDERS_DIR: path.join(metaRoot, "meta-senders"),
469
+ ENTWURF_META_RECEIVERS_DIR: path.join(metaRoot, "meta-receivers"),
470
+ },
471
+ };
472
+ }
473
+ /**
474
+ * A reserve refusal this verb answers by name; every other fence refusal keeps its own cause. A live holder is not
475
+ * an address (S3 joint review D4): a reservation's parent is a bridge waiting for a child, which entwurf_v2 cannot
476
+ * reach, and any other live holder is a process this verb did not judge contact-ready — so neither answer tells the
477
+ * caller to send. The branch reads the refusal's structured holder state, never its message.
478
+ */
479
+ function piDurableReserveRefusal(targetGardenId, err) {
480
+ if (err.reason === "owner-live") {
481
+ if (err.holderState === "reserved") {
482
+ return {
483
+ ok: false,
484
+ reason: "target-live",
485
+ detail: `${targetGardenId}'s pi-durable session is reserved for a child to adopt by a live process (${err.message}); ` +
486
+ `while that reservation stays unadopted under that live process a resume of ${targetGardenId} refuses on it; ` +
487
+ `nothing clears it automatically, a child that adopts it becomes its holder, and entwurf_v2 does not reach a reservation`,
488
+ };
489
+ }
490
+ return {
491
+ ok: false,
492
+ reason: "target-live",
493
+ detail: `${targetGardenId}'s pi-durable session is held by a live process (${err.message}); if that process is the ` +
494
+ `running citizen, entwurf_v2 reaches it only through the rail its own dispatch decides`,
495
+ };
496
+ }
497
+ if (err.reason === "owner-uncertain" || err.reason === "owner-other-host") {
498
+ return {
499
+ ok: false,
500
+ reason: "target-indeterminate",
501
+ detail: `the holder of ${targetGardenId}'s pi-durable session could not be proven gone on this host (${err.message}), ` +
502
+ `and resuming a session that is still open would put two processes on one transcript`,
503
+ };
504
+ }
505
+ return null;
506
+ }
507
+ const messageOf = (err) => (err instanceof Error ? err.message : String(err));
508
+ /**
509
+ * A reserve whose write committed and whose lock release then failed (S3 joint review D3). It says only what that
510
+ * call wrote and what its holder implies; the release's own words say what it left. Nothing is withdrawn, retried
511
+ * or repaired here.
512
+ */
513
+ function piDurableReservedThenFailed(targetGardenId, err, written) {
514
+ const residue = err.reason === "tx-release-failed"
515
+ ? `; if the transaction file that release named remains, that process ending does not remove it, and it fails closed until an operator removes it`
516
+ : "";
517
+ return (`entwurf-v2-visible-resume: the reserve of ${targetGardenId} WROTE its reservation of ${written.resource} at ` +
518
+ `generation ${written.generation} for pid ${written.ownerPid}, then a lock release failed (${err.message}). ` +
519
+ `Nothing was launched and nothing withdrew that reservation: while it stays unadopted under live pid ` +
520
+ `${written.ownerPid}, a resume of ${targetGardenId} refuses, and nothing clears it automatically${residue}`);
521
+ }
522
+ /**
523
+ * A withdrawal that failed (S3 joint review D1). Only what the failure itself records is stated: a write this call
524
+ * made, or a transaction file the release left; any other failure leaves the claim's state unestablished here.
525
+ */
526
+ function piDurableWithdrawFailed(targetGardenId, held, reservation, err) {
527
+ const residue = `if that transaction file remains, this process ending does not remove it, and it fails closed until an operator removes it`;
528
+ if (err instanceof PiDurableOwnerFenceError && err.written !== undefined) {
529
+ return (` — this call wrote the withdrawal of ${held} (the claim back to the donor's ${err.written.state} row at ` +
530
+ `generation ${err.written.generation}), then releasing its transaction failed (${err.message}); ${residue}`);
531
+ }
532
+ if (err instanceof PiDurableOwnerFenceError && err.reason === "tx-release-failed") {
533
+ return (` — withdrawing ${held} FAILED at its transaction release (${err.message}); whether this call ` +
534
+ `wrote anything is not recorded, and ${residue}`);
535
+ }
536
+ return (` — withdrawing ${held} FAILED (${messageOf(err)}); what its claim holds now is not established here: while it ` +
537
+ `stays reserved, unadopted, under this live process (pid ${reservation.owner.pid}), a resume of ${targetGardenId} ` +
538
+ `refuses`);
539
+ }
540
+ /**
541
+ * The pi-durable half (#157 B3), still under the caller's per-target lock, which stays the outer
542
+ * scope: the reserve takes record-arm then S_D itself and releases both before it returns, so before
543
+ * any launch. A reserve that refuses on eligibility writes nothing; a reserve whose lock release fails AFTER its
544
+ * write is named as such (its `written`), with the reservation it left. Neither opens anything.
545
+ *
546
+ * From the reservation on, this process holds D for one child, and what happens to that hold is
547
+ * decided by the launch outcome alone (L3-A1 §6), never by a guess:
548
+ *
549
+ * no launch request was made definitely none created → withdraw
550
+ * the seam refused (`ok:false`, no `failure`) the seam's contract: refused before anything was
551
+ * created (tmux: one of its named reasons; herdr:
552
+ * decided before the layout write) → withdraw
553
+ * a failure whose recovery is `none` that rail's own none-created certainty → withdraw
554
+ * a failure whose recovery is anything else something MAY exist → the reservation is KEPT
555
+ * the launcher threw unknown, whatever its position → KEPT
556
+ * a launch a child may adopt it → KEPT, then observed once
557
+ *
558
+ * A withdrawal that fails is reported with the refusal it followed and is never retried. A kept
559
+ * reservation is said so in the answer: it names this process as its holder, and while it stays unadopted
560
+ * under this live process a later resume of the same id refuses; nothing clears it automatically, and a
561
+ * child that adopts it becomes its holder.
562
+ */
563
+ async function resumePiDurable(targetGardenId, deps) {
564
+ let reservation;
565
+ try {
566
+ reservation = await deps.reservePiDurable(targetGardenId);
567
+ }
568
+ catch (err) {
569
+ if (err instanceof PiDurableOwnerFenceError) {
570
+ const refused = piDurableReserveRefusal(targetGardenId, err);
571
+ if (refused !== null)
572
+ return refused;
573
+ // A release that failed after the reservation was WRITTEN (S3 joint review D3): the reservation exists,
574
+ // so the answer names it and what it blocks, beside the release's own cause; nothing launches or withdraws.
575
+ if (err.written !== undefined)
576
+ throw new Error(piDurableReservedThenFailed(targetGardenId, err, err.written));
577
+ }
578
+ throw err;
579
+ }
580
+ const held = `${targetGardenId}'s reservation of ${reservation.resource} at generation ${reservation.generation}`;
581
+ const withdraw = async () => {
582
+ try {
583
+ await deps.withdrawPiDurable(reservation);
584
+ return ` — ${held} was withdrawn, since nothing was created`;
585
+ }
586
+ catch (err) {
587
+ return piDurableWithdrawFailed(targetGardenId, held, reservation, err);
588
+ }
589
+ };
590
+ const kept = ` — ${held} was KEPT, because something may exist that can still adopt it; it names this process ` +
591
+ `(pid ${reservation.owner.pid}) as its holder: while it stays unadopted under this live process a resume of ` +
592
+ `${targetGardenId} refuses, nothing clears it automatically, and a child that adopts it becomes its holder`;
593
+ let input;
594
+ try {
595
+ input = buildPiDurableAdoptLaunch(reservation);
596
+ }
597
+ catch (err) {
598
+ throw new Error(`entwurf-v2-visible-resume: ${messageOf(err)}${await withdraw()}`);
599
+ }
600
+ let launched;
601
+ try {
602
+ launched = await deps.launch(input);
603
+ }
604
+ catch (err) {
605
+ throw new Error(`entwurf-v2-visible-resume: the pi-durable launch of ${targetGardenId} threw: ${messageOf(err)}${kept}`);
606
+ }
364
607
  if (!launched.ok) {
365
- return { ok: false, reason: "launch-refused", detail: `${launched.reason} — ${launched.hint}` };
608
+ const noneCreated = !("failure" in launched) || launched.failure.recovery.outcome === "none";
609
+ return launchNotLaunched(launched, noneCreated ? await withdraw() : kept);
610
+ }
611
+ // A herdr agent-start handle belongs to the pi rail only: a pi-durable resume launches through
612
+ // tmux or ONE layout.apply. Handed one, it is a wiring defect; what was opened stays as it is.
613
+ if (launched.handle.rail === "herdr" || launched.handle.rail === "herdr-layout-claude") {
614
+ throw new Error(`entwurf-v2-visible-resume: the launcher handed a pi-durable resume of ${targetGardenId} a ` +
615
+ `${launched.handle.rail === "herdr" ? "herdr agent-start" : launched.handle.rail} handle — nothing was observed or closed${kept}`);
366
616
  }
367
- const observation = await observeReceiverReattached(targetGardenId, current, { ownerPid: marker.ownerPid, ownerStartKey: marker.ownerStartKey, attachIncarnation: current.attachIncarnation }, launched.handle.panePid, deps);
617
+ const observation = await observePiDurableAdoption(reservation, deps);
368
618
  return {
369
619
  ok: true,
370
620
  launch: {
371
- backend: "claude-code",
621
+ backend: "pi-durable",
372
622
  targetGardenId,
373
- nativeSessionId: current.nativeSessionId,
623
+ cwd: reservation.cwd,
624
+ reservation: {
625
+ generation: reservation.generation,
626
+ resource: reservation.resource,
627
+ ownerPid: reservation.owner.pid,
628
+ },
374
629
  handle: launched.handle,
375
630
  },
376
631
  observation,
377
632
  };
378
633
  }
634
+ /**
635
+ * ONE bounded operation, the pi-durable twin of `observeSameGidSocket` (#157 B3): read the
636
+ * reservation's D claim until a child's adoption write carries its generation, the claim is lost or
637
+ * unreadable, or the same fixed wait ends. Every read is the fence's strict reader; nothing here
638
+ * writes, withdraws, retries or watches past the deadline. A read that throws past that reader stops
639
+ * the wait as `unobserved` with its cause: the window is open, and the launch receipt must survive.
640
+ */
641
+ export async function observePiDurableAdoption(reservation, deps, timeoutMs = RESUME_OBSERVE_TIMEOUT_MS, intervalMs = RESUME_OBSERVE_INTERVAL_MS) {
642
+ const started = deps.now();
643
+ for (;;) {
644
+ let seen;
645
+ try {
646
+ seen = deps.observePiDurable(reservation);
647
+ }
648
+ catch (err) {
649
+ return { kind: "unobserved", reason: "observe-threw", detail: messageOf(err), waitedMs: deps.now() - started };
650
+ }
651
+ if (seen.class === "adopted")
652
+ return { kind: "adopted", waitedMs: deps.now() - started };
653
+ if (seen.class === "lost")
654
+ return { kind: "reservation-lost", waitedMs: deps.now() - started };
655
+ if (seen.class === "unobserved") {
656
+ return { kind: "unobserved", reason: seen.reason, detail: seen.detail, waitedMs: deps.now() - started };
657
+ }
658
+ if (deps.now() - started >= timeoutMs)
659
+ return { kind: "not-yet-adopted", waitedMs: deps.now() - started };
660
+ await deps.sleep(intervalMs);
661
+ }
662
+ }
379
663
  /**
380
664
  * ONE bounded operation, the Claude Code twin of `observeSameGidSocket`: wait for a NEW live owner
381
665
  * — in the pane this call opened (`panePid`), a join, not an address — to re-arm this garden id's
@@ -441,19 +725,22 @@ export async function observeSameGidSocket(targetGardenId, socketPath, deps, tim
441
725
  await deps.sleep(intervalMs);
442
726
  }
443
727
  }
728
+ // The static record/liveness hint text is authored in root ENTWURF.json (#154 C1-C); the reasons,
729
+ // this total table and the renderer stay here. acp-model-not-curated keeps the shared ACP constant,
730
+ // and the launch-stage mux hints folded into `detail` stay behind the mux import fence.
444
731
  const REJECT_HINT = {
445
- "target-invalid": "no citizen has that id; discover targets with entwurf_peers",
446
- "target-backend-unsupported": "that citizen's backend has no same-id resume (only pi and Claude Code do) — reach it with entwurf_v2, or open a fresh sibling",
732
+ "target-invalid": ENTWURF_RESUME_REJECT_HINTS["target-invalid"],
733
+ "target-backend-unsupported": ENTWURF_RESUME_REJECT_HINTS["target-backend-unsupported"],
447
734
  "acp-model-not-curated": ACP_MODEL_NOT_CURATED_HINT,
448
- "target-locked": "another resume or dispatch holds this citizen's lock right now",
449
- "target-live": "that citizen is already running — this verb only reopens dormant ones",
450
- "target-indeterminate": "the citizen could not be proven dormant (an inconclusive socket probe or receiver owner), so nothing was started",
451
- "target-receiver-absent": "a Claude Code citizen is proven dormant only by its receiver marker, and it has none — if its window is closed, reopen it yourself",
452
- "target-receiver-corrupt": "the citizen's receiver marker cannot be read as a marker, so its owner cannot be judged",
453
- "target-receiver-mismatch": "the receiver marker under this garden id names another backend or native session than the record — look at the store before anything reopens",
454
- "target-receiver-unbound": "the dead receiver owner is not bound to the record's last arm (none observed, a later attach, or the record moved), so a Claude Code process may still hold the session — if its window is closed, reopen it yourself",
455
- "target-address-conflict": "that citizen's control-socket path is corrupt",
456
- "launch-refused": "tmux was never asked to open a window",
735
+ "target-locked": ENTWURF_RESUME_REJECT_HINTS["target-locked"],
736
+ "target-live": ENTWURF_RESUME_REJECT_HINTS["target-live"],
737
+ "target-indeterminate": ENTWURF_RESUME_REJECT_HINTS["target-indeterminate"],
738
+ "target-receiver-absent": ENTWURF_RESUME_REJECT_HINTS["target-receiver-absent"],
739
+ "target-receiver-corrupt": ENTWURF_RESUME_REJECT_HINTS["target-receiver-corrupt"],
740
+ "target-receiver-mismatch": ENTWURF_RESUME_REJECT_HINTS["target-receiver-mismatch"],
741
+ "target-receiver-unbound": ENTWURF_RESUME_REJECT_HINTS["target-receiver-unbound"],
742
+ "target-address-conflict": ENTWURF_RESUME_REJECT_HINTS["target-address-conflict"],
743
+ "launch-refused": ENTWURF_RESUME_REJECT_HINTS["launch-refused"],
457
744
  };
458
745
  /**
459
746
  * ONE renderer — the same reason `mux-fresh-call` has one: it keeps the operator-visible answer
@@ -466,18 +753,29 @@ const REJECT_HINT = {
466
753
  */
467
754
  export function renderVisibleResume(result, attribution = { actor: null }) {
468
755
  if (!result.ok) {
756
+ if (result.reason === "launch-failed")
757
+ return renderLaunchFailed(result);
469
758
  return {
470
759
  text: `entwurf_resume_call rejected: ${result.reason} — ${REJECT_HINT[result.reason]}. ` +
471
760
  `${result.detail}. No window was opened.`,
472
761
  isError: true,
473
762
  };
474
763
  }
475
- const h = result.launch.handle;
476
764
  // Who asked is recorded, and it is not authority: the verb decides from the target's record and
477
765
  // live rail facts alone, whoever the caller is.
478
766
  const actor = attribution.actor === null
479
767
  ? ` actor: unattributed (the caller has no record-backed garden identity)\n`
480
768
  : ` actor: ${attribution.actor} (record-backed caller; attribution, not authority)\n`;
769
+ // #157 B3: pi-durable has its own receipts (a reservation, an adoption), on either rail.
770
+ if (result.launch.backend === "pi-durable") {
771
+ return renderPiDurableResume(result.launch, result.observation, actor);
772
+ }
773
+ const h = result.launch.handle;
774
+ // The herdr rail has its own coordinates and its own words; the tmux text below stays as it was.
775
+ if (h.rail === "herdr")
776
+ return renderHerdrPiResume(result, h, actor);
777
+ if (h.rail === "herdr-layout-claude")
778
+ return renderHerdrClaudeResume(result, h, actor);
481
779
  const window = ` runtime: ${h.runtimePath} (requested start cwd ${h.cwd})\n` +
482
780
  ` window: ${h.windowId} (index ${h.windowIndex}) in session ${h.sessionId}\n` +
483
781
  ` pane: ${h.paneId} pid ${h.panePid}\n`;
@@ -528,3 +826,174 @@ export function renderVisibleResume(result, attribution = { actor: null }) {
528
826
  isError: false,
529
827
  };
530
828
  }
829
+ /**
830
+ * The pi-durable resume (#157 B3), on tmux or on herdr's one layout.apply. Three facts kept apart: the
831
+ * reservation this process holds, the LAUNCH that was asked to adopt it, and what the D claim said
832
+ * after one bounded read. `adopted` says a child wrote its adoption — not that it is ready, alive,
833
+ * attached, addressable or that anything was recalled; entwurf_peers is where its rail is read.
834
+ */
835
+ function renderPiDurableResume(launch, observation, actor) {
836
+ const h = launch.handle;
837
+ const place = h.rail === "tmux" ? "window" : "tab";
838
+ const where = h.rail === "tmux"
839
+ ? ` runtime: ${h.runtimePath} pi-durable --entwurf-adopt (requested start cwd ${h.cwd})\n` +
840
+ ` window: ${h.windowId} (index ${h.windowIndex}) in session ${h.sessionId}\n` +
841
+ ` pane: ${h.paneId} pid ${h.panePid}\n`
842
+ : ` runtime: ${h.runtime} pi-durable --entwurf-adopt (requested start cwd ${h.cwd})\n` +
843
+ ` tab: ${h.herdrTabId} in workspace ${h.herdrWorkspaceId} — a NEW tab from ONE layout.apply, opened without taking focus\n` +
844
+ ` pane: ${h.herdrPaneId} — herdr VIEW coordinates, not an address\n`;
845
+ const r = launch.reservation;
846
+ const heldBy = `this process (pid ${r.ownerPid})`;
847
+ const observed = observation.kind === "adopted"
848
+ ? `OBSERVATION receipt: adopted — after ${observation.waitedMs}ms the claim of ${r.resource} carries generation ` +
849
+ `${r.generation} as its adoptedFrom: a child process wrote its adoption of this reservation. That is not ` +
850
+ `readiness, liveness, an attach, a turn or a recall — entwurf_peers shows whether its rail answers. This ` +
851
+ `call submitted no prompt.`
852
+ : observation.kind === "not-yet-adopted"
853
+ ? `OBSERVATION receipt: not-yet-adopted — after ${observation.waitedMs}ms the claim of ${r.resource} was still ` +
854
+ `this reservation. The ${place} is open and visible; read it directly. The reservation stays held by ` +
855
+ `${heldBy}: a child there can still adopt it and become its holder; while it stays unadopted under this live ` +
856
+ `process a resume of ${launch.targetGardenId} refuses, and nothing clears it automatically. Nothing was ` +
857
+ `retried, withdrawn or killed.`
858
+ : observation.kind === "reservation-lost"
859
+ ? `OBSERVATION receipt: reservation-lost — after ${observation.waitedMs}ms the claim of ${r.resource} ` +
860
+ `names neither this reservation nor an adoption of it (another owner, a reclaim or a withdrawal ` +
861
+ `replaced it). The ${place} is open; read it directly. Nothing was retried or killed.`
862
+ : `OBSERVATION receipt: unobserved — the claim of ${r.resource} could not be read after ` +
863
+ `${observation.waitedMs}ms (${observation.reason}: ${observation.detail}). The ${place} is open and ` +
864
+ `visible; nothing was retried, withdrawn or killed.`;
865
+ return {
866
+ text: `[entwurf resume call ↻${h.rail === "tmux" ? "" : " herdr"}]\n` +
867
+ ` target: ${launch.targetGardenId}\n` +
868
+ ` backend: pi-durable\n` +
869
+ ` reserved: ${r.resource} at generation ${r.generation}, held by ${heldBy} for one child to adopt\n` +
870
+ where +
871
+ actor +
872
+ `\n` +
873
+ `LAUNCH receipt: ${h.rail === "tmux" ? "tmux created that window" : "herdr created that tab"} and was asked to ` +
874
+ `start the managed entwurf pi-durable with the closed adopt context of the reservation above, its raw ` +
875
+ `profile as PI_CODING_AGENT_DIR and its meta root's four ENTWURF_META_*_DIR children as its launch env, ` +
876
+ `in the record's cwd.\n` +
877
+ observed,
878
+ isError: false,
879
+ };
880
+ }
881
+ /**
882
+ * The pi resume on the herdr rail (#157 G1a). Same two receipts, kept apart the same way; the
883
+ * coordinates are herdr's VIEW of a tab, and herdr's own readiness or session report is not consulted
884
+ * — the same-gid control socket is the observation on both rails.
885
+ */
886
+ function renderHerdrPiResume(result, h, actor) {
887
+ if (result.launch.backend !== "pi") {
888
+ throw new Error("entwurf-v2-visible-resume: a herdr launch handle arrived on a non-pi resume — only a pi resume renders through the herdr agent-start view");
889
+ }
890
+ const observation = result.observation;
891
+ const observed = observation.kind === "socket-alive";
892
+ return {
893
+ text: `[entwurf resume call ↻ herdr]\n` +
894
+ ` target: ${result.launch.targetGardenId}\n` +
895
+ ` session: ${result.launch.sessionFile}\n` +
896
+ ` runtime: requested kind ${h.requestedKind} — herdr resolves the executable, we did not (requested start cwd ${h.cwd})\n` +
897
+ ` tab: ${h.herdrTabId} in workspace ${h.herdrWorkspaceId} — a NEW tab beside the caller's, opened without taking focus\n` +
898
+ ` pane: ${h.herdrPaneId} terminal ${h.herdrTerminalId} — herdr VIEW coordinates, not an address\n` +
899
+ ` agent: ${h.herdrAgentName} (herdr's name for this launch, random per call)\n` +
900
+ actor +
901
+ `\n` +
902
+ `LAUNCH receipt: herdr created that tab and was asked to start pi on the transcript above.\n` +
903
+ (observed
904
+ ? `OBSERVATION receipt: the control socket for ${result.launch.targetGardenId} answered after ` +
905
+ `${observation.waitedMs}ms — that citizen is addressable again, and entwurf_v2 fire-and-forget ` +
906
+ `now reaches it. No turn was run by this call. herdr's own agent status was not consulted.`
907
+ : `OBSERVATION receipt: resume-unobserved — the control socket did not answer within ` +
908
+ `${observation.waitedMs}ms. The tab is open and visible; read it directly. The lock was ` +
909
+ `released, nothing was retried, and nothing was closed.`),
910
+ isError: false,
911
+ };
912
+ }
913
+ /**
914
+ * The Claude Code resume on the herdr rail (#157 stage 4). The same two receipts as on tmux, kept
915
+ * apart the same way. The tab and pane are herdr's VIEW; the pane's direct child is herdr's process
916
+ * report for the pane this call created — the pid the observation joins, never an address.
917
+ */
918
+ function renderHerdrClaudeResume(result, h, actor) {
919
+ if (result.launch.backend !== "claude-code") {
920
+ throw new Error("entwurf-v2-visible-resume: a herdr Claude Code launch handle arrived on a non-Claude Code resume");
921
+ }
922
+ const target = result.launch.targetGardenId;
923
+ const observation = result.observation;
924
+ const child = "pid" in h.paneChild
925
+ ? `pid ${h.paneChild.pid} (herdr's process report for that pane; a join, not an address)`
926
+ : `unread — ${h.paneChild.unread}`;
927
+ const left = "The tab is open and visible; read it directly. The lock was released, nothing was retried, and nothing was closed.";
928
+ return {
929
+ text: `[entwurf resume call ↻ herdr]\n` +
930
+ ` target: ${target}\n` +
931
+ ` backend: claude-code\n` +
932
+ ` session: ${result.launch.nativeSessionId}\n` +
933
+ ` runtime: ${h.runtime} --resume — herdr resolved the executable, we did not (requested start cwd ${h.cwd})\n` +
934
+ ` tab: ${h.herdrTabId} in workspace ${h.herdrWorkspaceId} — a NEW tab from ONE layout.apply, opened without taking focus\n` +
935
+ ` pane: ${h.herdrPaneId} — herdr VIEW coordinates, not an address\n` +
936
+ ` child: ${child}\n` +
937
+ actor +
938
+ `\n` +
939
+ `LAUNCH receipt: herdr created that tab and was asked to start claude --resume on the session above.\n` +
940
+ (observation.kind === "receiver-armed"
941
+ ? `OBSERVATION receipt: receiver-armed — a NEW live Claude Code process, the direct child of the pane this ` +
942
+ `call opened (pid ${observation.ownerPid}), re-armed the receiver marker of ${target} for the same session ` +
943
+ `after ${observation.waitedMs}ms, with a fresh arm binding the record also carries (same createdAt). ` +
944
+ `entwurf_v2 fire-and-forget now reaches its mailbox. This call submitted no prompt; the runtime's own ` +
945
+ `activity after resume (e.g. a doorbell on unread mail) is unmeasured.`
946
+ : observation.kind === "pane-process-unread"
947
+ ? `OBSERVATION receipt: pane-process-unread — herdr did not report the direct child of the pane this ` +
948
+ `call opened (${observation.cause}), so no receiver marker could be joined to it and no wait ran. ${left}`
949
+ : observation.kind === "record-unreadable"
950
+ ? `OBSERVATION receipt: record-unreadable — the record of ${target} could not be read after ` +
951
+ `${observation.waitedMs}ms: ${observation.cause}. ${left}`
952
+ : `OBSERVATION receipt: resume-unobserved — no new live receiver marker from the direct child of the pane ` +
953
+ `this call opened, bound to the record, appeared within ${observation.waitedMs}ms. ${left}`),
954
+ isError: false,
955
+ };
956
+ }
957
+ /**
958
+ * A launch that failed after it created something. The header claims no more than the recovery
959
+ * knows — the fresh herdr rail learned that the hard way: `none` means herdr declined by name before
960
+ * making anything, `unknown` means no answer we can act on so a tab MAY exist, and anything else means
961
+ * the tab was created and the recovery says whether its pane was closed. No observation ran, so this
962
+ * says nothing about whether the citizen came back.
963
+ */
964
+ function renderLaunchFailed(result) {
965
+ const f = result.failure;
966
+ const tabHeader = f.recovery.outcome === "none"
967
+ ? "entwurf_resume_call failed at the herdr tab create step"
968
+ : f.recovery.outcome === "unknown"
969
+ ? "entwurf_resume_call failed at the herdr tab create step with an UNKNOWN outcome"
970
+ : "entwurf_resume_call failed after the herdr tab was created";
971
+ // #157 B3: the pi-durable rail asks herdr for ONE layout.apply. Its classifier answers only none or
972
+ // unknown, so anything but none is UNKNOWN, and there is no tab create step to name.
973
+ const layout = f.rail === "herdr-layout";
974
+ const header = layout
975
+ ? `entwurf_resume_call failed at the herdr layout.apply step${f.recovery.outcome === "none" ? "" : " with an UNKNOWN outcome"}`
976
+ : tabHeader;
977
+ const code = f.vendorCode === undefined ? "" : ` [herdr: ${f.vendorCode}]`;
978
+ const created = f.created === undefined
979
+ ? ""
980
+ : ` created: tab ${f.created.herdrTabId} in workspace ${f.created.herdrWorkspaceId}, pane ${f.created.herdrPaneId} ` +
981
+ `terminal ${f.created.herdrTerminalId} — herdr VIEW coordinates, not an address\n`;
982
+ const recovery = f.recovery.outcome === "none"
983
+ ? layout
984
+ ? " recovery: none — the layout request was never written, or herdr declined it by name before making anything, so there is nothing to go look at\n"
985
+ : " recovery: none — herdr declined by name before making anything, so there is nothing to go look at\n"
986
+ : f.recovery.outcome === "unknown"
987
+ ? " recovery: UNKNOWN — we never got an answer we can act on, so a tab MAY exist that this call cannot name. Nothing was closed; check herdr\n"
988
+ : f.recovery.outcome === "closed"
989
+ ? ` recovery: closed ${f.recovery.paneId} (its terminal still matched the create receipt; the new tab went with it unless something else had joined it)\n`
990
+ : ` recovery: orphan-unreclaimed:${f.recovery.reason} (${f.recovery.paneId}) — this pane was NOT closed, on purpose\n`;
991
+ return {
992
+ text: `${header}: ${result.detail}.${code}\n` +
993
+ created +
994
+ recovery +
995
+ `No observation was attempted, so this says nothing about whether the citizen came back — entwurf_peers ` +
996
+ `shows whether its ${layout ? "rail" : "control socket"} answers. The lock was released and nothing was retried.`,
997
+ isError: true,
998
+ };
999
+ }