@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,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
@@ -73,6 +92,7 @@
73
92
  * half reads the receiver marker and the record instead.
74
93
  */
75
94
 
95
+ import path from "node:path";
76
96
  import { ACP_MODEL_NOT_CURATED_HINT, AcpModelNotCuratedError } from "./acp/model-ids.js";
77
97
  import { ENTWURF_RESUME_REJECT_HINTS } from "./agent-guidance.ts";
78
98
  import { buildResumePiArgs } from "./entwurf-resume-args.ts";
@@ -86,6 +106,10 @@ import {
86
106
  } from "./entwurf-v2-lock.ts";
87
107
  import {
88
108
  classifyMarkerOwner,
109
+ defaultMetaMailboxDir,
110
+ defaultMetaReceiversDir,
111
+ defaultMetaSendersDir,
112
+ defaultMetaSessionsDir,
89
113
  type MarkerOwnerVerdict,
90
114
  metaReceiverMarkerPath,
91
115
  parseMetaReceiverMarker,
@@ -93,10 +117,18 @@ import {
93
117
  processStartKey,
94
118
  readStoreRecordFile,
95
119
  } from "./meta-session.ts";
120
+ import {
121
+ observePiDurableReservation,
122
+ PiDurableOwnerFenceError,
123
+ type PiDurableReservation,
124
+ type PiDurableReservationObservation,
125
+ type PiDurableWrittenClaim,
126
+ reservePiDurableSession,
127
+ withdrawPiDurableReservation,
128
+ } from "./pi-durable-owner-fence.ts";
96
129
  import {
97
130
  type ClaudeCodeLaunchIdentity,
98
131
  type LaunchIdentity,
99
- type ResumeBackend,
100
132
  ResumeBackendUnsupportedError,
101
133
  resolveResumeLaunchIdentity,
102
134
  } from "./resume-launch-identity.ts";
@@ -126,7 +158,7 @@ export const RESUME_OBSERVE_INTERVAL_MS = 500;
126
158
  * `target-backend-unsupported` IS in here, and the line between it and those throws is worth
127
159
  * stating: asking to resume a Codex or agy citizen is not a defect at all. Nothing is corrupt, the
128
160
  * record is exactly right, and the operator has simply reached past a capability boundary — only
129
- * pi and Claude Code have a same-id resume. That deserves a named refusal a caller can act on, not
161
+ * pi, pi-durable and Claude Code have a same-id resume. That deserves a named refusal a caller can act on, not
130
162
  * an error report about a store that is fine. The four `target-receiver-*` reasons are the Claude
131
163
  * Code dormancy proof failing: nothing on disk proves the citizen is not running.
132
164
  */
@@ -144,9 +176,10 @@ export type VisibleResumeRejectReason =
144
176
  | "target-address-conflict"
145
177
  | "launch-refused";
146
178
 
147
- /** What the injected launcher gives back. Structural on purpose: naming mux's types here would
148
- * be the import this split exists to avoid. */
149
- export interface VisibleResumeLaunchHandle {
179
+ /** What the injected launcher gives back. Structural on purpose: naming mux's or herdr's types here
180
+ * would be the import this split exists to avoid. `rail` says which one ran. */
181
+ export interface VisibleResumeTmuxLaunchHandle {
182
+ rail: "tmux";
150
183
  serverPid: string;
151
184
  sessionId: string;
152
185
  windowId: string;
@@ -157,16 +190,118 @@ export interface VisibleResumeLaunchHandle {
157
190
  cwd: string;
158
191
  }
159
192
 
193
+ /** The herdr rail's handle (#157 G1a): what herdr was ASKED to start, the requested directory, and
194
+ * herdr's VIEW coordinates. No tmux field is invented for it — it has no server pid, window or
195
+ * resolved runtime path, and the pane pid the Claude Code observation joins on does not exist here. */
196
+ export interface VisibleResumeHerdrLaunchHandle {
197
+ rail: "herdr";
198
+ requestedKind: string;
199
+ cwd: string;
200
+ herdrAgentName: string;
201
+ herdrWorkspaceId: string;
202
+ herdrTabId: string;
203
+ herdrPaneId: string;
204
+ herdrTerminalId: string;
205
+ }
206
+
207
+ export type VisibleResumeLaunchHandle = VisibleResumeTmuxLaunchHandle | VisibleResumeHerdrLaunchHandle;
208
+
209
+ /** #157 B3: the herdr rail's pi-durable resume — ONE layout.apply, so herdr's layout reply is the whole
210
+ * receipt: the launcher asked for, the requested directory and herdr's VIEW coordinates. No agent name
211
+ * and no terminal id exist for it, which is why it carries its own rail tag: a pi resume handed one
212
+ * fails loud rather than rendering fields it never had. */
213
+ export interface VisibleResumeHerdrLayoutLaunchHandle {
214
+ rail: "herdr-layout";
215
+ runtime: string;
216
+ cwd: string;
217
+ herdrWorkspaceId: string;
218
+ herdrTabId: string;
219
+ herdrPaneId: string;
220
+ }
221
+
222
+ /**
223
+ * #157 B3: the pi-durable adopt launch projection (L3-A1 §1) — EXACTLY five names: the SDK's own
224
+ * profile input carrying the reservation's raw profile, and the standard children of the
225
+ * reservation's ONE meta root. A fixed projection of those two coordinates, never an env dictionary.
226
+ */
227
+ export interface PiDurableAdoptEnv {
228
+ PI_CODING_AGENT_DIR: string;
229
+ ENTWURF_META_SESSIONS_DIR: string;
230
+ ENTWURF_META_MAILBOX_DIR: string;
231
+ ENTWURF_META_SENDERS_DIR: string;
232
+ ENTWURF_META_RECEIVERS_DIR: string;
233
+ }
234
+
235
+ /** What the launch seam is asked to open. pi and Claude Code carry the record's argv only; a
236
+ * pi-durable adoption also carries its fixed projection, and nothing else can. */
237
+ export type VisibleResumeLaunchInput =
238
+ | { backend: "pi" | "claude-code"; cwd: string; runtimeArgs: readonly string[] }
239
+ | { backend: "pi-durable"; cwd: string; runtimeArgs: readonly string[]; piDurableAdoptEnv: PiDurableAdoptEnv };
240
+
241
+ /** A launch that failed AFTER it created something (on herdr: after the tab create). The vendor's
242
+ * own code when it gave one, what is out there now, and the created coordinates when the create reply
243
+ * was read. Recovery is structural for the same reason the handles are. Only the herdr rail can fail
244
+ * after a mutation today (tmux opens its window in one step), and `rail` says so rather than leaving
245
+ * the renderer to assume it: `herdr-layout` is the pi-durable rail's ONE layout.apply (#157 B3), whose
246
+ * classifier answers only none or unknown and which has no tab create step of its own. */
247
+ export interface VisibleResumeLaunchFailure {
248
+ rail: "herdr" | "herdr-layout";
249
+ vendorCode?: string;
250
+ recovery:
251
+ | { outcome: "none" }
252
+ | { outcome: "unknown" }
253
+ | { outcome: "closed"; paneId: string; terminalId: string }
254
+ | { outcome: "orphan-unreclaimed"; paneId: string; reason: string };
255
+ created?: { herdrWorkspaceId: string; herdrTabId: string; herdrPaneId: string; herdrTerminalId: string };
256
+ }
257
+
258
+ /**
259
+ * #157 stage 4: the herdr rail's Claude Code resume — ONE layout.apply, then ONE `pane.process_info`
260
+ * read of the pane it created. `paneChild` is herdr's report of that pane's DIRECT child (a layout
261
+ * argv pane spawns its program with no shell between, measured S4-CC-A H1), the twin of tmux's pane
262
+ * pid: a join for the observation, never an address. `runtime` is the name herdr was asked to run —
263
+ * herdr resolved it, we did not. Its own rail tag, so neither other resume can read it as its own.
264
+ */
265
+ export interface VisibleResumeHerdrClaudeLaunchHandle {
266
+ rail: "herdr-layout-claude";
267
+ runtime: string;
268
+ cwd: string;
269
+ herdrWorkspaceId: string;
270
+ herdrTabId: string;
271
+ herdrPaneId: string;
272
+ paneChild: { pid: string } | { unread: string };
273
+ }
274
+
160
275
  export type VisibleResumeLaunchOutcome =
161
- | { ok: true; handle: VisibleResumeLaunchHandle }
162
- | { ok: false; reason: string; hint: string };
276
+ | {
277
+ ok: true;
278
+ handle: VisibleResumeLaunchHandle | VisibleResumeHerdrLayoutLaunchHandle | VisibleResumeHerdrClaudeLaunchHandle;
279
+ }
280
+ /** Refused before anything was created. */
281
+ | { ok: false; reason: string; hint: string }
282
+ | { ok: false; reason: string; hint: string; failure: VisibleResumeLaunchFailure };
163
283
 
164
- /** Fact 1: tmux created a window and was asked to start the citizen's runtime there. `cwd` is the
165
- * REQUESTED start directory (what tmux was told), the same kind of fact as `runtimePath` — not an
166
- * observation of where the pane landed. */
284
+ /** Fact 1: tmux created a window — or, inside herdr, herdr created a tab — and
285
+ * was asked to start the citizen's runtime there. `cwd` is the REQUESTED start directory (what the placement owner
286
+ * was told), the same kind of fact as `runtimePath` — not an observation of where the pane landed.
287
+ * The Claude Code receipt is tmux's window or herdr's layout tab: its observation joins that pane's
288
+ * direct child. */
167
289
  export type ResumeLaunchReceipt =
168
290
  | { backend: "pi"; targetGardenId: string; sessionFile: string; handle: VisibleResumeLaunchHandle }
169
- | { backend: "claude-code"; targetGardenId: string; nativeSessionId: string; handle: VisibleResumeLaunchHandle };
291
+ | {
292
+ backend: "claude-code";
293
+ targetGardenId: string;
294
+ nativeSessionId: string;
295
+ handle: VisibleResumeTmuxLaunchHandle | VisibleResumeHerdrClaudeLaunchHandle;
296
+ }
297
+ /** #157 B3: the reservation is part of the launch fact — the window or tab was opened FOR it. */
298
+ | {
299
+ backend: "pi-durable";
300
+ targetGardenId: string;
301
+ cwd: string;
302
+ reservation: { generation: string; resource: string; ownerPid: number };
303
+ handle: VisibleResumeTmuxLaunchHandle | VisibleResumeHerdrLayoutLaunchHandle;
304
+ };
170
305
 
171
306
  /** Fact 2: the citizen's control socket came back under the SAME garden id, or it did not. This
172
307
  * is the only fact in this module that says a citizen is back. */
@@ -184,7 +319,10 @@ export interface ResumeObservationReceipt {
184
319
  export type ReceiverObservationReceipt =
185
320
  | { kind: "receiver-armed"; ownerPid: number; waitedMs: number }
186
321
  | { kind: "resume-unobserved"; waitedMs: number }
187
- | { kind: "record-unreadable"; cause: string; waitedMs: number };
322
+ | { kind: "record-unreadable"; cause: string; waitedMs: number }
323
+ /** #157 stage 4, herdr only: herdr did not report the created pane's direct child, so there was no
324
+ * pid to join a marker to and no wait ran. The tab exists; nothing is closed. */
325
+ | { kind: "pane-process-unread"; cause: string; waitedMs: number };
188
326
 
189
327
  /** What the receiver marker under a garden id says, read raw and judged by the shared owner rule. */
190
328
  export type ReceiverInspection =
@@ -207,9 +345,29 @@ export type ReceiverInspection =
207
345
  owner: MarkerOwnerVerdict;
208
346
  };
209
347
 
348
+ /**
349
+ * Fact 2 for pi-durable (#157 B3): what the reservation's D claim says, read by ONE bounded operation.
350
+ * `adopted` is an adoption WRITE by some child (the claim carries this reservation's generation as its
351
+ * `adoptedFrom`), not readiness, liveness, an attach or a turn; `not-yet-adopted` is still this
352
+ * reservation when the wait ended; `reservation-lost` is any other claim the strict reader accepted;
353
+ * `unobserved` is a claim that could not be read, with its named cause — never folded into lost.
354
+ */
355
+ export type PiDurableAdoptionObservationReceipt =
356
+ | { kind: "adopted"; waitedMs: number }
357
+ | { kind: "not-yet-adopted"; waitedMs: number }
358
+ | { kind: "reservation-lost"; waitedMs: number }
359
+ | { kind: "unobserved"; reason: string; detail: string; waitedMs: number };
360
+
210
361
  export type VisibleResumeResult =
211
- | { ok: true; launch: ResumeLaunchReceipt; observation: ResumeObservationReceipt | ReceiverObservationReceipt }
212
- | { ok: false; reason: VisibleResumeRejectReason; detail: string };
362
+ | {
363
+ ok: true;
364
+ launch: ResumeLaunchReceipt;
365
+ observation: ResumeObservationReceipt | ReceiverObservationReceipt | PiDurableAdoptionObservationReceipt;
366
+ }
367
+ | { ok: false; reason: VisibleResumeRejectReason; detail: string }
368
+ /** The launch failed after it created something. Not a refusal: something may be open, so this is
369
+ * never rendered as "No window was opened". No observation runs after it. */
370
+ | { ok: false; reason: "launch-failed"; detail: string; failure: VisibleResumeLaunchFailure };
213
371
 
214
372
  export interface VisibleResumeDeps {
215
373
  acquireLock: (gardenId: string) => AcquireLockResult;
@@ -219,12 +377,16 @@ export interface VisibleResumeDeps {
219
377
  probeSocket: (socketPath: string) => Promise<SocketLiveness>;
220
378
  /** The Claude Code dormancy proof and observation read this; pi never does. */
221
379
  inspectReceiver: (gardenId: string) => ReceiverInspection;
222
- /** The ONLY way this module reaches tmux. The surface supplies it. */
223
- launch: (input: {
224
- backend: ResumeBackend;
225
- cwd: string;
226
- runtimeArgs: readonly string[];
227
- }) => VisibleResumeLaunchOutcome;
380
+ /** The ONLY way this module reaches tmux or herdr. The surface supplies it; the herdr rail is
381
+ * asynchronous (its CLI runner must not block the caller's event loop), so this is awaited. */
382
+ launch: (input: VisibleResumeLaunchInput) => VisibleResumeLaunchOutcome | Promise<VisibleResumeLaunchOutcome>;
383
+ /** #157 B3, pi-durable only: reserve D for one child, record-arm then S_D, both released before
384
+ * this returns. The per-target lock this module holds stays the outer scope and is never retaken. */
385
+ reservePiDurable: (gardenId: string) => Promise<PiDurableReservation>;
386
+ /** Withdraw THIS process's own reservation, only once its launch is definitely none-created. */
387
+ withdrawPiDurable: (reservation: PiDurableReservation) => Promise<void>;
388
+ /** What the reservation's D claim says now: a read, never a watch. */
389
+ observePiDurable: (reservation: PiDurableReservation) => PiDurableReservationObservation;
228
390
  sleep: (ms: number) => Promise<void>;
229
391
  now: () => number;
230
392
  }
@@ -243,6 +405,20 @@ export function makeVisibleResumeDeps(
243
405
  probeSocket: (socketPath) => probeSocketLiveness(socketPath),
244
406
  inspectReceiver: (gid) => inspectReceiverMarker(gid),
245
407
  launch,
408
+ // The four roots THIS process resolves now, handed to the reserve as given: a split shape is
409
+ // the reserve's own named refusal, never merged here.
410
+ reservePiDurable: (gid) =>
411
+ reservePiDurableSession({
412
+ gardenId: gid,
413
+ roots: {
414
+ sessionsDir: defaultMetaSessionsDir(),
415
+ mailboxDir: defaultMetaMailboxDir(),
416
+ sendersDir: defaultMetaSendersDir(),
417
+ receiversDir: defaultMetaReceiversDir(),
418
+ },
419
+ }),
420
+ withdrawPiDurable: (reservation) => withdrawPiDurableReservation(reservation),
421
+ observePiDurable: (reservation) => observePiDurableReservation(reservation),
246
422
  sleep: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
247
423
  now: () => Date.now(),
248
424
  ...overrides,
@@ -366,7 +542,7 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
366
542
  ok: false,
367
543
  reason: err.reason,
368
544
  detail:
369
- `${targetGardenId} is a ${err.backend} citizen; only pi and Claude Code citizens can be reopened ` +
545
+ `${targetGardenId} is a ${err.backend} citizen; only pi, pi-durable and Claude Code citizens can be reopened ` +
370
546
  `under the same garden id`,
371
547
  };
372
548
  }
@@ -374,6 +550,7 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
374
550
  }
375
551
 
376
552
  if (identity.backend === "claude-code") return await resumeClaudeCode(targetGardenId, identity, deps);
553
+ if (identity.backend === "pi-durable") return await resumePiDurable(targetGardenId, deps);
377
554
 
378
555
  // Liveness preflight, still under the lock. A live citizen is addressed with
379
556
  // `entwurf_v2 fire-and-forget`, not reopened — and refusing here is also what stops two
@@ -412,9 +589,15 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
412
589
  provider: identity.provider,
413
590
  model: identity.model,
414
591
  });
