@junghanacs/entwurf 0.13.1 → 0.14.1

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 (158) hide show
  1. package/AGENTS.md +59 -15
  2. package/BASELINE.md +3 -3
  3. package/CHANGELOG.md +74 -0
  4. package/CONTRIBUTING.md +13 -9
  5. package/DELIVERY.md +7 -6
  6. package/README.md +37 -28
  7. package/VERIFY.md +22 -14
  8. package/demo/README.md +1 -1
  9. package/demo/demo-baseline.sh +1 -3
  10. package/demo/demo.sh +2 -5
  11. package/docs/acp-backend-rail.md +9 -4
  12. package/docs/external-mcp-host.md +4 -5
  13. package/docs/setup-clean-host.md +8 -7
  14. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +155 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/classify-tmux-cwd.js +47 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +300 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +141 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  37. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  38. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  39. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  40. package/mcp/entwurf-bridge/src/index.ts +173 -28
  41. package/mcp/entwurf-bridge/start.sh +2 -2
  42. package/mcp/entwurf-bridge/test.sh +23 -9
  43. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  44. package/package.json +22 -11
  45. package/pi-extensions/entwurf-control.ts +227 -40
  46. package/pi-extensions/lib/acp/backend.ts +71 -12
  47. package/pi-extensions/lib/acp/overlay.ts +8 -6
  48. package/pi-extensions/lib/classify-tmux-cwd.ts +50 -0
  49. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  50. package/pi-extensions/lib/entwurf-core.ts +15 -15
  51. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  52. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  53. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  54. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  55. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  56. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  57. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  58. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  59. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  60. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  61. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  62. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  63. package/pi-extensions/lib/meta-session.ts +93 -5
  64. package/pi-extensions/lib/mux-fresh-call.ts +381 -0
  65. package/pi-extensions/lib/mux-launch.ts +267 -0
  66. package/pi-extensions/lib/mux-placement.ts +387 -0
  67. package/pi-extensions/lib/mux-resume-call.ts +189 -0
  68. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  69. package/pi-extensions/lib/session-id.js +8 -5
  70. package/pi-extensions/lib/socket-discovery.ts +3 -3
  71. package/run.sh +471 -201
  72. package/scripts/agy-bridge-config.py +5 -1
  73. package/scripts/check-acp-backend-preflight.ts +1 -1
  74. package/scripts/check-acp-overlay.ts +13 -3
  75. package/scripts/check-acp-stream-hooks.ts +504 -0
  76. package/scripts/check-elapsed.sh +25 -0
  77. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  78. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  79. package/scripts/check-entwurf-control-rpc.ts +4 -3
  80. package/scripts/check-entwurf-resume-args.ts +72 -70
  81. package/scripts/check-entwurf-session-identity.ts +14 -10
  82. package/scripts/check-entwurf-v2-contract.ts +34 -59
  83. package/scripts/check-entwurf-v2-decider.ts +17 -177
  84. package/scripts/check-entwurf-v2-lock.ts +5 -2
  85. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  86. package/scripts/check-entwurf-v2-production.ts +2 -91
  87. package/scripts/check-entwurf-v2-release.ts +10 -105
  88. package/scripts/check-entwurf-v2-runner.ts +4 -85
  89. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  90. package/scripts/check-entwurf-v2-send.ts +0 -28
  91. package/scripts/check-entwurf-v2-surface.ts +157 -128
  92. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  93. package/scripts/check-fresh-cut-gate.sh +1 -1
  94. package/scripts/check-gate-qualification.ts +99 -7
  95. package/scripts/check-install-container.sh +10 -2
  96. package/scripts/check-install-surface.ts +1 -1
  97. package/scripts/check-keyset-overlap.py +1 -1
  98. package/scripts/check-meta-facts.ts +249 -0
  99. package/scripts/check-meta-identity-consumers.ts +1 -1
  100. package/scripts/check-meta-session.ts +169 -0
  101. package/scripts/check-mux-launch-tmux.ts +316 -0
  102. package/scripts/check-mux-launch.ts +288 -0
  103. package/scripts/check-mux-launcher-fence.ts +264 -0
  104. package/scripts/check-mux-parent-artifact.ts +195 -0
  105. package/scripts/check-mux-placement-tmux.ts +322 -0
  106. package/scripts/check-mux-placement.ts +323 -0
  107. package/scripts/check-mux-resume-call.ts +283 -0
  108. package/scripts/check-probe-cli-shim.ts +25 -22
  109. package/scripts/check-probe-ordering.ts +84 -76
  110. package/scripts/check-release-gate-outcomes.ts +127 -7
  111. package/scripts/check-resume-launch-identity.ts +244 -0
  112. package/scripts/check-socket-discovery.ts +1 -1
  113. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  114. package/scripts/inventory-verification-surface.ts +349 -0
  115. package/scripts/lib/claude-launcher-fence.ts +322 -0
  116. package/scripts/lib/mutation-qualify.ts +109 -3
  117. package/scripts/meta-bridge-doctor.sh +6 -8
  118. package/scripts/meta-bridge-state.py +75 -1
  119. package/scripts/meta-facts.ts +60 -0
  120. package/scripts/mutants/acp-overlay.json +17 -0
  121. package/scripts/mutants/acp-stream-hooks.json +158 -0
  122. package/scripts/mutants/bridge-boot-resume.json +45 -0
  123. package/scripts/mutants/meta-facts.json +50 -0
  124. package/scripts/mutants/meta-identity.json +36 -0
  125. package/scripts/mutants/meta-retire.json +47 -0
  126. package/scripts/mutants/mux-boundary.json +196 -0
  127. package/scripts/mutants/mux-fresh-call.json +229 -0
  128. package/scripts/mutants/mux-launcher-fence.json +123 -0
  129. package/scripts/mutants/mux-parent-artifact.json +39 -0
  130. package/scripts/mutants/mux-resume-call.json +148 -0
  131. package/scripts/mutants/probe-ordering.json +0 -1037
  132. package/scripts/mutants/release-gate.json +35 -0
  133. package/scripts/mutants/resume-args.json +76 -0
  134. package/scripts/mutants/resume-launch-identity.json +96 -0
  135. package/scripts/mutants/v2-surface.json +58 -18
  136. package/scripts/mutants/v2-visible-resume.json +215 -0
  137. package/scripts/new-session-id.ts +9 -4
  138. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  139. package/scripts/smoke-agy-native-push-live.ts +6 -17
  140. package/scripts/smoke-entwurf-chain-live.ts +11 -3
  141. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  142. package/scripts/smoke-meta-honesty.sh +1 -1
  143. package/scripts/smoke-meta-install-state.sh +169 -3
  144. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  145. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  146. package/scripts/smoke-pi-attach.ts +1 -1
  147. package/scripts/smoke-user-scope-citizen.sh +1 -1
  148. package/scripts/tsconfig.json +1 -0
  149. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  150. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  151. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  152. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  153. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  154. package/scripts/check-acp-sdk-surface.ts +0 -275
  155. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  156. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  157. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  158. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
