@junghanacs/entwurf 0.41.2 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/AGENTS.md +4 -2
  2. package/BASELINE.md +110 -1
  3. package/CHANGELOG.md +328 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/ENTWURF-CLI.json +1229 -0
  6. package/ENTWURF.json +284 -0
  7. package/FAQ.md +4 -4
  8. package/README.md +125 -184
  9. package/VERIFY.md +2 -2
  10. package/docs/external-mcp-host.md +3 -3
  11. package/docs/setup-clean-host.md +37 -12
  12. package/mcp/entwurf-bridge/dist/ENTWURF.json +284 -0
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +46 -196
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/agent-guidance.js +299 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +21 -25
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +498 -29
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -10
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +613 -33
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-resume-call.js +497 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +3 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +23 -20
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +68 -21
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +7 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-location.js +347 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-owner-fence.js +1132 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +1 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +7 -4
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +18 -11
  29. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -60
  30. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +40 -7
  31. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +1 -27
  32. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +1 -1
  33. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  34. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +1 -1
  35. package/mcp/entwurf-bridge/src/index.ts +149 -327
  36. package/mcp/entwurf-bridge/tsconfig.build.json +7 -1
  37. package/native/win32-x64/manifest.json +1 -1
  38. package/package.json +8 -4
  39. package/pi/pi-durable/bootstrap.mjs +179 -6
  40. package/pi/pi-durable/carrier/runtime.js +108 -9
  41. package/pi/pi-durable/carrier/session-name.js +11 -0
  42. package/pi/pi-durable/carrier/sessions.js +96 -20
  43. package/pi/pi-durable/overlay/upstream-pin.json +11 -5
  44. package/pi-extensions/lib/agent-guidance.ts +341 -0
  45. package/pi-extensions/lib/entwurf-v2-surface.ts +25 -31
  46. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +725 -54
  47. package/pi-extensions/lib/fresh-call-dispatch.ts +64 -12
  48. package/pi-extensions/lib/herdr-fresh-call.ts +919 -40
  49. package/pi-extensions/lib/herdr-resume-call.ts +810 -0
  50. package/pi-extensions/lib/meta-session.ts +13 -8
  51. package/pi-extensions/lib/mux-fresh-call.ts +23 -26
  52. package/pi-extensions/lib/mux-resume-call.ts +95 -23
  53. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +9 -2
  54. package/pi-extensions/lib/pi-durable-location.ts +446 -0
  55. package/pi-extensions/lib/pi-durable-owner-fence.ts +1461 -0
  56. package/pi-extensions/lib/pi-durable-runtime.ts +4 -0
  57. package/pi-extensions/lib/remote-pair-send.ts +7 -4
  58. package/pi-extensions/lib/resume-launch-identity.ts +31 -12
  59. package/pi-extensions/meta-bridge-pi-durable.ts +52 -79
  60. package/run.sh +417 -654
  61. package/scripts/build-bridge.sh +10 -0
  62. package/scripts/check-capability-bundle-reach.ts +86 -0
  63. package/scripts/check-claude-launch.ts +341 -0
  64. package/scripts/check-entwurf-self-address.ts +3 -3
  65. package/scripts/check-entwurf-v2-surface.ts +94 -98
  66. package/scripts/check-fresh-call-dispatch.ts +70 -9
  67. package/scripts/check-fresh-cut-gate.sh +23 -9
  68. package/scripts/check-gate-qualification.ts +24 -17
  69. package/scripts/check-herdr-activation.ts +301 -23
  70. package/scripts/check-herdr-fresh-call.ts +161 -25
  71. package/scripts/check-herdr-plugin-build.ts +173 -1
  72. package/scripts/check-herdr-plugin.ts +28 -2
  73. package/scripts/check-herdr-runtime-bootstrap.ts +136 -0
  74. package/scripts/check-install-surface.ts +1236 -6
  75. package/scripts/check-meta-doctor-oracle.sh +1 -1
  76. package/scripts/check-meta-facts.ts +20 -1
  77. package/scripts/check-mux-resume-call.ts +48 -3
  78. package/scripts/check-peer-facts.ts +39 -2
  79. package/scripts/cli-help.mjs +222 -0
  80. package/scripts/dev-bin.sh +39 -2
  81. package/scripts/herdr-activation.mjs +28 -6
  82. package/scripts/herdr-operator-link.mjs +389 -0
  83. package/scripts/herdr-plugin-activate.mjs +5 -0
  84. package/scripts/herdr-plugin-deactivate.mjs +18 -5
  85. package/scripts/herdr-rails.mjs +43 -7
  86. package/scripts/herdr-runtime.mjs +27 -0
  87. package/scripts/meta-bridge-fresh-cut.ts +1 -27
  88. package/scripts/meta-bridge-prune.ts +1 -1
  89. package/scripts/meta-facts.ts +1 -1
  90. package/scripts/mutants/agent-guidance.json +160 -0
  91. package/scripts/mutants/bridge-boot-resume.json +6 -8
  92. package/scripts/mutants/capability-bundle-reach.json +124 -0
  93. package/scripts/mutants/claude-launch.json +142 -0
  94. package/scripts/mutants/cli-help-source.json +420 -0
  95. package/scripts/mutants/control-send-receipt.json +21 -4
  96. package/scripts/mutants/dev-gate-dispatch.json +109 -0
  97. package/scripts/mutants/fresh-call-dispatch.json +38 -2
  98. package/scripts/mutants/fresh-cut.json +15 -0
  99. package/scripts/mutants/herdr-activation.json +128 -0
  100. package/scripts/mutants/herdr-fresh-call.json +278 -5
  101. package/scripts/mutants/herdr-operator-link.json +318 -0
  102. package/scripts/mutants/herdr-plugin-build.json +55 -0
  103. package/scripts/mutants/herdr-plugin.json +17 -0
  104. package/scripts/mutants/herdr-resume-call.json +288 -0
  105. package/scripts/mutants/herdr-runtime-bootstrap.json +48 -0
  106. package/scripts/mutants/meta-facts.json +15 -0
  107. package/scripts/mutants/mux-fresh-call.json +14 -3
  108. package/scripts/mutants/mux-resume-call.json +89 -3
  109. package/scripts/mutants/peer-facts.json +15 -0
  110. package/scripts/mutants/pi-durable.json +996 -6
  111. package/scripts/mutants/remote-pair-send.json +21 -2
  112. package/scripts/mutants/self-address.json +10 -10
  113. package/scripts/mutants/source-install.json +35 -0
  114. package/scripts/mutants/v2-surface.json +50 -64
  115. package/scripts/mutants/v2-visible-resume.json +266 -6
  116. package/scripts/peer-facts.ts +1 -1
  117. package/scripts/smoke-agy-install-state.sh +66 -0
  118. package/scripts/smoke-meta-prune.sh +23 -0
  119. package/scripts/smoke-omp-fresh-live.ts +12 -3
  120. package/scripts/smoke-omp-receive-live.ts +7 -1
  121. package/scripts/smoke-pi-durable-fresh-live.ts +12 -2
  122. package/team.json.example +39 -0