415
- const launched = deps.launch({ backend: "pi", cwd: identity.cwd, runtimeArgs });
416
- if (!launched.ok) {
417
- return { ok: false, reason: "launch-refused", detail: `${launched.reason} — ${launched.hint}` };
592
+ const launched = await deps.launch({ backend: "pi", cwd: identity.cwd, runtimeArgs });
593
+ if (!launched.ok) return launchNotLaunched(launched);
594
+ // Only a pi-durable launch carries a layout handle (#157 B3). Handed to a pi resume, it is a
595
+ // wiring defect, not an outcome: whatever was opened stays as it is and the lock is released.
596
+ if (launched.handle.rail === "herdr-layout" || launched.handle.rail === "herdr-layout-claude") {
597
+ throw new Error(
598
+ `entwurf-v2-visible-resume: the launcher handed a pi resume of ${targetGardenId} a ${launched.handle.rail} handle — ` +
599
+ `only a pi-durable or Claude Code resume launches through layout.apply. Nothing was observed or closed.`,
600
+ );
418
601
  }
419
602
 
420
603
  const observation = await observeSameGidSocket(targetGardenId, mapped.socketPath, deps);
@@ -428,6 +611,23 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
428
611
  }
429
612
  }
430
613
 
614
+ /**
615
+ * A launch that did not become a launch, kept in its own shape. A refusal created nothing and is
616
+ * `launch-refused`. A failure that carries what it created (herdr: after the tab create) is
617
+ * `launch-failed`, with the vendor code, recovery and coordinates passed through untouched — folding
618
+ * it into a refusal is what would print "No window was opened" over a tab that may be on screen.
619
+ * Neither is retried, and neither is followed by an observation.
620
+ */
621
+ function launchNotLaunched(
622
+ launched: Extract<VisibleResumeLaunchOutcome, { ok: false }>,
623
+ note = "",
624
+ ): VisibleResumeResult {
625
+ const detail = `${launched.reason} — ${launched.hint}${note}`;
626
+ return "failure" in launched
627
+ ? { ok: false, reason: "launch-failed", detail, failure: launched.failure }
628
+ : { ok: false, reason: "launch-refused", detail };
629
+ }
630
+
431
631
  /**
432
632
  * The Claude Code half, still under the caller's lock. Dormancy needs a DEAD owner verdict on the
433
633
  * receiver marker AND the binding: the record re-read AFTER that verdict must still name the same
@@ -523,33 +723,294 @@ async function resumeClaudeCode(
523
723
  };
524
724
  }
525
725
 
526
- const launched = deps.launch({
726
+ const launched = await deps.launch({
527
727
  backend: "claude-code",
528
728
  cwd: current.cwd,
529
729
  runtimeArgs: ["--resume", current.nativeSessionId],
530
730
  });
531
- if (!launched.ok) {
532
- return { ok: false, reason: "launch-refused", detail: `${launched.reason} — ${launched.hint}` };
731
+ if (!launched.ok) return launchNotLaunched(launched);
732
+ // The observation below joins the receiver marker's owner to the pane's DIRECT child: tmux's pane
733
+ // pid, or herdr's process report for the layout pane (#157 stage 4). A handle of any other rail
734
+ // carries no such pid, so it is a wiring defect, not an outcome: it fails loud rather than watching
735
+ // for a pid that does not exist. Whatever that launcher opened is left as it is; the lock is still
736
+ // released.
737
+ const handle = launched.handle;
738
+ if (handle.rail !== "tmux" && handle.rail !== "herdr-layout-claude") {
739
+ throw new Error(
740
+ `entwurf-v2-visible-resume: the launcher handed a Claude Code resume of ${targetGardenId} a ${handle.rail} ` +
741
+ `handle — its observation joins the opened pane's direct child, which that rail does not carry. Nothing was ` +
742
+ `observed or closed.`,
743
+ );
744
+ }
745
+ const launch = { backend: "claude-code" as const, targetGardenId, nativeSessionId: current.nativeSessionId, handle };
746
+ // herdr did not name the new pane's direct child: there is nothing to join, so no wait runs. The tab
747
+ // is open; nothing is retried or closed.
748
+ if (handle.rail === "herdr-layout-claude" && "unread" in handle.paneChild) {
749
+ return {
750
+ ok: true,
751
+ launch,
752
+ observation: { kind: "pane-process-unread", cause: handle.paneChild.unread, waitedMs: 0 },
753
+ };
533
754
  }
755
+ const panePid = handle.rail === "tmux" ? handle.panePid : (handle.paneChild as { pid: string }).pid;
534
756
  const observation = await observeReceiverReattached(
535
757
  targetGardenId,
536
758
  current,
537
759
  { ownerPid: marker.ownerPid, ownerStartKey: marker.ownerStartKey, attachIncarnation: current.attachIncarnation },
538
- launched.handle.panePid,
760
+ panePid,
539
761
  deps,
540
762
  );
763
+ return { ok: true, launch, observation };
764
+ }
765
+
766
+ /**
767
+ * #157 B3: the adopt launch for ONE reservation — the managed verb with its closed context
768
+ * `{v:1, gardenId, metaRoot, sourceGeneration}` (no session, cwd, profile, model, prompt, module,
769
+ * nonce or callback rides it), the reservation's cwd, and the fixed five-name projection of its raw
770
+ * profile and its meta root. Pure: it reads nothing and writes nothing.
771
+ */
772
+ export function buildPiDurableAdoptLaunch(
773
+ reservation: Pick<PiDurableReservation, "gardenId" | "metaRoot" | "generation" | "profile" | "cwd">,
774
+ ): Extract<VisibleResumeLaunchInput, { backend: "pi-durable" }> {
775
+ const { metaRoot } = reservation;
776
+ const context = {
777
+ v: 1,
778
+ gardenId: reservation.gardenId,
779
+ metaRoot,
780
+ sourceGeneration: reservation.generation,
781
+ };
782
+ return {
783
+ backend: "pi-durable",
784
+ cwd: reservation.cwd,
785
+ runtimeArgs: ["pi-durable", "--entwurf-adopt", JSON.stringify(context)],
786
+ piDurableAdoptEnv: {
787
+ PI_CODING_AGENT_DIR: reservation.profile,
788
+ ENTWURF_META_SESSIONS_DIR: path.join(metaRoot, "meta-sessions"),
789
+ ENTWURF_META_MAILBOX_DIR: path.join(metaRoot, "meta-mailbox"),
790
+ ENTWURF_META_SENDERS_DIR: path.join(metaRoot, "meta-senders"),
791
+ ENTWURF_META_RECEIVERS_DIR: path.join(metaRoot, "meta-receivers"),
792
+ },
793
+ };
794
+ }
795
+
796
+ /**
797
+ * A reserve refusal this verb answers by name; every other fence refusal keeps its own cause. A live holder is not
798
+ * an address (S3 joint review D4): a reservation's parent is a bridge waiting for a child, which entwurf_v2 cannot
799
+ * reach, and any other live holder is a process this verb did not judge contact-ready — so neither answer tells the
800
+ * caller to send. The branch reads the refusal's structured holder state, never its message.
801
+ */
802
+ function piDurableReserveRefusal(targetGardenId: string, err: PiDurableOwnerFenceError): VisibleResumeResult | null {
803
+ if (err.reason === "owner-live") {
804
+ if (err.holderState === "reserved") {
805
+ return {
806
+ ok: false,
807
+ reason: "target-live",
808
+ detail:
809
+ `${targetGardenId}'s pi-durable session is reserved for a child to adopt by a live process (${err.message}); ` +
810
+ `while that reservation stays unadopted under that live process a resume of ${targetGardenId} refuses on it; ` +
811
+ `nothing clears it automatically, a child that adopts it becomes its holder, and entwurf_v2 does not reach a reservation`,
812
+ };
813
+ }
814
+ return {
815
+ ok: false,
816
+ reason: "target-live",
817
+ detail:
818
+ `${targetGardenId}'s pi-durable session is held by a live process (${err.message}); if that process is the ` +
819
+ `running citizen, entwurf_v2 reaches it only through the rail its own dispatch decides`,
820
+ };
821
+ }
822
+ if (err.reason === "owner-uncertain" || err.reason === "owner-other-host") {
823
+ return {
824
+ ok: false,
825
+ reason: "target-indeterminate",
826
+ detail:
827
+ `the holder of ${targetGardenId}'s pi-durable session could not be proven gone on this host (${err.message}), ` +
828
+ `and resuming a session that is still open would put two processes on one transcript`,
829
+ };
830
+ }
831
+ return null;
832
+ }
833
+
834
+ const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err));
835
+
836
+ /**
837
+ * A reserve whose write committed and whose lock release then failed (S3 joint review D3). It says only what that
838
+ * call wrote and what its holder implies; the release's own words say what it left. Nothing is withdrawn, retried
839
+ * or repaired here.
840
+ */
841
+ function piDurableReservedThenFailed(
842
+ targetGardenId: string,
843
+ err: PiDurableOwnerFenceError,
844
+ written: PiDurableWrittenClaim,
845
+ ): string {
846
+ const residue =
847
+ err.reason === "tx-release-failed"
848
+ ? `; if the transaction file that release named remains, that process ending does not remove it, and it fails closed until an operator removes it`
849
+ : "";
850
+ return (
851
+ `entwurf-v2-visible-resume: the reserve of ${targetGardenId} WROTE its reservation of ${written.resource} at ` +
852
+ `generation ${written.generation} for pid ${written.ownerPid}, then a lock release failed (${err.message}). ` +
853
+ `Nothing was launched and nothing withdrew that reservation: while it stays unadopted under live pid ` +
854
+ `${written.ownerPid}, a resume of ${targetGardenId} refuses, and nothing clears it automatically${residue}`
855
+ );
856
+ }
857
+
858
+ /**
859
+ * A withdrawal that failed (S3 joint review D1). Only what the failure itself records is stated: a write this call
860
+ * made, or a transaction file the release left; any other failure leaves the claim's state unestablished here.
861
+ */
862
+ function piDurableWithdrawFailed(
863
+ targetGardenId: string,
864
+ held: string,
865
+ reservation: PiDurableReservation,
866
+ err: unknown,
867
+ ): string {
868
+ const residue = `if that transaction file remains, this process ending does not remove it, and it fails closed until an operator removes it`;
869
+ if (err instanceof PiDurableOwnerFenceError && err.written !== undefined) {
870
+ return (
871
+ ` — this call wrote the withdrawal of ${held} (the claim back to the donor's ${err.written.state} row at ` +
872
+ `generation ${err.written.generation}), then releasing its transaction failed (${err.message}); ${residue}`
873
+ );
874
+ }
875
+ if (err instanceof PiDurableOwnerFenceError && err.reason === "tx-release-failed") {
876
+ return (
877
+ ` — withdrawing ${held} FAILED at its transaction release (${err.message}); whether this call ` +
878
+ `wrote anything is not recorded, and ${residue}`
879
+ );
880
+ }
881
+ return (
882
+ ` — withdrawing ${held} FAILED (${messageOf(err)}); what its claim holds now is not established here: while it ` +
883
+ `stays reserved, unadopted, under this live process (pid ${reservation.owner.pid}), a resume of ${targetGardenId} ` +
884
+ `refuses`
885
+ );
886
+ }
887
+
888
+ /**
889
+ * The pi-durable half (#157 B3), still under the caller's per-target lock, which stays the outer
890
+ * scope: the reserve takes record-arm then S_D itself and releases both before it returns, so before
891
+ * any launch. A reserve that refuses on eligibility writes nothing; a reserve whose lock release fails AFTER its
892
+ * write is named as such (its `written`), with the reservation it left. Neither opens anything.
893
+ *
894
+ * From the reservation on, this process holds D for one child, and what happens to that hold is
895
+ * decided by the launch outcome alone (L3-A1 §6), never by a guess:
896
+ *
897
+ * no launch request was made definitely none created → withdraw
898
+ * the seam refused (`ok:false`, no `failure`) the seam's contract: refused before anything was
899
+ * created (tmux: one of its named reasons; herdr:
900
+ * decided before the layout write) → withdraw
901
+ * a failure whose recovery is `none` that rail's own none-created certainty → withdraw
902
+ * a failure whose recovery is anything else something MAY exist → the reservation is KEPT
903
+ * the launcher threw unknown, whatever its position → KEPT
904
+ * a launch a child may adopt it → KEPT, then observed once
905
+ *
906
+ * A withdrawal that fails is reported with the refusal it followed and is never retried. A kept
907
+ * reservation is said so in the answer: it names this process as its holder, and while it stays unadopted
908
+ * under this live process a later resume of the same id refuses; nothing clears it automatically, and a
909
+ * child that adopts it becomes its holder.
910
+ */
911
+ async function resumePiDurable(targetGardenId: string, deps: VisibleResumeDeps): Promise<VisibleResumeResult> {
912
+ let reservation: PiDurableReservation;
913
+ try {
914
+ reservation = await deps.reservePiDurable(targetGardenId);
915
+ } catch (err) {
916
+ if (err instanceof PiDurableOwnerFenceError) {
917
+ const refused = piDurableReserveRefusal(targetGardenId, err);
918
+ if (refused !== null) return refused;
919
+ // A release that failed after the reservation was WRITTEN (S3 joint review D3): the reservation exists,
920
+ // so the answer names it and what it blocks, beside the release's own cause; nothing launches or withdraws.
921
+ if (err.written !== undefined) throw new Error(piDurableReservedThenFailed(targetGardenId, err, err.written));
922
+ }
923
+ throw err;
924
+ }
925
+ const held = `${targetGardenId}'s reservation of ${reservation.resource} at generation ${reservation.generation}`;
926
+ const withdraw = async (): Promise<string> => {
927
+ try {
928
+ await deps.withdrawPiDurable(reservation);
929
+ return ` — ${held} was withdrawn, since nothing was created`;
930
+ } catch (err) {
931
+ return piDurableWithdrawFailed(targetGardenId, held, reservation, err);
932
+ }
933
+ };
934
+ const kept =
935
+ ` — ${held} was KEPT, because something may exist that can still adopt it; it names this process ` +
936
+ `(pid ${reservation.owner.pid}) as its holder: while it stays unadopted under this live process a resume of ` +
937
+ `${targetGardenId} refuses, nothing clears it automatically, and a child that adopts it becomes its holder`;
938
+
939
+ let input: ReturnType<typeof buildPiDurableAdoptLaunch>;
940
+ try {
941
+ input = buildPiDurableAdoptLaunch(reservation);
942
+ } catch (err) {
943
+ throw new Error(`entwurf-v2-visible-resume: ${messageOf(err)}${await withdraw()}`);
944
+ }
945
+ let launched: VisibleResumeLaunchOutcome;
946
+ try {
947
+ launched = await deps.launch(input);
948
+ } catch (err) {
949
+ throw new Error(
950
+ `entwurf-v2-visible-resume: the pi-durable launch of ${targetGardenId} threw: ${messageOf(err)}${kept}`,
951
+ );
952
+ }
953
+ if (!launched.ok) {
954
+ const noneCreated = !("failure" in launched) || launched.failure.recovery.outcome === "none";
955
+ return launchNotLaunched(launched, noneCreated ? await withdraw() : kept);
956
+ }
957
+ // A herdr agent-start handle belongs to the pi rail only: a pi-durable resume launches through
958
+ // tmux or ONE layout.apply. Handed one, it is a wiring defect; what was opened stays as it is.
959
+ if (launched.handle.rail === "herdr" || launched.handle.rail === "herdr-layout-claude") {
960
+ throw new Error(
961
+ `entwurf-v2-visible-resume: the launcher handed a pi-durable resume of ${targetGardenId} a ` +
962
+ `${launched.handle.rail === "herdr" ? "herdr agent-start" : launched.handle.rail} handle — nothing was observed or closed${kept}`,
963
+ );
964
+ }
965
+ const observation = await observePiDurableAdoption(reservation, deps);
541
966
  return {
542
967
  ok: true,
543
968
  launch: {
544
- backend: "claude-code",
969
+ backend: "pi-durable",
545
970
  targetGardenId,
546
- nativeSessionId: current.nativeSessionId,
971
+ cwd: reservation.cwd,
972
+ reservation: {
973
+ generation: reservation.generation,
974
+ resource: reservation.resource,
975
+ ownerPid: reservation.owner.pid,
976
+ },
547
977
  handle: launched.handle,
548
978
  },
549
979
  observation,
550
980
  };
551
981
  }