@@ -0,0 +1,189 @@
1
+ /**
2
+ * mux-resume-call — open ONE window in the caller's own tmux session at a cwd this module did
3
+ * not choose, and hand it argv this module did not author.
4
+ *
5
+ * ── Why this is a module and not a parameter on fresh-call ──
6
+ *
7
+ * `mux-fresh-call` carries a TASK to a runtime it names, and starts the sibling wherever the
8
+ * caller happens to be unless the caller REQUESTS one literal start directory (#73). A resume
9
+ * carries neither a task nor a caller choice: the argv comes from the record (`entwurf-v2-
10
+ * visible-resume` builds it) and the cwd comes from the record too — it is the directory the
11
+ * citizen's own transcript header remembers, never something the caller picks. Those are
12
+ * different inputs with a different risk, so they get a different module rather than a fourth
13
+ * parameter on a composition whose contract is "identity is an OUTPUT".
14
+ *
15
+ * visible-resume composition → resume-call → placement leaf (unchanged, carrier-free)
16
+ * resume-call -X-> garden identity, records, locks, delivery
17
+ *
18
+ * This module never learns which citizen it is reopening. It sees a cwd and an argv tail.
19
+ *
20
+ * ── The cwd is the whole reason this module owns its own tmux argv ──
21
+ *
22
+ * `mux-placement`'s `buildAppendArgs` deliberately emits no `-c` ("default shell only"), and it
23
+ * stays that way — a resume must not widen the leaf's grammar for the three other callers. So
24
+ * the `-c` SHAPE lives here, while the classification of the value lives in the shared
25
+ * `classify-tmux-cwd.ts` leaf (fresh-call hands tmux the same flag, and a twin copy of the
26
+ * measured rules would rot apart on the next tmux hazard). The measured tmux 3.6a facts —
27
+ * a nonexistent `-c` silently lands the child in `$HOME`, `#` is format-expanded, whitespace
28
+ * is safe — are documented on that leaf. `|` is fine too: the cwd never enters the `-F` row
29
+ * (see `APPEND_FORMAT` below).
30
+ *
31
+ * What stays HERE is the phrasing: this module's hints say "recorded cwd", because a resume's
32
+ * directory comes from the record — fresh-call's say "requested cwd" for the same reasons.
33
+ *
34
+ * ── What the receipt does NOT say ──
35
+ *
36
+ * The handle carries the shared `APPEND_FORMAT` fields only — native ids and decimals. The cwd
37
+ * is reported as what tmux was ASKED for, exactly as `runtimePath` is: `-P -F` prints its row
38
+ * BEFORE the child has chdir'd (measured — `#{pane_current_path}` in that row still reports the
39
+ * client's cwd), so a start-path field there would be a free-form path that is also racy. Proving
40
+ * where the pane actually landed is a separate query against the stable pane id, and it belongs
41
+ * to acceptance, not to the product's launch receipt.
42
+ */
43
+
44
+ import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
45
+ import {
46
+ assertLaunchTarget,
47
+ LaunchPreconditionError,
48
+ type LaunchRejectReason,
49
+ resolveRuntimeOnPath,
50
+ } from "./mux-launch.ts";
51
+ import {
52
+ APPEND_FORMAT,
53
+ assertSelector,
54
+ assertTmuxOk,
55
+ inspectPlacement,
56
+ type Placement,
57
+ type PlacementRejectReason,
58
+ parseWindowFields,
59
+ requireSameContext,
60
+ runTmux,
61
+ type WindowHandle,
62
+ } from "./mux-placement.ts";
63
+
64
+ /** The fixed runtime a resume reopens. Only pi stands a control socket up, so only pi has a
65
+ * same-id resume at all — the backend boundary is enforced upstream by
66
+ * `resolveResumeLaunchIdentity`, and this constant is the mux half of the same fact. */
67
+ export const RESUME_CALL_RUNTIME = "pi";
68
+
69
+ /** Why a resume window could not be opened. Every value is a NAMED refusal; this module has no
70
+ * fallback launch and no fallback directory. The cwd members come from the shared classification
71
+ * leaf and their string values are stable contract. */
72
+ export type ResumeCallRejectReason = PlacementRejectReason | LaunchRejectReason | TmuxCwdRejectReason;
73
+
74
+ /** Coordinates plus what was handed to tmux. `cwd` is the REQUESTED start directory — the same
75
+ * kind of fact as `runtimePath`, namely what tmux was asked for, not an observation. */
76
+ export interface ResumeCallReceipt extends WindowHandle {
77
+ runtimePath: string;
78
+ cwd: string;
79
+ }
80
+
81
+ export type ResumeCallResult = { ok: true; receipt: ResumeCallReceipt } | { ok: false; reason: ResumeCallRejectReason };
82
+
83
+ /**
84
+ * Launch argv: the leaf's detached-append shape plus `-c`, the runtime, then the caller's flags.
85
+ * `--` is what keeps tmux from reading the runtime or its flags as tmux options.
86
+ */
87
+ export function buildResumeCallArgs(
88
+ placement: Placement,
89
+ cwd: string,
90
+ runtimePath: string,
91
+ runtimeArgs: readonly string[],
92
+ ): string[] {
93
+ assertSelector("session", placement.sessionId);
94
+ assertLaunchTarget(runtimePath);
95
+ const bad = classifyTmuxCwd(cwd);
96
+ if (bad) throw new Error(`mux-resume-call: refusing to build argv with an unusable cwd (${bad}): ${cwd}`);
97
+ return [
98
+ "new-window",
99
+ "-d",
100
+ "-a",
101
+ "-t",
102
+ `${placement.sessionId}:{end}`,
103
+ "-c",
104
+ cwd,
105
+ "-P",
106
+ "-F",
107
+ APPEND_FORMAT,
108
+ "--",
109
+ runtimePath,
110
+ ...runtimeArgs,
111
+ ];
112
+ }
113
+
114
+ /**
115
+ * Open the window. Order is the safety argument, same as `freshCall`: classify the cwd, resolve
116
+ * and prove the runtime, re-read the caller's placement and refuse a changed context — and only
117
+ * then mutate. Nothing above can leave a window behind.
118
+ *
119
+ * `runtimeArgs` is passed through untouched. This module does not know what `--session` means.
120
+ */
121
+ export function resumeCall(
122
+ params: { cwd: string; runtimeArgs: readonly string[] },
123
+ env: NodeJS.ProcessEnv = process.env,
124
+ ): ResumeCallResult {
125
+ const badCwd = classifyTmuxCwd(params.cwd);
126
+ if (badCwd) return { ok: false, reason: badCwd };
127
+
128
+ let runtimePath: string;
129
+ try {
130
+ runtimePath = resolveRuntimeOnPath(RESUME_CALL_RUNTIME, env);
131
+ } catch (err) {
132
+ if (err instanceof LaunchPreconditionError) return { ok: false, reason: err.reason };
133
+ throw err;
134
+ }
135
+
136
+ const inspected = inspectPlacement(env);
137
+ if (!inspected.ok) return { ok: false, reason: inspected.reason };
138
+ const placement = inspected.placement;
139
+ requireSameContext("resumeCall", placement, env);
140
+
141
+ const run = runTmux(buildResumeCallArgs(placement, params.cwd, runtimePath, params.runtimeArgs), env);
142
+ assertTmuxOk("new-window", run);
143
+
144
+ let fields: ReturnType<typeof parseWindowFields>;
145
+ try {
146
+ fields = parseWindowFields(run.stdout);
147
+ } catch (err) {
148
+ // The window exists and its id is precisely what could not be read. Diffing the inventory
149
+ // to find "the new one" is the guess this rail forbids everywhere else, so name the orphan.
150
+ throw new Error(
151
+ `mux-resume-call: launched ${runtimePath} but could not read the window handle tmux printed — a window may ` +
152
+ `be open in session ${placement.sessionId} that this call cannot identify or close: ${
153
+ err instanceof Error ? err.message : String(err)
154
+ }`,
155
+ );
156
+ }
157
+
158
+ return {
159
+ ok: true,
160
+ receipt: {
161
+ serverPid: placement.serverPid,
162
+ sessionId: placement.sessionId,
163
+ ...fields,
164
+ runtimePath,
165
+ cwd: params.cwd,
166
+ },
167
+ };
168
+ }
169
+
170
+ /** Why each refusal happened, in the caller's terms. A reason a caller cannot act on is a reason
171
+ * they will guess about. */
172
+ export const RESUME_CALL_REJECT_HINT: Record<ResumeCallRejectReason, string> = {
173
+ "no-tmux-context": "this agent is not running inside tmux, so there is no session to reopen the citizen in",
174
+ "anchor-malformed": "TMUX_PANE is not a native pane id",
175
+ "anchor-unresolved": "tmux resolved no pane for this agent's anchor",
176
+ "anchor-mismatch": "tmux answered about a different pane than the one asked about",
177
+ "cwd-not-absolute": "the recorded cwd is not an absolute path",
178
+ "cwd-format-token":
179
+ "the recorded cwd contains '#', which tmux expands as a format inside -c — it would silently rewrite the path or run a command",
180
+ "cwd-missing":
181
+ "the recorded cwd no longer exists; tmux would not report this, it would open the window in $HOME and look successful",
182
+ "cwd-not-directory": "the recorded cwd exists but is not a directory",
183
+ "runtime-unresolved": "pi is not installed on PATH",
184
+ "runtime-not-absolute": "the resolved runtime path is not absolute",
185
+ "runtime-path-whitespace": "the resolved runtime path contains whitespace, which tmux would re-split",
186
+ "runtime-missing": "nothing exists at the resolved runtime path",
187
+ "runtime-not-regular-file": "the resolved runtime path is not a regular file",
188
+ "runtime-not-executable": "the resolved runtime path is not executable",
189
+ };
@@ -0,0 +1,162 @@
1
+ /**
2
+ * resume-launch-identity — the RECORD-AUTHORITATIVE half of resuming a pi citizen,
3
+ * kept after the visible-first cut removed everything that used to call it.
4
+ *
5
+ * It was the preamble of the `spawn-bg` watcher's `spawnChild` (entwurf-v2-spawn-production.ts,
6
+ * deleted with that transport): garden id → the meta-record → the transcript that record
7
+ * claims → the provider/model/cwd that transcript's own header carries. None of that is
8
+ * about being hidden or visible. It is the answer to "which being is this, and which
9
+ * conversation is theirs", and it is the same answer a VISIBLE resume will need.
10
+ *
11
+ * So this leaf is preserved deliberately, with no consumer in the shipped tree today.
12
+ * `check-resume-launch-identity` holds its contract so the record-integrity rules below —
13
+ * the transcript header id must equal `record.nativeSessionId`, an addressable read
14
+ * (#52) rather than a plain targeted one, a recorded ACP provider must resolve its
15
+ * bridge — cannot rot while they wait.
16
+ *
17
+ * It takes a bare garden id, not a plan: the plan type it used to destructure belonged to
18
+ * the removed transport, and the identity question never needed the rest of it.
19
+ */
20
+
21
+ import { existsSync } from "node:fs";
22
+ import path from "node:path";
23
+ import { getEntwurfExplicitExtensions, readSessionIdentity } from "./entwurf-core.ts";
24
+ import { readAddressableMetaIdentity } from "./meta-session.ts";
25
+
26
+ /**
27
+ * The ONE expected refusal on this leaf: the target is a citizen of a backend that has no
28
+ * same-id resume at all.
29
+ *
30
+ * Everything else this function throws is a defect in the record, the transcript or the
31
+ * environment — a stale path, a foreign session file, an unrecorded model, an unresolvable
32
+ * bridge — and those keep their bare cause-rich `Error`, because a caller cannot act on them
33
+ * except by looking. A Claude Code or agy garden id is different in kind: nothing is broken,
34
+ * the operator simply asked a capability boundary to do something it does not cover, and the
35
+ * honest answer is a named refusal rather than an error report about a store that is fine.
36
+ *
37
+ * It carries a `reason` so the caller matches on a FIELD. Parsing the message string would make
38
+ * the wording load-bearing, and re-reading the record to ask "was it pi?" would read an address
39
+ * twice and could answer differently the second time.
40
+ */
41
+ export class ResumeBackendUnsupportedError extends Error {
42
+ readonly reason = "target-not-pi" as const;
43
+ readonly backend: string;
44
+ constructor(gardenId: string, backend: string) {
45
+ super(
46
+ `resume-launch-identity: ${gardenId} is a ${backend} citizen — same-id resume is a host-adapter relaunch ` +
47
+ `capability, and its domain currently contains backend pi only (only pi stands a control socket up). ` +
48
+ `This is a capability boundary, not the control-socket rail and not citizen rank.`,
49
+ );
50
+ this.name = "ResumeBackendUnsupportedError";
51
+ this.backend = backend;
52
+ }
53
+ }
54
+
55
+ /** The launch-time facts `buildResumePiArgs` needs, resolved from the meta-record + the
56
+ * recorded transcript (record authority, #50 C2/C3). */
57
+ export interface LaunchIdentity {
58
+ /** The EXACT session JSONL to resume — `pi --session <path>`. */
59
+ sessionFile: string;
60
+ cwd: string;
61
+ explicitExtensionArgs: readonly string[];
62
+ provider: string | null | undefined;
63
+ model: string;
64
+ }
65
+
66
+ /**
67
+ * Resolve launch identity for a resume. The TARGET is now resolved through the
68
+ * meta-record (#50 C2): `gardenId → record.transcriptPath`. It used to be a global
69
+ * header scan for a JSONL whose header id equalled the garden id — which only worked
70
+ * while entwurf forced pi's session id to BE the garden id. With the record minting the
71
+ * address, that scan cannot find anything (a citizen's header carries pi's own uuid), so
72
+ * keeping it would not have been a "smaller change", it would have been a broken one.
73
+ *
74
+ * The record is also the AUTHORIZATION now (#50 C3). The old gates — `requireEntwurf`
75
+ * (an `entwurf` tag in the session NAME, planted by a name mirror that no longer
76
+ * exists) and the sessionId-bound resume-marker env — are deleted. Record-backed pi
77
+ * citizens are all siblings (LOCKED PROTOCOL 6), so "this garden id names a pi citizen
78
+ * with a recorded transcript" is the whole test, PLUS one integrity check: the resumed
79
+ * file's header id must equal `record.nativeSessionId` (pi owns the transcript, the
80
+ * record remembers whose it is — a mismatch means the transcriptPath is stale or
81
+ * foreign, and resuming it would put a turn into a different being's session).
82
+ *
83
+ * That check is per-record, and per-record is not enough: the v2 lock domain is keyed on
84
+ * GARDEN ID, so two records sharing one `nativeSessionId` would each pass their own
85
+ * integrity check and resume the SAME transcript concurrently under two different locks.
86
+ * Hence {@link readAddressableMetaIdentity} rather than the plain targeted read (#52) —
87
+ * a resume is exactly the moment a record stops being data and becomes an address.
88
+ *
89
+ * Everything else is unchanged authority: readSessionIdentity (first model_change) for
90
+ * provider/model/cwd, getEntwurfExplicitExtensions for bridge re-injection (#29 fail-fast).
91
+ * Throws on anything that makes a resume impossible — a stale/foreign transcript, a
92
+ * deleted session file, an unrecorded model, an unresolvable ACP bridge. Every throw
93
+ * names its own cause; none of them degrades into a silent no-op.
94
+ */
95
+ export function resolveResumeLaunchIdentity(gardenId: string): LaunchIdentity {
96
+ const record = readAddressableMetaIdentity(gardenId);
97
+ if (record.backend !== "pi") {
98
+ throw new ResumeBackendUnsupportedError(gardenId, record.backend);
99
+ }
100
+ const sessionFile = record.transcriptPath;
101
+ if (!sessionFile) {
102
+ throw new Error(
103
+ `resume-launch-identity: ${gardenId} has no recorded transcriptPath — ` +
104
+ `the citizen never wrote a session file (no turn yet), so there is nothing to resume.`,
105
+ );
106
+ }
107
+ // A recorded path is only a resume target while it names ONE file from everywhere. The record
108
+ // schema types transcriptPath as a nullable string and does not require an absolute path, and
109
+ // `existsSync` below would happily resolve a relative one against THIS process's cwd — while
110
+ // the launch resolves `--session <relative>` inside the window's own `-c <record cwd>`. Those
111
+ // are two different files whenever the two directories differ, and the resume would open the
112
+ // wrong transcript with a receipt that looks correct. This is a bad record, not an expected
113
+ // refusal, so it throws with its own cause like every other integrity failure here.
114
+ if (!path.isAbsolute(sessionFile)) {
115
+ throw new Error(
116
+ `resume-launch-identity: ${gardenId} recorded a RELATIVE transcriptPath ${JSON.stringify(sessionFile)} — ` +
117
+ `a resume resolves --session inside the window's own working directory, so a relative path would name a ` +
118
+ `different file than the one checked here; refusing rather than resuming an unknown transcript.`,
119
+ );
120
+ }
121
+ // A recorded path is only a resume target while the file is actually on disk.
122
+ // Without this check a missing transcript falls through readSessionIdentity's
123
+ // ENOENT swallow and surfaces as "no recorded model" — the wrong cause (F7):
124
+ // the transcript was deleted, or the record carries a phantom path minted
125
+ // before birth guarded on file existence.
126
+ if (!existsSync(sessionFile)) {
127
+ throw new Error(
128
+ `resume-launch-identity: ${gardenId} recorded transcriptPath "${sessionFile}" ` +
129
+ `does not exist on disk — the transcript was deleted, or the record carries a phantom ` +
130
+ `path from a pre-guard birth; nothing to resume.`,
131
+ );
132
+ }
133
+ const identity = readSessionIdentity(sessionFile);
134
+ const resumeModel = identity?.modelId ?? null;
135
+ if (!identity || !resumeModel) {
136
+ throw new Error(`resume-launch-identity: ${gardenId} has no recorded model — cannot resume.`);
137
+ }
138
+ if (identity.sessionId !== record.nativeSessionId) {
139
+ throw new Error(
140
+ `resume-launch-identity: ${gardenId} transcript header id "${identity.sessionId ?? "(none)"}" ` +
141
+ `does not match the record's nativeSessionId "${record.nativeSessionId}" — the recorded transcriptPath ` +
142
+ `is stale or points at a foreign session file; refusing to resume another being's transcript.`,
143
+ );
144
+ }
145
+ const explicitExtensions = getEntwurfExplicitExtensions(resumeModel, false, identity.provider);
146
+ if (explicitExtensions.unresolvedAcpIntent) {
147
+ throw new Error(
148
+ `resume-launch-identity: ${gardenId} recorded provider=entwurf but the bridge ` +
149
+ `extension could not be resolved — refusing to resume with an unknown provider (#29).`,
150
+ );
151
+ }
152
+ if (!identity.cwd) {
153
+ throw new Error(`resume-launch-identity: ${gardenId} header has no cwd (the cold-resume authority, #9).`);
154
+ }
155
+ return {
156
+ sessionFile,
157
+ cwd: identity.cwd,
158
+ explicitExtensionArgs: explicitExtensions.args,
159
+ provider: explicitExtensions.provider ?? identity.provider,
160
+ model: explicitExtensions.modelOverride ?? resumeModel,
161
+ };
162
+ }
@@ -12,8 +12,9 @@
12
12
  * every path, so the id grammar has ONE definition instead of one-per-importer.