@@ -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
  *
@@ -48,6 +52,7 @@
48
52
  * where the pane actually landed is a separate query against the stable pane id, and it belongs
49
53
  * to acceptance, not to the product's launch receipt.
50
54
  */
55
+ import { ENTWURF_RESUME_LAUNCH_REJECT_HINTS } from "./agent-guidance.js";
51
56
  import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
52
57
  import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, SCRUBBED_INHERITED_ENV, } from "./mux-launch.js";
53
58
  import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
@@ -55,24 +60,53 @@ import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWin
55
60
  * backend boundary is enforced upstream by `resolveResumeLaunchIdentity`, and this is the mux half
56
61
  * of the same fact. */
57
62
  export const RESUME_CALL_RUNTIME = { pi: "pi", "claude-code": "claude" };
63
+ /**
64
+ * #157 B3: a pi-durable resume reopens through the MANAGED verb, exactly as its fresh call does —
65
+ * `entwurf pi-durable` verifies this package's carrier and its SDK set before anything runs, and the
66
+ * durable app is never started bare. It is its own entry (`resumePiDurableCall`) rather than a row of
67
+ * the table above, because it is the one resume whose launch also carries an environment.
68
+ */
69
+ export const RESUME_CALL_PI_DURABLE_RUNTIME = "entwurf";
70
+ /** The five `NAME=value` assignments, in the fixed order, or none. Every value must be an absolute
71
+ * path: a relative one would resolve against whatever directory the child starts in. */
72
+ function adoptEnvAssignments(adoptEnv) {
73
+ if (adoptEnv === undefined)
74
+ return [];
75
+ const pairs = [
76
+ ["PI_CODING_AGENT_DIR", adoptEnv.PI_CODING_AGENT_DIR],
77
+ ["ENTWURF_META_SESSIONS_DIR", adoptEnv.ENTWURF_META_SESSIONS_DIR],
78
+ ["ENTWURF_META_MAILBOX_DIR", adoptEnv.ENTWURF_META_MAILBOX_DIR],
79
+ ["ENTWURF_META_SENDERS_DIR", adoptEnv.ENTWURF_META_SENDERS_DIR],
80
+ ["ENTWURF_META_RECEIVERS_DIR", adoptEnv.ENTWURF_META_RECEIVERS_DIR],
81
+ ];
82
+ for (const [name, value] of pairs) {
83
+ if (typeof value !== "string" || !value.startsWith("/")) {
84
+ throw new Error(`mux-resume-call: refusing to build argv with a non-absolute ${name}: ${JSON.stringify(value)}`);
85
+ }
86
+ }
87
+ return pairs.map(([name, value]) => `${name}=${value}`);
88
+ }
58
89
  /**
59
90
  * Launch argv: the leaf's detached-append shape, the fixed identity scrub (`SCRUBBED_INHERITED_ENV`,
60
- * the same two assignments every fresh window carries), `-c`, the runtime, then the caller's flags.
91
+ * the same two assignments every fresh window carries), for a pi-durable adoption the five fixed
92
+ * projection assignments after it (#157 B3), `-c`, the runtime, then the caller's flags.
61
93
  * `--` is what keeps tmux from reading the runtime or its flags as tmux options. The scrub is not a
62
94
  * carrier: it only empties identity a tmux server may have inherited, and a resumed Claude Code
63
95
  * (which, unlike pi, never re-exports its own) would otherwise present a stale one to its bridge.
64
96
  */
