@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
@@ -360,9 +360,12 @@ export interface MetaIdentity {
360
360
  * arm-capable event and writes in the SAME atomic upsert as that attach, then copies into
361
361
  * the receiver marker it writes under the record-arm lock. A dead marker carrying the
362
362
  * value the record still carries is evidence that no later arm-capable attach happened;
363
- * one that does not carry it is not. `null` means no binding was ever observed — every
364
- * other backend, a record no arm has touched since this field existed, a create — and
365
- * is never filled by inference. Every writer that does not mint PRESERVES it.
363
+ * one that does not carry it is not. The pi-durable birth (#157 B3 C4) mints it the same way
364
+ * under the same lock and copies it into its location evidence (`pi-durable-location.ts`)
365
+ * instead of a marker; there it binds a location, never a dormancy verdict. `null` means no
366
+ * binding was ever observed — every other backend, a pi-durable record no birth has bound,
367
+ * a record no arm has touched since this field existed, a create — and is never filled by
368
+ * inference. Every writer that does not mint PRESERVES it.
366
369
  */
367
370
  attachIncarnation: string | null;
368
371
  }
@@ -614,10 +617,10 @@ export interface MetaIdentityMintInput {
614
617
  model?: string | null;
615
618
  transcriptPath?: string | null;
616
619
  /**
617
- * The arm binding to commit with this write. ONLY the Claude Code hook passes it, and
618
- * only on an arm-capable event under the record-arm lock; `undefined` (every other
619
- * writer, every UserPromptSubmit) keeps the existing value, and a create without it is
620
- * `null`.
620
+ * The arm binding to commit with this write. ONLY the Claude Code hook (on an arm-capable
621
+ * event) and the pi-durable birth (#157 B3 C4) pass it, each under the record-arm lock;
622
+ * `undefined` (every other writer, every UserPromptSubmit) keeps the existing value, and a
623
+ * create without it is `null`.
621
624
  */
622
625
  attachIncarnation?: string;
623
626
  }
@@ -1471,7 +1474,9 @@ export function defaultMetaReceiversDir(): string {
1471
1474
  * sandboxed receivers root sandboxes it too and no new environment carrier exists. It is
1472
1475
  * deliberately NOT the v2 dispatch lock dir — visible resume holds that per-gid lock across
1473
1476
  * its whole launch + observation window, and the relaunched session's own arm must not wait
1474
- * on it. Only the Claude Code hook takes this lock; nothing else reads or writes here.
1477
+ * on it. The Claude Code hook and the pi-durable birth (`pi-durable-location.ts`, #157 B3 C4)
1478
+ * take this lock, each around the upsert that mints `attachIncarnation`; nothing else reads or
1479
+ * writes here.
1475
1480
  */
1476
1481
  export function defaultMetaRecordArmLocksDir(receiversDir: string = defaultMetaReceiversDir()): string {
1477
1482
  return path.join(path.dirname(receiversDir), "meta-record-arm-locks");
@@ -23,7 +23,11 @@
23
23
  * This is the tmux rail. A caller inside herdr (`HERDR_ENV=1`, herdr's own marker, read for its
24
24
  * exact value as `fresh-call-dispatch` does) is refused by name before anything else: herdr owns
25
25
  * placement there, and an ambient `TMUX` that happens to be set would otherwise put the window in
26
- * a session the operator is not looking at. There is no herdr resume rail yet and no fallback.
26
+ * a session the operator is not looking at. Since #157 G1a the shared rail root (`fresh-call-dispatch`)
27
+ * hands a resume inside herdr to the separate herdr rail (`herdr-resume-call.ts`) and never reaches
28
+ * this leaf there;
29
+ * this guard stays so that a direct caller still cannot open a tmux window from inside herdr. No
30
+ * fallback in either direction.
27
31
  *
28
32
  * ── The cwd is the whole reason this module owns its own tmux argv ──
29
33
  *
@@ -77,6 +81,49 @@ import {
77
81
  export const RESUME_CALL_RUNTIME = { pi: "pi", "claude-code": "claude" } as const;
78
82
  export type ResumeCallBackend = keyof typeof RESUME_CALL_RUNTIME;
79
83
 
84
+ /**
85
+ * #157 B3: a pi-durable resume reopens through the MANAGED verb, exactly as its fresh call does —
86
+ * `entwurf pi-durable` verifies this package's carrier and its SDK set before anything runs, and the
87
+ * durable app is never started bare. It is its own entry (`resumePiDurableCall`) rather than a row of
88
+ * the table above, because it is the one resume whose launch also carries an environment.
89
+ */
90
+ export const RESUME_CALL_PI_DURABLE_RUNTIME = "entwurf";
91
+
92
+ /**
93
+ * The pi-durable adopt launch projection (#157 B3, L3-A1 §1): EXACTLY these five names, each read by
94
+ * name and emitted in this order — the SDK's own profile input, and the standard children of ONE meta
95
+ * root. It is a fixed projection of two coordinates the caller derived, not an environment dictionary:
96
+ * no other name can be expressed through it. The launched child checks the four roots it actually
97
+ * received against its closed adopt context, and its raw profile against the reservation, before
98
+ * anything opens, so a value this module mangled would refuse there rather than open something else.
99
+ */
100
+ export interface ResumeCallPiDurableAdoptEnv {
101
+ readonly PI_CODING_AGENT_DIR: string;
102
+ readonly ENTWURF_META_SESSIONS_DIR: string;
103
+ readonly ENTWURF_META_MAILBOX_DIR: string;
104
+ readonly ENTWURF_META_SENDERS_DIR: string;
105
+ readonly ENTWURF_META_RECEIVERS_DIR: string;
106
+ }
107
+
108
+ /** The five `NAME=value` assignments, in the fixed order, or none. Every value must be an absolute
109
+ * path: a relative one would resolve against whatever directory the child starts in. */
110
+ function adoptEnvAssignments(adoptEnv: ResumeCallPiDurableAdoptEnv | undefined): string[] {
111
+ if (adoptEnv === undefined) return [];
112
+ const pairs: readonly (readonly [string, string])[] = [
113
+ ["PI_CODING_AGENT_DIR", adoptEnv.PI_CODING_AGENT_DIR],
114
+ ["ENTWURF_META_SESSIONS_DIR", adoptEnv.ENTWURF_META_SESSIONS_DIR],
115
+ ["ENTWURF_META_MAILBOX_DIR", adoptEnv.ENTWURF_META_MAILBOX_DIR],
116
+ ["ENTWURF_META_SENDERS_DIR", adoptEnv.ENTWURF_META_SENDERS_DIR],
117
+ ["ENTWURF_META_RECEIVERS_DIR", adoptEnv.ENTWURF_META_RECEIVERS_DIR],
118
+ ];
119
+ for (const [name, value] of pairs) {
120
+ if (typeof value !== "string" || !value.startsWith("/")) {
121
+ throw new Error(`mux-resume-call: refusing to build argv with a non-absolute ${name}: ${JSON.stringify(value)}`);
122
+ }
123
+ }
124
+ return pairs.map(([name, value]) => `${name}=${value}`);
125
+ }
126
+
80
127
  /** Why a resume window could not be opened. Every value is a NAMED refusal; this module has no
81
128
  * fallback launch and no fallback directory. The cwd members come from the shared classification
82
129
  * leaf and their string values are stable contract. */
@@ -97,7 +144,8 @@ export type ResumeCallResult = { ok: true; receipt: ResumeCallReceipt } | { ok:
97
144
 
98
145
  /**
99
146
  * Launch argv: the leaf's detached-append shape, the fixed identity scrub (`SCRUBBED_INHERITED_ENV`,
100
- * the same two assignments every fresh window carries), `-c`, the runtime, then the caller's flags.
147
+ * the same two assignments every fresh window carries), for a pi-durable adoption the five fixed
148
+ * projection assignments after it (#157 B3), `-c`, the runtime, then the caller's flags.
101
149
  * `--` is what keeps tmux from reading the runtime or its flags as tmux options. The scrub is not a
102
150
  * carrier: it only empties identity a tmux server may have inherited, and a resumed Claude Code
103
151
  * (which, unlike pi, never re-exports its own) would otherwise present a stale one to its bridge.
@@ -107,16 +155,19 @@ export function buildResumeCallArgs(
107
155
  cwd: string,
108
156
  runtimePath: string,
109
157
  runtimeArgs: readonly string[],
158
+ adoptEnv?: ResumeCallPiDurableAdoptEnv,
110
159
  ): string[] {
111
160
  assertSelector("session", placement.sessionId);
112
161
  assertLaunchTarget(runtimePath);
113
162
  const bad = classifyTmuxCwd(cwd);
114
163
  if (bad) throw new Error(`mux-resume-call: refusing to build argv with an unusable cwd (${bad}): ${cwd}`);
164
+ const adoptAssignments = adoptEnvAssignments(adoptEnv);
115
165
  return [
116
166
  "new-window",
117
167
  "-d",
118
168
  "-a",
119
169
  ...SCRUBBED_INHERITED_ENV.flatMap((assignment) => ["-e", assignment]),
170
+ ...adoptAssignments.flatMap((assignment) => ["-e", assignment]),
120
171
  "-t",
121
172
  `${placement.sessionId}:{end}`,
122
173
  "-c",
@@ -140,6 +191,28 @@ export function buildResumeCallArgs(
140
191
  export function resumeCall(
141
192
  params: { backend: ResumeCallBackend; cwd: string; runtimeArgs: readonly string[] },
142
193
  env: NodeJS.ProcessEnv = process.env,
194
+ ): ResumeCallResult {
195
+ return openResumeWindow(RESUME_CALL_RUNTIME[params.backend], params, undefined, env);
196
+ }
197
+
198
+ /**
199
+ * #157 B3: the pi-durable resume window. The same order, refusals and receipt as `resumeCall` — the
200
+ * runtime is the managed `entwurf`, and the window additionally carries the fixed adopt projection.
201
+ * `runtimeArgs` (the managed verb and its closed adopt context) is passed through untouched; this
202
+ * module never decodes it.
203
+ */
204
+ export function resumePiDurableCall(
205
+ params: { cwd: string; runtimeArgs: readonly string[]; adoptEnv: ResumeCallPiDurableAdoptEnv },
206
+ env: NodeJS.ProcessEnv = process.env,
207
+ ): ResumeCallResult {
208
+ return openResumeWindow(RESUME_CALL_PI_DURABLE_RUNTIME, params, params.adoptEnv, env);
209
+ }
210
+
211
+ function openResumeWindow(
212
+ runtime: string,
213
+ params: { cwd: string; runtimeArgs: readonly string[] },
214
+ adoptEnv: ResumeCallPiDurableAdoptEnv | undefined,
215
+ env: NodeJS.ProcessEnv,
143
216
  ): ResumeCallResult {
144
217
  if (env.HERDR_ENV === "1") return { ok: false, reason: "herdr-resume-unsupported" };
145
218
  const badCwd = classifyTmuxCwd(params.cwd);
@@ -147,7 +220,7 @@ export function resumeCall(
147
220
 
148
221
  let runtimePath: string;
149
222
  try {
150
- runtimePath = resolveRuntimeOnPath(RESUME_CALL_RUNTIME[params.backend], env);
223
+ runtimePath = resolveRuntimeOnPath(runtime, env);
151
224
  } catch (err) {
152
225
  if (err instanceof LaunchPreconditionError) return { ok: false, reason: err.reason };
153
226
  throw err;
@@ -158,7 +231,7 @@ export function resumeCall(
158
231
  const placement = inspected.placement;
159
232
  requireSameContext("resumeCall", placement, env);
160
233
 
161
- const run = runTmux(buildResumeCallArgs(placement, params.cwd, runtimePath, params.runtimeArgs), env);
234
+ const run = runTmux(buildResumeCallArgs(placement, params.cwd, runtimePath, params.runtimeArgs, adoptEnv), env);
162
235
  assertTmuxOk("new-window", run);
163
236
 
164
237
  let fields: ReturnType<typeof parseWindowFields>;
@@ -19,7 +19,7 @@
19
19
  * `{v:2,task}` payload, whose decoder caps `task` at 16000 characters. A framed
20
20
  * task over that cap would open a window whose bootstrap refuses its own argv.
21
21
  *
22
- * ── Then the carrier, then the four units ──
22
+ * ── Then the carrier, then the five units ──
23
23
  *
24
24
  * carrier the package's own pi-durable carrier and its declared SDK set, verified
25
25
  * (`pi-durable-runtime.ts`); passed in as that check's verdict.
@@ -29,6 +29,9 @@
29
29
  * it every dispatch to the citizen is the honest `mailbox-undeliverable`.
30
30
  * visible identity the packaged bootstrap — the one that hands the TUI the view whose conversation
31
31
  * labels carry the garden id.
32
+ * owner fence the compiled owner fence in the same closure — the packaged bootstrap imports it,
33
+ * so without it the bootstrap cannot load. Asked here as a file this package must
34
+ * ship, nothing more; the carrier verdict above does not cover it.
32
35
  *
33
36
  * ── What this deliberately does NOT claim ──
34
37
  *
@@ -52,7 +55,8 @@ export type PiDurablePreflightRejectReason =
52
55
  | "pi-durable-birth-unit-missing"
53
56
  | "pi-durable-mcp-hand-missing"
54
57
  | "pi-durable-receive-unit-missing"
55
- | "pi-durable-visible-identity-missing";
58
+ | "pi-durable-visible-identity-missing"
59
+ | "pi-durable-owner-fence-missing";
56
60
 
57
61
  /** `<provider>/<model id>` at the FIRST `/`, or `null` when either side is empty. */
58
62
  export function splitPiDurableModel(model: string): { provider: string; model: string } | null {
@@ -89,6 +93,7 @@ export function piDurableFreshPreflight(params: {
89
93
  if (!existsSync(layout.bridgeEntry)) return "pi-durable-mcp-hand-missing";
90
94
  if (!ownerJoinAdmitsDurableHost(layout.deliverability)) return "pi-durable-receive-unit-missing";
91
95
  if (!existsSync(layout.bootstrap)) return "pi-durable-visible-identity-missing";
96
+ if (!existsSync(layout.ownerFence)) return "pi-durable-owner-fence-missing";
92
97
  return null;
93
98
  }
94
99
 
@@ -110,4 +115,6 @@ export const PI_DURABLE_PREFLIGHT_HINT: Record<
110
115
  "this Entwurf package's compiled owner join does not admit pi-durable-host, so nothing could ever be delivered to the sibling — reinstall or rebuild Entwurf",
111
116
  "pi-durable-visible-identity-missing":
112
117
  "this Entwurf package has no packaged pi-durable bootstrap (pi/pi-durable/bootstrap.mjs), so no TUI would carry the garden id — reinstall Entwurf",
118
+ "pi-durable-owner-fence-missing":
119
+ "this Entwurf package has no compiled pi-durable owner fence (mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-owner-fence.js), which its packaged bootstrap imports — reinstall or rebuild Entwurf",
113
120
  };
@@ -0,0 +1,446 @@
1
+ /**
2
+ * pi-durable LOCATION EVIDENCE (#157 B3, C4 locator leaf) — where a pi-durable citizen's native
3
+ * session directory physically is, written by the durable host's own birth and bound to its
4
+ * record by the record's `attachIncarnation`.
5
+ *
6
+ * WHAT THIS IS. One garden-keyed file per pi-durable citizen, `<locations>/<gardenId>.location.json`,
7
+ * holding the minimum location + binding evidence its attach already knows: the native session id,
8
+ * the realpath cwd, `realpath(session.directory)`, the owner pid and its start key, and the
9
+ * `attachIncarnation` minted in the same record-arm-locked commit as the record's own. The record
10
+ * stays the SOLE address (AGENTS.md Hard Rule 2): this file is evidence the record points at, keyed
11
+ * by the record's garden id, and it counts only while the record carries the very binding it copied.
12
+ *
13
+ * WHAT THIS IS NOT. Not an address, a registry, a presence marker, a liveness or ownership verdict,
14
+ * a dormancy proof or a guarded enrollment. A matching pair says ONE thing: the record's last
15
+ * committed binding named this physical directory and this owner incarnation. A process holding the
16
+ * session before its own commit (the unbound window from the native lease to this birth) is
17
+ * invisible here; only an owner claim taken before the lease could see it, and none exists in this
18
+ * leaf. Public resume of a pi-durable citizen stays refused.
19
+ *
20
+ * WHERE. `<dirname(receiversDir)>/meta-pi-durable-locations`: a sibling of the receivers root the
21
+ * birth actually uses, the same derivation as the record-arm lock dir, so a sandboxed receivers root
22
+ * sandboxes it too and no new environment carrier exists. It is deliberately NOT inside the record
23
+ * store (every entry there is certified), the receivers or senders roots (fresh-cut walks every entry
24
+ * there as a marker) or the mailbox (fresh-cut archives it).
25
+ *
26
+ * LIFECYCLE (Rule 8). Written by a pi-durable birth only; never removed by retire, close, the
27
+ * doorbell or any reader. No reader in this tree enumerates the directory: fresh-cut archives the
28
+ * store and the mailbox and walks the marker roots, the doctor certifies the store. A carrier whose
29
+ * record is absent (a cut generation) or whose binding no longer matches is INERT — every read
30
+ * below refuses it by name. There is no automatic deletion, migration, GC, doctor framework or
31
+ * recovery verb for it.
32
+ *
33
+ * FAILURE STATES OF A BIRTH (all non-green; none is "binding not committed" by fiat):
34
+ * (a) arm lock busy, or any failure before the minted upsert: the store keeps whatever pair it
35
+ * had — possibly an OLDER matching pair;
36
+ * (b) the minted upsert landed but the carrier write failed: record ≠ carrier, a pair mismatch
37
+ * every read refuses;
38
+ * (c) the pair committed but a later step of the birth failed: a matching pair, and still a failed
39
+ * birth. The two files are never one atomic commit.
40
+ * Calling a cleanup is not a cleanup's success: an arm-lock release that does not answer
41
+ * `released` is reported, never assumed.
42
+ */
43
+
44
+ import * as fs from "node:fs";
45
+ import * as path from "node:path";
46
+ import { acquireLock, releaseLock } from "./entwurf-v2-lock.ts";
47
+ import {
48
+ defaultMetaRecordArmLocksDir,
49
+ findCertifiedIdentityByNativeSessionId,
50
+ isPlausibleOwnerPid,
51
+ type MetaIdentity,
52
+ mintAttachIncarnation,
53
+ NOT_REGULAR_ENTRY_CODE,
54
+ processStartKey,
55
+ readAddressableMetaIdentity,
56
+ readStoreRecordFile,
57
+ requireAttachIncarnation,
58
+ requireGardenId,
59
+ requireNonEmptyString,
60
+ type UpsertAction,
61
+ UpsertActionMismatchError,
62
+ upsertMetaSession,
63
+ } from "./meta-session.ts";
64
+
65
+ export const PI_DURABLE_LOCATION_DIRNAME = "meta-pi-durable-locations";
66
+ export const PI_DURABLE_LOCATION_SUFFIX = ".location.json";
67
+ export const PI_DURABLE_LOCATION_SCHEMA_VERSION = 1;
68
+ const PI_DURABLE_BACKEND = "pi-durable";
69
+
70
+ const LOCATION_KEYS: readonly string[] = [
71
+ "schemaVersion",
72
+ "gardenId",
73
+ "backend",
74
+ "nativeSessionId",
75
+ "cwd",
76
+ "nativeDirectory",
77
+ "ownerPid",
78
+ "ownerStartKey",
79
+ "attachIncarnation",
80
+ "writtenAt",
81
+ ];
82
+
83
+ /** The location evidence one pi-durable birth commits. Evidence only — see the file header. */
84
+ export interface PiDurableLocation {
85
+ schemaVersion: typeof PI_DURABLE_LOCATION_SCHEMA_VERSION;
86
+ gardenId: string;
87
+ backend: typeof PI_DURABLE_BACKEND;
88
+ nativeSessionId: string;
89
+ /** The realpath cwd the record carries (`sessions.ts:42` realpaths it before the attach sees it). */
90
+ cwd: string;
91
+ /** `realpath(session.directory)` at the birth: the physical native session directory. */
92
+ nativeDirectory: string;
93
+ ownerPid: number;
94
+ ownerStartKey: string;
95
+ /** The record's `attachIncarnation` minted in the same arm-locked commit. Never null here. */
96
+ attachIncarnation: string;
97
+ writtenAt: string;
98
+ }
99
+
100
+ /** A birth that could not commit its location binding. Always non-green; see FAILURE STATES. */
101
+ export class PiDurableLocationError extends Error {
102
+ readonly reason:
103
+ | "owner-pid-implausible"
104
+ | "cwd-not-absolute"
105
+ | "native-directory-invalid"
106
+ | "owner-start-key-unreadable"
107
+ | "record-arm-lock-busy"
108
+ | "record-arm-lock-release-failed"
109
+ | "carrier-write-failed";
110
+ constructor(reason: PiDurableLocationError["reason"], detail: string, options?: { cause?: unknown }) {
111
+ super(`pi-durable location (${reason}): ${detail}`, options);
112
+ this.name = "PiDurableLocationError";
113
+ this.reason = reason;
114
+ }
115
+ }
116
+
117
+ /** `<dirname(receiversDir)>/meta-pi-durable-locations`, from the receivers root the caller actually uses. */
118
+ export function piDurableLocationDir(receiversDir: string): string {
119
+ if (!path.isAbsolute(receiversDir)) {
120
+ throw new Error(`pi-durable location: the receivers root must be absolute (got ${JSON.stringify(receiversDir)})`);
121
+ }
122
+ return path.join(path.dirname(receiversDir), PI_DURABLE_LOCATION_DIRNAME);
123
+ }
124
+
125
+ export function piDurableLocationPath(gardenId: string, receiversDir: string): string {
126
+ return path.join(piDurableLocationDir(receiversDir), `${requireGardenId(gardenId)}${PI_DURABLE_LOCATION_SUFFIX}`);
127
+ }
128
+
129
+ function requireAbsolute(value: unknown, field: string): string {
130
+ const text = requireNonEmptyString(value, field);
131
+ if (!path.isAbsolute(text)) throw new Error(`location field "${field}" must be an absolute path`);
132
+ return text;
133
+ }
134
+
135
+ /** Strict parse: exact keyset, live schema version, body garden id equal to the file's. Throws on any drift. */
136
+ export function parsePiDurableLocation(json: string, expectedGardenId: string): PiDurableLocation {
137
+ const raw: unknown = JSON.parse(json);
138
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("location is not a JSON object");
139
+ const obj = raw as Record<string, unknown>;
140
+ const stray = Object.keys(obj).filter((key) => !LOCATION_KEYS.includes(key));
141
+ if (stray.length > 0) throw new Error(`location carries unexpected key(s) ${stray.join(", ")}`);
142
+ const missing = LOCATION_KEYS.filter((key) => !(key in obj));
143
+ if (missing.length > 0) throw new Error(`location lacks key(s) ${missing.join(", ")}`);
144
+ if (obj.schemaVersion !== PI_DURABLE_LOCATION_SCHEMA_VERSION) {
145
+ throw new Error(
146
+ `location schemaVersion is ${JSON.stringify(obj.schemaVersion)}, not ${PI_DURABLE_LOCATION_SCHEMA_VERSION}`,
147
+ );
148
+ }
149
+ if (obj.backend !== PI_DURABLE_BACKEND) throw new Error(`location backend is ${JSON.stringify(obj.backend)}`);
150
+ if (!isPlausibleOwnerPid(obj.ownerPid))
151
+ throw new Error(`location ownerPid ${JSON.stringify(obj.ownerPid)} is not a plausible owner`);
152
+ const attachIncarnation = requireAttachIncarnation(obj.attachIncarnation);
153
+ if (attachIncarnation === null)
154
+ throw new Error("location attachIncarnation is null: a location without a binding is not evidence");
155
+ const location: PiDurableLocation = {
156
+ schemaVersion: PI_DURABLE_LOCATION_SCHEMA_VERSION,
157
+ gardenId: requireGardenId(obj.gardenId),
158
+ backend: PI_DURABLE_BACKEND,
159
+ nativeSessionId: requireNonEmptyString(obj.nativeSessionId, "nativeSessionId"),
160
+ cwd: requireAbsolute(obj.cwd, "cwd"),
161
+ nativeDirectory: requireAbsolute(obj.nativeDirectory, "nativeDirectory"),
162
+ ownerPid: obj.ownerPid,
163
+ ownerStartKey: requireNonEmptyString(obj.ownerStartKey, "ownerStartKey"),
164
+ attachIncarnation,
165
+ writtenAt: requireNonEmptyString(obj.writtenAt, "writtenAt"),
166
+ };
167
+ if (location.gardenId !== expectedGardenId) {
168
+ throw new AddressMismatch(`location body names ${location.gardenId}, its file names ${expectedGardenId}`);
169
+ }
170
+ return location;
171
+ }
172
+
173
+ class AddressMismatch extends Error {}
174
+
175
+ /**
176
+ * Atomic private write: an exclusive tmp beside the final name, then rename. A failed write leaves no tmp of its
177
+ * own; an entry that already held the tmp name (the exclusive create failed) is not this call's and is left as is.
178
+ */
179
+ function writePiDurableLocation(location: PiDurableLocation, receiversDir: string): string {
180
+ const dir = piDurableLocationDir(receiversDir);
181
+ const file = piDurableLocationPath(location.gardenId, receiversDir);
182
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
183
+ // Ownership of the tmp is its creation by THIS call: a `wx` that failed (EEXIST included) made nothing here.
184
+ let created = false;
185
+ try {
186
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
187
+ const fd = fs.openSync(tmp, "wx", 0o600);
188
+ created = true;
189
+ try {
190
+ fs.writeFileSync(fd, `${JSON.stringify(location, null, "\t")}\n`);
191
+ } finally {
192
+ fs.closeSync(fd);
193
+ }
194
+ fs.renameSync(tmp, file);
195
+ } catch (error) {
196
+ let leftover = "";
197
+ try {
198
+ if (created) fs.rmSync(tmp, { force: true });
199
+ } catch (cleanup) {
200
+ leftover = `; its tmp ${tmp} could not be removed (${cleanup instanceof Error ? cleanup.message : String(cleanup)})`;
201
+ }
202
+ throw new PiDurableLocationError(
203
+ "carrier-write-failed",
204
+ `could not write ${file} (${error instanceof Error ? error.message : String(error)})${leftover}`,
205
+ { cause: error },
206
+ );
207
+ }
208
+ return file;
209
+ }
210
+
211
+ export interface CommitPiDurableBirthOptions {
212
+ nativeSessionId: string;
213
+ /** The realpath cwd the durable session reports (absolute). */
214
+ cwd: string;
215
+ /** `session.directory` as the durable runtime reports it; realpath'd and verified here. */
216
+ sessionDirectory: string;
217
+ ownerPid: number;
218
+ sessionsDir: string;
219
+ /** The receivers root this birth uses (absolute); the arm-lock and location dirs derive from it. */
220
+ receiversDir: string;
221
+ }
222
+
223
+ export interface CommitPiDurableBirthResult {
224
+ /** `create` when THIS birth published the record, else `attach`. */
225
+ action: UpsertAction;
226
+ record: MetaIdentity;
227
+ dir: string;
228
+ /** Absolute path of the written record. */
229
+ path: string;
230
+ locationPath: string;
231
+ }
232
+
233
+ /**
234
+ * The pi-durable birth's record write, in the order the Claude Code hook already uses for its own
235
+ * binding (`meta-bridge-hook.ts` commitRecord): a read-only certified lookup; a create-only publish
236
+ * when absent (a create replaces nothing, so it takes no lock); the record-arm lock for the garden id;
237
+ * the attach upsert minting `attachIncarnation`; the location carrier copying that same value; the
238
+ * release. Everything that can be refused before a write — the receivers root, the owner pid, the cwd,
239
+ * the native directory, the owner start key — is checked first. See FAILURE STATES for what each failure leaves behind.
240
+ */
241
+ export function commitPiDurableBirth(opts: CommitPiDurableBirthOptions): CommitPiDurableBirthResult {
242
+ // A relative receivers root would put the arm lock and the carrier wherever the process happens to be.
243
+ piDurableLocationDir(opts.receiversDir);
244
+ // The writer refuses, before the first record write, what the carrier's parser would refuse: no
245
+ // birth may commit a record whose location evidence could never be read back.
246
+ if (!isPlausibleOwnerPid(opts.ownerPid)) {
247
+ throw new PiDurableLocationError(
248
+ "owner-pid-implausible",
249
+ `owner pid ${JSON.stringify(opts.ownerPid)} is not a plausible owner; nothing was written`,
250
+ );
251
+ }
252
+ if (typeof opts.cwd !== "string" || !path.isAbsolute(opts.cwd)) {
253
+ throw new PiDurableLocationError(
254
+ "cwd-not-absolute",
255
+ `the durable session cwd ${JSON.stringify(opts.cwd)} is not absolute; nothing was written`,
256
+ );
257
+ }
258
+ let nativeDirectory: string;
259
+ try {
260
+ if (!path.isAbsolute(opts.sessionDirectory)) throw new Error("not absolute");
261
+ nativeDirectory = fs.realpathSync(opts.sessionDirectory);
262
+ if (!fs.statSync(nativeDirectory).isDirectory()) throw new Error("not a directory");
263
+ } catch (error) {
264
+ throw new PiDurableLocationError(
265
+ "native-directory-invalid",
266
+ `the durable session directory ${JSON.stringify(opts.sessionDirectory)} is not an existing directory ` +
267
+ `(${error instanceof Error ? error.message : String(error)}); nothing was written`,
268
+ );
269
+ }
270
+ const ownerStartKey = processStartKey(opts.ownerPid);
271
+ if (ownerStartKey === "") {
272
+ throw new PiDurableLocationError(
273
+ "owner-start-key-unreadable",
274
+ `no start key could be read for owner pid ${opts.ownerPid}; nothing was written`,
275
+ );
276
+ }
277
+ const input = {
278
+ backend: PI_DURABLE_BACKEND,
279
+ nativeSessionId: opts.nativeSessionId,
280
+ cwd: opts.cwd,
281
+ model: null,
282
+ transcriptPath: null,
283
+ } as const;
284
+
285
+ let action: UpsertAction = "attach";
286
+ let existing = findCertifiedIdentityByNativeSessionId(opts.nativeSessionId, opts.sessionsDir);
287
+ if (existing === null) {
288
+ try {
289
+ existing = upsertMetaSession({ input, expectAction: "create", dir: opts.sessionsDir }).record;
290
+ action = "create";
291
+ } catch (error) {
292
+ if (!(error instanceof UpsertActionMismatchError)) throw error;
293
+ existing = findCertifiedIdentityByNativeSessionId(opts.nativeSessionId, opts.sessionsDir);
294
+ if (existing === null) throw error;
295
+ }
296
+ }
297
+
298
+ const lockDir = defaultMetaRecordArmLocksDir(opts.receiversDir);
299
+ const acquired = acquireLock(existing.gardenId, { dir: lockDir });
300
+ if (!acquired.ok) {
301
+ throw new PiDurableLocationError(
302
+ "record-arm-lock-busy",
303
+ `${acquired.conflict.detail}; the minted binding was not written, so the store keeps whatever pair it had`,
304
+ );
305
+ }
306
+ let result: CommitPiDurableBirthResult;
307
+ try {
308
+ const attachIncarnation = mintAttachIncarnation();
309
+ const upsert = upsertMetaSession({
310
+ input: { ...input, attachIncarnation },
311
+ expectAction: "attach",
312
+ dir: opts.sessionsDir,
313
+ });
314
+ const locationPath = writePiDurableLocation(
315
+ {
316
+ schemaVersion: PI_DURABLE_LOCATION_SCHEMA_VERSION,
317
+ gardenId: upsert.record.gardenId,
318
+ backend: PI_DURABLE_BACKEND,
319
+ nativeSessionId: upsert.record.nativeSessionId,
320
+ cwd: upsert.record.cwd,
321
+ nativeDirectory,
322
+ ownerPid: opts.ownerPid,
323
+ ownerStartKey,
324
+ attachIncarnation,
325
+ writtenAt: new Date().toISOString(),
326
+ },
327
+ opts.receiversDir,
328
+ );
329
+ result = { action, record: upsert.record, dir: upsert.dir, path: upsert.path, locationPath };
330
+ } catch (error) {
331
+ let released: string;
332
+ try {
333
+ released = releaseLock(acquired.claim);
334
+ } catch (cleanup) {
335
+ released = `threw: ${cleanup instanceof Error ? cleanup.message : String(cleanup)}`;
336
+ }
337
+ if (released !== "released" && error instanceof Error) {
338
+ error.message += ` (the record-arm lock release then answered ${released}, not released)`;
339
+ }
340
+ throw error;
341
+ }
342
+ const released = releaseLock(acquired.claim);
343
+ if (released !== "released") {
344
+ throw new PiDurableLocationError(
345
+ "record-arm-lock-release-failed",
346
+ `the pair for ${result.record.gardenId} was written, but releasing its record-arm lock answered ${released}`,
347
+ );
348
+ }
349
+ return result;
350
+ }
351
+
352
+ export type PiDurableLocationRefusal =
353
+ | "record-unaddressable"
354
+ | "record-not-pi-durable"
355
+ | "record-unbound"
356
+ | "carrier-absent"
357
+ | "carrier-not-regular"
358
+ | "carrier-unreadable"
359
+ | "carrier-malformed"
360
+ | "carrier-address-mismatch"
361
+ | "binding-mismatch"
362
+ | "native-directory-invalid";
363
+
364
+ export type PiDurableLocationVerdict =
365
+ | { ok: true; record: MetaIdentity; location: PiDurableLocation }
366
+ | { ok: false; reason: PiDurableLocationRefusal; detail: string };
367
+
368
+ /**
369
+ * The verification leaf: from a garden id (the address) to location evidence, or a named refusal.
370
+ * The record is read through the addressable reader; the carrier through the store's no-follow,
371
+ * regular-file read; nothing in the native directory is opened. A pass means ONLY that the record's
372
+ * current binding and this carrier agree and the named directory exists as itself — never liveness,
373
+ * ownership, dormancy or an admission.
374
+ */
375
+ export function verifyPiDurableLocation(opts: {
376
+ gardenId: string;
377
+ sessionsDir: string;
378
+ receiversDir: string;
379
+ }): PiDurableLocationVerdict {
380
+ let record: MetaIdentity;
381
+ try {
382
+ record = readAddressableMetaIdentity(opts.gardenId, opts.sessionsDir);
383
+ } catch (error) {
384
+ return {
385
+ ok: false,
386
+ reason: "record-unaddressable",
387
+ detail: error instanceof Error ? error.message : String(error),
388
+ };
389
+ }
390
+ if (record.backend !== PI_DURABLE_BACKEND) {
391
+ return { ok: false, reason: "record-not-pi-durable", detail: `${record.gardenId} is a ${record.backend} record` };
392
+ }
393
+ if (record.attachIncarnation === null) {
394
+ return {
395
+ ok: false,
396
+ reason: "record-unbound",
397
+ detail: `${record.gardenId} carries no attachIncarnation: no committed location binding to check`,
398
+ };
399
+ }
400
+ const file = piDurableLocationPath(record.gardenId, opts.receiversDir);
401
+ let text: string;
402
+ try {
403
+ text = readStoreRecordFile(file);
404
+ } catch (error) {
405
+ const code = (error as { code?: unknown }).code;
406
+ if (code === "ENOENT") return { ok: false, reason: "carrier-absent", detail: `${file} does not exist` };
407
+ if (code === "ELOOP" || code === NOT_REGULAR_ENTRY_CODE) {
408
+ return {
409
+ ok: false,
410
+ reason: "carrier-not-regular",
411
+ detail: `${file} is not a regular file and is never followed`,
412
+ };
413
+ }
414
+ return { ok: false, reason: "carrier-unreadable", detail: error instanceof Error ? error.message : String(error) };
415
+ }
416
+ let location: PiDurableLocation;
417
+ try {
418
+ location = parsePiDurableLocation(text, record.gardenId);
419
+ } catch (error) {
420
+ const detail = error instanceof Error ? error.message : String(error);
421
+ if (error instanceof AddressMismatch) return { ok: false, reason: "carrier-address-mismatch", detail };
422
+ return { ok: false, reason: "carrier-malformed", detail };
423
+ }
424
+ const differs = (["nativeSessionId", "cwd", "attachIncarnation"] as const).filter(
425
+ (field) => location[field] !== record[field],
426
+ );
427
+ if (differs.length > 0) {
428
+ return {
429
+ ok: false,
430
+ reason: "binding-mismatch",
431
+ detail: `the carrier and the record of ${record.gardenId} differ on ${differs.join(", ")}`,
432
+ };
433
+ }
434
+ try {
435
+ const real = fs.realpathSync(location.nativeDirectory);
436
+ if (real !== location.nativeDirectory) throw new Error(`it now resolves to ${real}`);
437
+ if (!fs.statSync(real).isDirectory()) throw new Error("it is not a directory");
438
+ } catch (error) {
439
+ return {
440
+ ok: false,
441
+ reason: "native-directory-invalid",
442
+ detail: `${location.nativeDirectory}: ${error instanceof Error ? error.message : String(error)}`,
443
+ };
444
+ }
445
+ return { ok: true, record, location };
446
+ }