13
13
  *
14
14
  * Keep dependency-free except `node:crypto`. The validator/grammar here is the
15
- * same one the 0.9.0 resident garden guard and entwurf spawn collision pre-check
16
- * enforce do NOT fork it.
15
+ * one every V3 record/address surface shares do NOT fork it. Collision safety
16
+ * is NOT a validator/generator concern: it belongs to the record store's
17
+ * exclusive CREATE publish, which fails loud on an occupied final path.
17
18
  */
18
19
 
19
20
  import { randomBytes } from "node:crypto";
@@ -45,9 +46,11 @@ export function formatSessionTimestamp(now = new Date()) {
45
46
  }
46
47
 
47
48
  /**
48
- * Durable garden sessionId minted at the session's true birth. 6 hex suffix
49
- * defeats same-second parallel-spawn collision; callers that spawn still
50
- * header-scan pre-check (assertSessionIdAvailableForSpawn).
49
+ * Durable garden sessionId minted at the session's true birth. The 6-hex
50
+ * (24-bit) suffix makes a same-second collision unlikely, never impossible.
51
+ * No caller pre-checks availability: collision SAFETY is owned by the record
52
+ * store's exclusive CREATE publish (upsertMetaSession), which fails loud on
53
+ * an occupied final path instead of replacing whatever entry holds it.
51
54
  *
52
55
  * @param {Date} [now]
53
56
  * @returns {string}
@@ -28,8 +28,8 @@
28
28
  * The legacy bridge `getLiveSessions` guarded this (`entry.isSymbolicLink()`);
29
29
  * deriving the listing from facts would drop that guard unless we re-assert it
30
30
  * here. A symlinked socket is NEVER probed: a citizen owning one is forced to
31
- * `dead` (→ dormant → resume a fresh process, never SEND to a hijacked
32
- * listener); a record-less one is quarantined out of the listing entirely.
31
+ * `dead` (→ dormant → an honest reject, never SEND to a hijacked listener);
32
+ * a record-less one is quarantined out of the listing entirely.
33
33
  * Both surface as `symlinkedGardenIds`.
34
34
  * - MALFORMED NAME (P3): a `*.sock` whose stem is not a garden id has no citizen
35
35
  * to correlate to and is dropped — but VISIBLY (`malformedNames`), not
@@ -127,7 +127,7 @@ export interface LstatLike {
127
127
 
128
128
  /**
129
129
  * Inspect the EXACT control-socket path given (no gid re-derivation) and classify it by
130
- * lstat alone. This is the path-addressed core of the inspection: the 5c-3 spawn-bg watcher
130
+ * lstat alone. This is the path-addressed core of the inspection: a resume watcher
131
131
  * observes `plan.expectedSocketPath` and MUST inspect that exact path (its contract forbids
132
132
  * re-deriving a path from the gid), so the path-taking form is the SSOT and
133
133
  * `inspectTargetControlSocket` is the thin gid→path wrapper over it. `lstatFn` is injectable