65
- export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs) {
97
+ export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs, adoptEnv) {
66
98
  assertSelector("session", placement.sessionId);
67
99
  assertLaunchTarget(runtimePath);
68
100
  const bad = classifyTmuxCwd(cwd);
69
101
  if (bad)
70
102
  throw new Error(`mux-resume-call: refusing to build argv with an unusable cwd (${bad}): ${cwd}`);
103
+ const adoptAssignments = adoptEnvAssignments(adoptEnv);
71
104
  return [
72
105
  "new-window",
73
106
  "-d",
74
107
  "-a",
75
108
  ...SCRUBBED_INHERITED_ENV.flatMap((assignment) => ["-e", assignment]),
109
+ ...adoptAssignments.flatMap((assignment) => ["-e", assignment]),
76
110
  "-t",
77
111
  `${placement.sessionId}:{end}`,
78
112
  "-c",
@@ -93,6 +127,18 @@ export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs) {
93
127
  * `runtimeArgs` is passed through untouched. This module does not know what `--session` means.
94
128
  */
95
129
  export function resumeCall(params, env = process.env) {
130
+ return openResumeWindow(RESUME_CALL_RUNTIME[params.backend], params, undefined, env);
131
+ }
132
+ /**
133
+ * #157 B3: the pi-durable resume window. The same order, refusals and receipt as `resumeCall` — the
134
+ * runtime is the managed `entwurf`, and the window additionally carries the fixed adopt projection.
135
+ * `runtimeArgs` (the managed verb and its closed adopt context) is passed through untouched; this
136
+ * module never decodes it.
137
+ */
138
+ export function resumePiDurableCall(params, env = process.env) {
139
+ return openResumeWindow(RESUME_CALL_PI_DURABLE_RUNTIME, params, params.adoptEnv, env);
140
+ }
141
+ function openResumeWindow(runtime, params, adoptEnv, env) {
96
142
  if (env.HERDR_ENV === "1")
97
143
  return { ok: false, reason: "herdr-resume-unsupported" };
98
144
  const badCwd = classifyTmuxCwd(params.cwd);
@@ -100,7 +146,7 @@ export function resumeCall(params, env = process.env) {
100
146
  return { ok: false, reason: badCwd };
101
147
  let runtimePath;
102
148
  try {
103
- runtimePath = resolveRuntimeOnPath(RESUME_CALL_RUNTIME[params.backend], env);
149
+ runtimePath = resolveRuntimeOnPath(runtime, env);
104
150
  }
105
151
  catch (err) {
106
152
  if (err instanceof LaunchPreconditionError)
@@ -112,7 +158,7 @@ export function resumeCall(params, env = process.env) {
112
158
  return { ok: false, reason: inspected.reason };
113
159
  const placement = inspected.placement;
114
160
  requireSameContext("resumeCall", placement, env);
115
- const run = runTmux(buildResumeCallArgs(placement, params.cwd, runtimePath, params.runtimeArgs), env);
161
+ const run = runTmux(buildResumeCallArgs(placement, params.cwd, runtimePath, params.runtimeArgs, adoptEnv), env);
116
162
  assertTmuxOk("new-window", run);
117
163
  let fields;
118
164
  try {
@@ -136,21 +182,22 @@ export function resumeCall(params, env = process.env) {
136
182
  };
137
183
  }
138
184
  /** Why each refusal happened, in the caller's terms. A reason a caller cannot act on is a reason
139
- * they will guess about. */
185
+ * they will guess about. The text is authored in root ENTWURF.json (#154 C1-D); each
186
+ * key is bound explicitly so the reason union stays exhaustive here. */
140
187
  export const RESUME_CALL_REJECT_HINT = {
141
- "no-tmux-context": "this agent is not running inside tmux, so there is no session to reopen the citizen in",
142
- "anchor-malformed": "TMUX_PANE is not a native pane id",
143
- "anchor-unresolved": "tmux resolved no pane for this agent's anchor",
144
- "anchor-mismatch": "tmux answered about a different pane than the one asked about",
145
- "cwd-not-absolute": "the recorded cwd is not an absolute path",
146
- "cwd-format-token": "the recorded cwd contains '#', which tmux expands as a format inside -c — it would silently rewrite the path or run a command",
147
- "cwd-missing": "the recorded cwd no longer exists; tmux would not report this, it would open the window in $HOME and look successful",
148
- "cwd-not-directory": "the recorded cwd exists but is not a directory",
149
- "herdr-resume-unsupported": "this agent runs inside herdr, and same-id resume has only a tmux rail — nothing was opened in any ambient tmux session",
150
- "runtime-unresolved": "the citizen's runtime (pi or claude) is not installed on PATH",
151
- "runtime-not-absolute": "the resolved runtime path is not absolute",
152
- "runtime-path-whitespace": "the resolved runtime path contains whitespace, which tmux would re-split",
153
- "runtime-missing": "nothing exists at the resolved runtime path",
154
- "runtime-not-regular-file": "the resolved runtime path is not a regular file",
155
- "runtime-not-executable": "the resolved runtime path is not executable",
188
+ "no-tmux-context": ENTWURF_RESUME_LAUNCH_REJECT_HINTS["no-tmux-context"],
189
+ "anchor-malformed": ENTWURF_RESUME_LAUNCH_REJECT_HINTS["anchor-malformed"],
190
+ "anchor-unresolved": ENTWURF_RESUME_LAUNCH_REJECT_HINTS["anchor-unresolved"],
191
+ "anchor-mismatch": ENTWURF_RESUME_LAUNCH_REJECT_HINTS["anchor-mismatch"],
192
+ "cwd-not-absolute": ENTWURF_RESUME_LAUNCH_REJECT_HINTS["cwd-not-absolute"],
193
+ "cwd-format-token": ENTWURF_RESUME_LAUNCH_REJECT_HINTS["cwd-format-token"],
194
+ "cwd-missing": ENTWURF_RESUME_LAUNCH_REJECT_HINTS["cwd-missing"],
195
+ "cwd-not-directory": ENTWURF_RESUME_LAUNCH_REJECT_HINTS["cwd-not-directory"],
196
+ "herdr-resume-unsupported": ENTWURF_RESUME_LAUNCH_REJECT_HINTS["herdr-resume-unsupported"],
197
+ "runtime-unresolved": ENTWURF_RESUME_LAUNCH_REJECT_HINTS["runtime-unresolved"],
198
+ "runtime-not-absolute": ENTWURF_RESUME_LAUNCH_REJECT_HINTS["runtime-not-absolute"],
199
+ "runtime-path-whitespace": ENTWURF_RESUME_LAUNCH_REJECT_HINTS["runtime-path-whitespace"],
200
+ "runtime-missing": ENTWURF_RESUME_LAUNCH_REJECT_HINTS["runtime-missing"],
201
+ "runtime-not-regular-file": ENTWURF_RESUME_LAUNCH_REJECT_HINTS["runtime-not-regular-file"],
202
+ "runtime-not-executable": ENTWURF_RESUME_LAUNCH_REJECT_HINTS["runtime-not-executable"],
156
203
  };
@@ -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
  *
@@ -78,6 +81,8 @@ export function piDurableFreshPreflight(params) {
78
81
  return "pi-durable-receive-unit-missing";
79
82
  if (!existsSync(layout.bootstrap))
80
83
  return "pi-durable-visible-identity-missing";
84
+ if (!existsSync(layout.ownerFence))
85
+ return "pi-durable-owner-fence-missing";
81
86
  return null;
82
87
  }
83
88
  /** Repair text beside the predicate that decides it. The runtime reasons keep theirs on the runtime
@@ -89,4 +94,5 @@ export const PI_DURABLE_PREFLIGHT_HINT = {
89
94
  "pi-durable-mcp-hand-missing": "this Entwurf package has no compiled entwurf-bridge entry, so the sibling's callback tool would not exist — reinstall or rebuild Entwurf",
90
95
  "pi-durable-receive-unit-missing": "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",
91
96
  "pi-durable-visible-identity-missing": "this Entwurf package has no packaged pi-durable bootstrap (pi/pi-durable/bootstrap.mjs), so no TUI would carry the garden id — reinstall Entwurf",
97
+ "pi-durable-owner-fence-missing": "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",
92
98
  };
@@ -0,0 +1,347 @@
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
+ import * as fs from "node:fs";
44
+ import * as path from "node:path";
45
+ import { acquireLock, releaseLock } from "./entwurf-v2-lock.js";
46
+ import { defaultMetaRecordArmLocksDir, findCertifiedIdentityByNativeSessionId, isPlausibleOwnerPid, mintAttachIncarnation, NOT_REGULAR_ENTRY_CODE, processStartKey, readAddressableMetaIdentity, readStoreRecordFile, requireAttachIncarnation, requireGardenId, requireNonEmptyString, UpsertActionMismatchError, upsertMetaSession, } from "./meta-session.js";
47
+ export const PI_DURABLE_LOCATION_DIRNAME = "meta-pi-durable-locations";
48
+ export const PI_DURABLE_LOCATION_SUFFIX = ".location.json";
49
+ export const PI_DURABLE_LOCATION_SCHEMA_VERSION = 1;
50
+ const PI_DURABLE_BACKEND = "pi-durable";
51
+ const LOCATION_KEYS = [
52
+ "schemaVersion",
53
+ "gardenId",
54
+ "backend",
55
+ "nativeSessionId",
56
+ "cwd",
57
+ "nativeDirectory",
58
+ "ownerPid",
59
+ "ownerStartKey",
60
+ "attachIncarnation",
61
+ "writtenAt",
62
+ ];
63
+ /** A birth that could not commit its location binding. Always non-green; see FAILURE STATES. */
64
+ export class PiDurableLocationError extends Error {
65
+ reason;
66
+ constructor(reason, detail, options) {
67
+ super(`pi-durable location (${reason}): ${detail}`, options);
68
+ this.name = "PiDurableLocationError";
69
+ this.reason = reason;
70
+ }
71
+ }
72
+ /** `<dirname(receiversDir)>/meta-pi-durable-locations`, from the receivers root the caller actually uses. */
73
+ export function piDurableLocationDir(receiversDir) {
74
+ if (!path.isAbsolute(receiversDir)) {
75
+ throw new Error(`pi-durable location: the receivers root must be absolute (got ${JSON.stringify(receiversDir)})`);
76
+ }
77
+ return path.join(path.dirname(receiversDir), PI_DURABLE_LOCATION_DIRNAME);
78
+ }
79
+ export function piDurableLocationPath(gardenId, receiversDir) {
80
+ return path.join(piDurableLocationDir(receiversDir), `${requireGardenId(gardenId)}${PI_DURABLE_LOCATION_SUFFIX}`);
81
+ }
82
+ function requireAbsolute(value, field) {
83
+ const text = requireNonEmptyString(value, field);
84
+ if (!path.isAbsolute(text))
85
+ throw new Error(`location field "${field}" must be an absolute path`);
86
+ return text;
87
+ }
88
+ /** Strict parse: exact keyset, live schema version, body garden id equal to the file's. Throws on any drift. */
89
+ export function parsePiDurableLocation(json, expectedGardenId) {
90
+ const raw = JSON.parse(json);
91
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
92
+ throw new Error("location is not a JSON object");
93
+ const obj = raw;
94
+ const stray = Object.keys(obj).filter((key) => !LOCATION_KEYS.includes(key));
95
+ if (stray.length > 0)
96
+ throw new Error(`location carries unexpected key(s) ${stray.join(", ")}`);
97
+ const missing = LOCATION_KEYS.filter((key) => !(key in obj));
98
+ if (missing.length > 0)
99
+ throw new Error(`location lacks key(s) ${missing.join(", ")}`);
100
+ if (obj.schemaVersion !== PI_DURABLE_LOCATION_SCHEMA_VERSION) {
101
+ throw new Error(`location schemaVersion is ${JSON.stringify(obj.schemaVersion)}, not ${PI_DURABLE_LOCATION_SCHEMA_VERSION}`);
102
+ }
103
+ if (obj.backend !== PI_DURABLE_BACKEND)
104
+ throw new Error(`location backend is ${JSON.stringify(obj.backend)}`);
105
+ if (!isPlausibleOwnerPid(obj.ownerPid))
106
+ throw new Error(`location ownerPid ${JSON.stringify(obj.ownerPid)} is not a plausible owner`);
107
+ const attachIncarnation = requireAttachIncarnation(obj.attachIncarnation);
108
+ if (attachIncarnation === null)
109
+ throw new Error("location attachIncarnation is null: a location without a binding is not evidence");
110
+ const location = {
111
+ schemaVersion: PI_DURABLE_LOCATION_SCHEMA_VERSION,
112
+ gardenId: requireGardenId(obj.gardenId),
113
+ backend: PI_DURABLE_BACKEND,
114
+ nativeSessionId: requireNonEmptyString(obj.nativeSessionId, "nativeSessionId"),
115
+ cwd: requireAbsolute(obj.cwd, "cwd"),
116
+ nativeDirectory: requireAbsolute(obj.nativeDirectory, "nativeDirectory"),
117
+ ownerPid: obj.ownerPid,
118
+ ownerStartKey: requireNonEmptyString(obj.ownerStartKey, "ownerStartKey"),
119
+ attachIncarnation,
120
+ writtenAt: requireNonEmptyString(obj.writtenAt, "writtenAt"),
121
+ };
122
+ if (location.gardenId !== expectedGardenId) {
123
+ throw new AddressMismatch(`location body names ${location.gardenId}, its file names ${expectedGardenId}`);
124
+ }
125
+ return location;
126
+ }
127
+ class AddressMismatch extends Error {
128
+ }
129
+ /**
130
+ * Atomic private write: an exclusive tmp beside the final name, then rename. A failed write leaves no tmp of its
131
+ * own; an entry that already held the tmp name (the exclusive create failed) is not this call's and is left as is.
132
+ */
133
+ function writePiDurableLocation(location, receiversDir) {
134
+ const dir = piDurableLocationDir(receiversDir);
135
+ const file = piDurableLocationPath(location.gardenId, receiversDir);
136
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
137
+ // Ownership of the tmp is its creation by THIS call: a `wx` that failed (EEXIST included) made nothing here.
138
+ let created = false;
139
+ try {
140
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
141
+ const fd = fs.openSync(tmp, "wx", 0o600);
142
+ created = true;
143
+ try {
144
+ fs.writeFileSync(fd, `${JSON.stringify(location, null, "\t")}\n`);
145
+ }
146
+ finally {
147
+ fs.closeSync(fd);
148
+ }
149
+ fs.renameSync(tmp, file);
150
+ }
151
+ catch (error) {
152
+ let leftover = "";
153
+ try {
154
+ if (created)
155
+ fs.rmSync(tmp, { force: true });
156
+ }
157
+ catch (cleanup) {
158
+ leftover = `; its tmp ${tmp} could not be removed (${cleanup instanceof Error ? cleanup.message : String(cleanup)})`;
159
+ }
160
+ throw new PiDurableLocationError("carrier-write-failed", `could not write ${file} (${error instanceof Error ? error.message : String(error)})${leftover}`, { cause: error });
161
+ }
162
+ return file;
163
+ }
164
+ /**
165
+ * The pi-durable birth's record write, in the order the Claude Code hook already uses for its own
166
+ * binding (`meta-bridge-hook.ts` commitRecord): a read-only certified lookup; a create-only publish
167
+ * when absent (a create replaces nothing, so it takes no lock); the record-arm lock for the garden id;
168
+ * the attach upsert minting `attachIncarnation`; the location carrier copying that same value; the
169
+ * release. Everything that can be refused before a write — the receivers root, the owner pid, the cwd,
170
+ * the native directory, the owner start key — is checked first. See FAILURE STATES for what each failure leaves behind.
171
+ */
172
+ export function commitPiDurableBirth(opts) {
173
+ // A relative receivers root would put the arm lock and the carrier wherever the process happens to be.
174
+ piDurableLocationDir(opts.receiversDir);
175
+ // The writer refuses, before the first record write, what the carrier's parser would refuse: no
176
+ // birth may commit a record whose location evidence could never be read back.
177
+ if (!isPlausibleOwnerPid(opts.ownerPid)) {
178
+ throw new PiDurableLocationError("owner-pid-implausible", `owner pid ${JSON.stringify(opts.ownerPid)} is not a plausible owner; nothing was written`);
179
+ }
180
+ if (typeof opts.cwd !== "string" || !path.isAbsolute(opts.cwd)) {
181
+ throw new PiDurableLocationError("cwd-not-absolute", `the durable session cwd ${JSON.stringify(opts.cwd)} is not absolute; nothing was written`);
182
+ }
183
+ let nativeDirectory;
184
+ try {
185
+ if (!path.isAbsolute(opts.sessionDirectory))
186
+ throw new Error("not absolute");
187
+ nativeDirectory = fs.realpathSync(opts.sessionDirectory);
188
+ if (!fs.statSync(nativeDirectory).isDirectory())
189
+ throw new Error("not a directory");
190
+ }
191
+ catch (error) {
192
+ throw new PiDurableLocationError("native-directory-invalid", `the durable session directory ${JSON.stringify(opts.sessionDirectory)} is not an existing directory ` +
193
+ `(${error instanceof Error ? error.message : String(error)}); nothing was written`);
194
+ }
195
+ const ownerStartKey = processStartKey(opts.ownerPid);
196
+ if (ownerStartKey === "") {
197
+ throw new PiDurableLocationError("owner-start-key-unreadable", `no start key could be read for owner pid ${opts.ownerPid}; nothing was written`);
198
+ }
199
+ const input = {
200
+ backend: PI_DURABLE_BACKEND,
201
+ nativeSessionId: opts.nativeSessionId,
202
+ cwd: opts.cwd,
203
+ model: null,
204
+ transcriptPath: null,
205
+ };
206
+ let action = "attach";
207
+ let existing = findCertifiedIdentityByNativeSessionId(opts.nativeSessionId, opts.sessionsDir);
208
+ if (existing === null) {
209
+ try {
210
+ existing = upsertMetaSession({ input, expectAction: "create", dir: opts.sessionsDir }).record;
211
+ action = "create";
212
+ }
213
+ catch (error) {
214
+ if (!(error instanceof UpsertActionMismatchError))
215
+ throw error;
216
+ existing = findCertifiedIdentityByNativeSessionId(opts.nativeSessionId, opts.sessionsDir);
217
+ if (existing === null)
218
+ throw error;
219
+ }
220
+ }
221
+ const lockDir = defaultMetaRecordArmLocksDir(opts.receiversDir);
222
+ const acquired = acquireLock(existing.gardenId, { dir: lockDir });
223
+ if (!acquired.ok) {
224
+ throw new PiDurableLocationError("record-arm-lock-busy", `${acquired.conflict.detail}; the minted binding was not written, so the store keeps whatever pair it had`);
225
+ }
226
+ let result;
227
+ try {
228
+ const attachIncarnation = mintAttachIncarnation();
229
+ const upsert = upsertMetaSession({
230
+ input: { ...input, attachIncarnation },
231
+ expectAction: "attach",
232
+ dir: opts.sessionsDir,
233
+ });
234
+ const locationPath = writePiDurableLocation({
235
+ schemaVersion: PI_DURABLE_LOCATION_SCHEMA_VERSION,
236
+ gardenId: upsert.record.gardenId,
237
+ backend: PI_DURABLE_BACKEND,
238
+ nativeSessionId: upsert.record.nativeSessionId,
239
+ cwd: upsert.record.cwd,
240
+ nativeDirectory,
241
+ ownerPid: opts.ownerPid,
242
+ ownerStartKey,
243
+ attachIncarnation,
244
+ writtenAt: new Date().toISOString(),
245
+ }, opts.receiversDir);
246
+ result = { action, record: upsert.record, dir: upsert.dir, path: upsert.path, locationPath };
247
+ }
248
+ catch (error) {
249
+ let released;
250
+ try {
251
+ released = releaseLock(acquired.claim);
252
+ }
253
+ catch (cleanup) {
254
+ released = `threw: ${cleanup instanceof Error ? cleanup.message : String(cleanup)}`;
255
+ }
256
+ if (released !== "released" && error instanceof Error) {
257
+ error.message += ` (the record-arm lock release then answered ${released}, not released)`;
258
+ }
259
+ throw error;
260
+ }
261
+ const released = releaseLock(acquired.claim);
262
+ if (released !== "released") {
263
+ throw new PiDurableLocationError("record-arm-lock-release-failed", `the pair for ${result.record.gardenId} was written, but releasing its record-arm lock answered ${released}`);
264
+ }
265
+ return result;
266
+ }
267
+ /**
268
+ * The verification leaf: from a garden id (the address) to location evidence, or a named refusal.
269
+ * The record is read through the addressable reader; the carrier through the store's no-follow,
270
+ * regular-file read; nothing in the native directory is opened. A pass means ONLY that the record's
271
+ * current binding and this carrier agree and the named directory exists as itself — never liveness,
272
+ * ownership, dormancy or an admission.
273
+ */
274
+ export function verifyPiDurableLocation(opts) {
275
+ let record;
276
+ try {
277
+ record = readAddressableMetaIdentity(opts.gardenId, opts.sessionsDir);
278
+ }
279
+ catch (error) {
280
+ return {
281
+ ok: false,
282
+ reason: "record-unaddressable",
283
+ detail: error instanceof Error ? error.message : String(error),
284
+ };
285
+ }
286
+ if (record.backend !== PI_DURABLE_BACKEND) {
287
+ return { ok: false, reason: "record-not-pi-durable", detail: `${record.gardenId} is a ${record.backend} record` };
288
+ }
289
+ if (record.attachIncarnation === null) {
290
+ return {
291
+ ok: false,
292
+ reason: "record-unbound",
293
+ detail: `${record.gardenId} carries no attachIncarnation: no committed location binding to check`,
294
+ };
295
+ }
296
+ const file = piDurableLocationPath(record.gardenId, opts.receiversDir);
297
+ let text;
298
+ try {
299
+ text = readStoreRecordFile(file);
300
+ }
301
+ catch (error) {
302
+ const code = error.code;
303
+ if (code === "ENOENT")
304
+ return { ok: false, reason: "carrier-absent", detail: `${file} does not exist` };
305
+ if (code === "ELOOP" || code === NOT_REGULAR_ENTRY_CODE) {
306
+ return {
307
+ ok: false,
308
+ reason: "carrier-not-regular",
309
+ detail: `${file} is not a regular file and is never followed`,
310
+ };
311
+ }
312
+ return { ok: false, reason: "carrier-unreadable", detail: error instanceof Error ? error.message : String(error) };
313
+ }
314
+ let location;
315
+ try {
316
+ location = parsePiDurableLocation(text, record.gardenId);
317
+ }
318
+ catch (error) {
319
+ const detail = error instanceof Error ? error.message : String(error);
320
+ if (error instanceof AddressMismatch)
321
+ return { ok: false, reason: "carrier-address-mismatch", detail };
322
+ return { ok: false, reason: "carrier-malformed", detail };
323
+ }
324
+ const differs = ["nativeSessionId", "cwd", "attachIncarnation"].filter((field) => location[field] !== record[field]);
325
+ if (differs.length > 0) {
326
+ return {
327
+ ok: false,
328
+ reason: "binding-mismatch",
329
+ detail: `the carrier and the record of ${record.gardenId} differ on ${differs.join(", ")}`,
330
+ };
331
+ }
332
+ try {
333
+ const real = fs.realpathSync(location.nativeDirectory);
334
+ if (real !== location.nativeDirectory)
335
+ throw new Error(`it now resolves to ${real}`);
336
+ if (!fs.statSync(real).isDirectory())
337
+ throw new Error("it is not a directory");
338
+ }
339
+ catch (error) {
340
+ return {
341
+ ok: false,
342
+ reason: "native-directory-invalid",
343
+ detail: `${location.nativeDirectory}: ${error instanceof Error ? error.message : String(error)}`,
344
+ };
345
+ }
346
+ return { ok: true, record, location };
347
+ }