552
982
 
983
+ /**
984
+ * ONE bounded operation, the pi-durable twin of `observeSameGidSocket` (#157 B3): read the
985
+ * reservation's D claim until a child's adoption write carries its generation, the claim is lost or
986
+ * unreadable, or the same fixed wait ends. Every read is the fence's strict reader; nothing here
987
+ * writes, withdraws, retries or watches past the deadline. A read that throws past that reader stops
988
+ * the wait as `unobserved` with its cause: the window is open, and the launch receipt must survive.
989
+ */
990
+ export async function observePiDurableAdoption(
991
+ reservation: PiDurableReservation,
992
+ deps: Pick<VisibleResumeDeps, "observePiDurable" | "sleep" | "now">,
993
+ timeoutMs: number = RESUME_OBSERVE_TIMEOUT_MS,
994
+ intervalMs: number = RESUME_OBSERVE_INTERVAL_MS,
995
+ ): Promise<PiDurableAdoptionObservationReceipt> {
996
+ const started = deps.now();
997
+ for (;;) {
998
+ let seen: PiDurableReservationObservation;
999
+ try {
1000
+ seen = deps.observePiDurable(reservation);
1001
+ } catch (err) {
1002
+ return { kind: "unobserved", reason: "observe-threw", detail: messageOf(err), waitedMs: deps.now() - started };
1003
+ }
1004
+ if (seen.class === "adopted") return { kind: "adopted", waitedMs: deps.now() - started };
1005
+ if (seen.class === "lost") return { kind: "reservation-lost", waitedMs: deps.now() - started };
1006
+ if (seen.class === "unobserved") {
1007
+ return { kind: "unobserved", reason: seen.reason, detail: seen.detail, waitedMs: deps.now() - started };
1008
+ }
1009
+ if (deps.now() - started >= timeoutMs) return { kind: "not-yet-adopted", waitedMs: deps.now() - started };
1010
+ await deps.sleep(intervalMs);
1011
+ }
1012
+ }
1013
+
553
1014
  /**
554
1015
  * ONE bounded operation, the Claude Code twin of `observeSameGidSocket`: wait for a NEW live owner
555
1016
  * — in the pane this call opened (`panePid`), a join, not an address — to re-arm this garden id's
@@ -666,6 +1127,7 @@ export function renderVisibleResume(
666
1127
  attribution: { actor: string | null } = { actor: null },
667
1128
  ): { text: string; isError: boolean } {
668
1129
  if (!result.ok) {
1130
+ if (result.reason === "launch-failed") return renderLaunchFailed(result);
669
1131
  return {
670
1132
  text:
671
1133
  `entwurf_resume_call rejected: ${result.reason} — ${REJECT_HINT[result.reason]}. ` +
@@ -673,13 +1135,20 @@ export function renderVisibleResume(
673
1135
  isError: true,
674
1136
  };
675
1137
  }
676
- const h = result.launch.handle;
677
1138
  // Who asked is recorded, and it is not authority: the verb decides from the target's record and
678
1139
  // live rail facts alone, whoever the caller is.
679
1140
  const actor =
680
1141
  attribution.actor === null
681
1142
  ? ` actor: unattributed (the caller has no record-backed garden identity)\n`
682
1143
  : ` actor: ${attribution.actor} (record-backed caller; attribution, not authority)\n`;
1144
+ // #157 B3: pi-durable has its own receipts (a reservation, an adoption), on either rail.
1145
+ if (result.launch.backend === "pi-durable") {
1146
+ return renderPiDurableResume(result.launch, result.observation as PiDurableAdoptionObservationReceipt, actor);
1147
+ }
1148
+ const h = result.launch.handle;
1149
+ // The herdr rail has its own coordinates and its own words; the tmux text below stays as it was.
1150
+ if (h.rail === "herdr") return renderHerdrPiResume(result, h, actor);
1151
+ if (h.rail === "herdr-layout-claude") return renderHerdrClaudeResume(result, h, actor);
683
1152
  const window =
684
1153
  ` runtime: ${h.runtimePath} (requested start cwd ${h.cwd})\n` +
685
1154
  ` window: ${h.windowId} (index ${h.windowIndex}) in session ${h.sessionId}\n` +
@@ -733,3 +1202,206 @@ export function renderVisibleResume(
733
1202
  isError: false,
734
1203
  };
735
1204
  }
1205
+
1206
+ /**
1207
+ * The pi-durable resume (#157 B3), on tmux or on herdr's one layout.apply. Three facts kept apart: the
1208
+ * reservation this process holds, the LAUNCH that was asked to adopt it, and what the D claim said
1209
+ * after one bounded read. `adopted` says a child wrote its adoption — not that it is ready, alive,
1210
+ * attached, addressable or that anything was recalled; entwurf_peers is where its rail is read.
1211
+ */
1212
+ function renderPiDurableResume(
1213
+ launch: Extract<ResumeLaunchReceipt, { backend: "pi-durable" }>,
1214
+ observation: PiDurableAdoptionObservationReceipt,
1215
+ actor: string,
1216
+ ): { text: string; isError: boolean } {
1217
+ const h = launch.handle;
1218
+ const place = h.rail === "tmux" ? "window" : "tab";
1219
+ const where =
1220
+ h.rail === "tmux"
1221
+ ? ` runtime: ${h.runtimePath} pi-durable --entwurf-adopt (requested start cwd ${h.cwd})\n` +
1222
+ ` window: ${h.windowId} (index ${h.windowIndex}) in session ${h.sessionId}\n` +
1223
+ ` pane: ${h.paneId} pid ${h.panePid}\n`
1224
+ : ` runtime: ${h.runtime} pi-durable --entwurf-adopt (requested start cwd ${h.cwd})\n` +
1225
+ ` tab: ${h.herdrTabId} in workspace ${h.herdrWorkspaceId} — a NEW tab from ONE layout.apply, opened without taking focus\n` +
1226
+ ` pane: ${h.herdrPaneId} — herdr VIEW coordinates, not an address\n`;
1227
+ const r = launch.reservation;
1228
+ const heldBy = `this process (pid ${r.ownerPid})`;
1229
+ const observed =
1230
+ observation.kind === "adopted"
1231
+ ? `OBSERVATION receipt: adopted — after ${observation.waitedMs}ms the claim of ${r.resource} carries generation ` +
1232
+ `${r.generation} as its adoptedFrom: a child process wrote its adoption of this reservation. That is not ` +
1233
+ `readiness, liveness, an attach, a turn or a recall — entwurf_peers shows whether its rail answers. This ` +
1234
+ `call submitted no prompt.`
1235
+ : observation.kind === "not-yet-adopted"
1236
+ ? `OBSERVATION receipt: not-yet-adopted — after ${observation.waitedMs}ms the claim of ${r.resource} was still ` +
1237
+ `this reservation. The ${place} is open and visible; read it directly. The reservation stays held by ` +
1238
+ `${heldBy}: a child there can still adopt it and become its holder; while it stays unadopted under this live ` +
1239
+ `process a resume of ${launch.targetGardenId} refuses, and nothing clears it automatically. Nothing was ` +
1240
+ `retried, withdrawn or killed.`
1241
+ : observation.kind === "reservation-lost"
1242
+ ? `OBSERVATION receipt: reservation-lost — after ${observation.waitedMs}ms the claim of ${r.resource} ` +
1243
+ `names neither this reservation nor an adoption of it (another owner, a reclaim or a withdrawal ` +
1244
+ `replaced it). The ${place} is open; read it directly. Nothing was retried or killed.`
1245
+ : `OBSERVATION receipt: unobserved — the claim of ${r.resource} could not be read after ` +
1246
+ `${observation.waitedMs}ms (${observation.reason}: ${observation.detail}). The ${place} is open and ` +
1247
+ `visible; nothing was retried, withdrawn or killed.`;
1248
+ return {
1249
+ text:
1250
+ `[entwurf resume call ↻${h.rail === "tmux" ? "" : " herdr"}]\n` +
1251
+ ` target: ${launch.targetGardenId}\n` +
1252
+ ` backend: pi-durable\n` +
1253
+ ` reserved: ${r.resource} at generation ${r.generation}, held by ${heldBy} for one child to adopt\n` +
1254
+ where +
1255
+ actor +
1256
+ `\n` +
1257
+ `LAUNCH receipt: ${h.rail === "tmux" ? "tmux created that window" : "herdr created that tab"} and was asked to ` +
1258
+ `start the managed entwurf pi-durable with the closed adopt context of the reservation above, its raw ` +
1259
+ `profile as PI_CODING_AGENT_DIR and its meta root's four ENTWURF_META_*_DIR children as its launch env, ` +
1260
+ `in the record's cwd.\n` +
1261
+ observed,
1262
+ isError: false,
1263
+ };
1264
+ }
1265
+
1266
+ /**
1267
+ * The pi resume on the herdr rail (#157 G1a). Same two receipts, kept apart the same way; the
1268
+ * coordinates are herdr's VIEW of a tab, and herdr's own readiness or session report is not consulted
1269
+ * — the same-gid control socket is the observation on both rails.
1270
+ */
1271
+ function renderHerdrPiResume(
1272
+ result: Extract<VisibleResumeResult, { ok: true }>,
1273
+ h: VisibleResumeHerdrLaunchHandle,
1274
+ actor: string,
1275
+ ): { text: string; isError: boolean } {
1276
+ if (result.launch.backend !== "pi") {
1277
+ throw new Error(
1278
+ "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",
1279
+ );
1280
+ }
1281
+ const observation = result.observation as ResumeObservationReceipt;
1282
+ const observed = observation.kind === "socket-alive";
1283
+ return {
1284
+ text:
1285
+ `[entwurf resume call ↻ herdr]\n` +
1286
+ ` target: ${result.launch.targetGardenId}\n` +
1287
+ ` session: ${result.launch.sessionFile}\n` +
1288
+ ` runtime: requested kind ${h.requestedKind} — herdr resolves the executable, we did not (requested start cwd ${h.cwd})\n` +
1289
+ ` tab: ${h.herdrTabId} in workspace ${h.herdrWorkspaceId} — a NEW tab beside the caller's, opened without taking focus\n` +
1290
+ ` pane: ${h.herdrPaneId} terminal ${h.herdrTerminalId} — herdr VIEW coordinates, not an address\n` +
1291
+ ` agent: ${h.herdrAgentName} (herdr's name for this launch, random per call)\n` +
1292
+ actor +
1293
+ `\n` +
1294
+ `LAUNCH receipt: herdr created that tab and was asked to start pi on the transcript above.\n` +
1295
+ (observed
1296
+ ? `OBSERVATION receipt: the control socket for ${result.launch.targetGardenId} answered after ` +
1297
+ `${observation.waitedMs}ms — that citizen is addressable again, and entwurf_v2 fire-and-forget ` +
1298
+ `now reaches it. No turn was run by this call. herdr's own agent status was not consulted.`
1299
+ : `OBSERVATION receipt: resume-unobserved — the control socket did not answer within ` +
1300
+ `${observation.waitedMs}ms. The tab is open and visible; read it directly. The lock was ` +
1301
+ `released, nothing was retried, and nothing was closed.`),
1302
+ isError: false,
1303
+ };
1304
+ }
1305
+
1306
+ /**
1307
+ * The Claude Code resume on the herdr rail (#157 stage 4). The same two receipts as on tmux, kept
1308
+ * apart the same way. The tab and pane are herdr's VIEW; the pane's direct child is herdr's process
1309
+ * report for the pane this call created — the pid the observation joins, never an address.
1310
+ */
1311
+ function renderHerdrClaudeResume(
1312
+ result: Extract<VisibleResumeResult, { ok: true }>,
1313
+ h: VisibleResumeHerdrClaudeLaunchHandle,
1314
+ actor: string,
1315
+ ): { text: string; isError: boolean } {
1316
+ if (result.launch.backend !== "claude-code") {
1317
+ throw new Error("entwurf-v2-visible-resume: a herdr Claude Code launch handle arrived on a non-Claude Code resume");
1318
+ }
1319
+ const target = result.launch.targetGardenId;
1320
+ const observation = result.observation as ReceiverObservationReceipt;
1321
+ const child =
1322
+ "pid" in h.paneChild
1323
+ ? `pid ${h.paneChild.pid} (herdr's process report for that pane; a join, not an address)`
1324
+ : `unread — ${h.paneChild.unread}`;
1325
+ const left =
1326
+ "The tab is open and visible; read it directly. The lock was released, nothing was retried, and nothing was closed.";
1327
+ return {
1328
+ text:
1329
+ `[entwurf resume call ↻ herdr]\n` +
1330
+ ` target: ${target}\n` +
1331
+ ` backend: claude-code\n` +
1332
+ ` session: ${result.launch.nativeSessionId}\n` +
1333
+ ` runtime: ${h.runtime} --resume — herdr resolved the executable, we did not (requested start cwd ${h.cwd})\n` +
1334
+ ` tab: ${h.herdrTabId} in workspace ${h.herdrWorkspaceId} — a NEW tab from ONE layout.apply, opened without taking focus\n` +
1335
+ ` pane: ${h.herdrPaneId} — herdr VIEW coordinates, not an address\n` +
1336
+ ` child: ${child}\n` +
1337
+ actor +
1338
+ `\n` +
1339
+ `LAUNCH receipt: herdr created that tab and was asked to start claude --resume on the session above.\n` +
1340
+ (observation.kind === "receiver-armed"
1341
+ ? `OBSERVATION receipt: receiver-armed — a NEW live Claude Code process, the direct child of the pane this ` +
1342
+ `call opened (pid ${observation.ownerPid}), re-armed the receiver marker of ${target} for the same session ` +
1343
+ `after ${observation.waitedMs}ms, with a fresh arm binding the record also carries (same createdAt). ` +
1344
+ `entwurf_v2 fire-and-forget now reaches its mailbox. This call submitted no prompt; the runtime's own ` +
1345
+ `activity after resume (e.g. a doorbell on unread mail) is unmeasured.`
1346
+ : observation.kind === "pane-process-unread"
1347
+ ? `OBSERVATION receipt: pane-process-unread — herdr did not report the direct child of the pane this ` +
1348
+ `call opened (${observation.cause}), so no receiver marker could be joined to it and no wait ran. ${left}`
1349
+ : observation.kind === "record-unreadable"
1350
+ ? `OBSERVATION receipt: record-unreadable — the record of ${target} could not be read after ` +
1351
+ `${observation.waitedMs}ms: ${observation.cause}. ${left}`
1352
+ : `OBSERVATION receipt: resume-unobserved — no new live receiver marker from the direct child of the pane ` +
1353
+ `this call opened, bound to the record, appeared within ${observation.waitedMs}ms. ${left}`),
1354
+ isError: false,
1355
+ };
1356
+ }
1357
+
1358
+ /**
1359
+ * A launch that failed after it created something. The header claims no more than the recovery
1360
+ * knows — the fresh herdr rail learned that the hard way: `none` means herdr declined by name before
1361
+ * making anything, `unknown` means no answer we can act on so a tab MAY exist, and anything else means
1362
+ * the tab was created and the recovery says whether its pane was closed. No observation ran, so this
1363
+ * says nothing about whether the citizen came back.
1364
+ */
1365
+ function renderLaunchFailed(result: Extract<VisibleResumeResult, { reason: "launch-failed" }>): {
1366
+ text: string;
1367
+ isError: boolean;
1368
+ } {
1369
+ const f = result.failure;
1370
+ const tabHeader =
1371
+ f.recovery.outcome === "none"
1372
+ ? "entwurf_resume_call failed at the herdr tab create step"
1373
+ : f.recovery.outcome === "unknown"
1374
+ ? "entwurf_resume_call failed at the herdr tab create step with an UNKNOWN outcome"
1375
+ : "entwurf_resume_call failed after the herdr tab was created";
1376
+ // #157 B3: the pi-durable rail asks herdr for ONE layout.apply. Its classifier answers only none or
1377
+ // unknown, so anything but none is UNKNOWN, and there is no tab create step to name.
1378
+ const layout = f.rail === "herdr-layout";
1379
+ const header = layout
1380
+ ? `entwurf_resume_call failed at the herdr layout.apply step${f.recovery.outcome === "none" ? "" : " with an UNKNOWN outcome"}`
1381
+ : tabHeader;
1382
+ const code = f.vendorCode === undefined ? "" : ` [herdr: ${f.vendorCode}]`;
1383
+ const created =
1384
+ f.created === undefined
1385
+ ? ""
1386
+ : ` created: tab ${f.created.herdrTabId} in workspace ${f.created.herdrWorkspaceId}, pane ${f.created.herdrPaneId} ` +
1387
+ `terminal ${f.created.herdrTerminalId} — herdr VIEW coordinates, not an address\n`;
1388
+ const recovery =
1389
+ f.recovery.outcome === "none"
1390
+ ? layout
1391
+ ? " 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"
1392
+ : " recovery: none — herdr declined by name before making anything, so there is nothing to go look at\n"
1393
+ : f.recovery.outcome === "unknown"
1394
+ ? " 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"
1395
+ : f.recovery.outcome === "closed"
1396
+ ? ` 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`
1397
+ : ` recovery: orphan-unreclaimed:${f.recovery.reason} (${f.recovery.paneId}) — this pane was NOT closed, on purpose\n`;
1398
+ return {
1399
+ text:
1400
+ `${header}: ${result.detail}.${code}\n` +
1401
+ created +
1402
+ recovery +
1403
+ `No observation was attempted, so this says nothing about whether the citizen came back — entwurf_peers ` +
1404
+ `shows whether its ${layout ? "rail" : "control socket"} answers. The lock was released and nothing was retried.`,
1405
+ isError: true,
1406
+ };
1407
+ }