@junghanacs/entwurf 0.13.0 → 0.14.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 (193) hide show
  1. package/AGENTS.md +52 -17
  2. package/BASELINE.md +60 -206
  3. package/CHANGELOG.md +64 -0
  4. package/CONTRIBUTING.md +14 -10
  5. package/DELIVERY.md +118 -261
  6. package/README.md +91 -431
  7. package/VERIFY.md +74 -95
  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 +236 -0
  12. package/docs/external-mcp-host.md +131 -0
  13. package/docs/fresh-cut-policy.md +99 -0
  14. package/docs/setup-clean-host.md +124 -328
  15. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +160 -67
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +11 -9
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
  37. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  38. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  39. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
  40. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  41. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  42. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  43. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  44. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  45. package/mcp/entwurf-bridge/src/index.ts +176 -67
  46. package/mcp/entwurf-bridge/start.sh +2 -2
  47. package/mcp/entwurf-bridge/test.sh +23 -9
  48. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  49. package/package.json +25 -11
  50. package/pi-extensions/entwurf-control.ts +220 -71
  51. package/pi-extensions/lib/acp/acp-client.ts +22 -0
  52. package/pi-extensions/lib/acp/augment.ts +42 -3
  53. package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
  54. package/pi-extensions/lib/acp/backend.ts +366 -38
  55. package/pi-extensions/lib/acp/config.ts +1 -1
  56. package/pi-extensions/lib/acp/engraving.ts +43 -1
  57. package/pi-extensions/lib/acp/event-mapper.ts +16 -13
  58. package/pi-extensions/lib/acp/models.ts +4 -4
  59. package/pi-extensions/lib/acp/overlay.ts +11 -9
  60. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  61. package/pi-extensions/lib/entwurf-core.ts +15 -15
  62. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  63. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  64. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  65. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  66. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  67. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  68. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  69. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  70. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  71. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  72. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  73. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  74. package/pi-extensions/lib/meta-session.ts +93 -5
  75. package/pi-extensions/lib/mux-fresh-call.ts +328 -0
  76. package/pi-extensions/lib/mux-launch.ts +267 -0
  77. package/pi-extensions/lib/mux-placement.ts +387 -0
  78. package/pi-extensions/lib/mux-resume-call.ts +221 -0
  79. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  80. package/pi-extensions/lib/session-id.js +8 -5
  81. package/pi-extensions/lib/socket-discovery.ts +3 -3
  82. package/prompts/engraving.md +13 -5
  83. package/run.sh +688 -282
  84. package/scripts/agy-bridge-config.py +5 -1
  85. package/scripts/check-acp-backend-preflight.ts +1 -1
  86. package/scripts/check-acp-carrier-augment.ts +246 -10
  87. package/scripts/check-acp-cortex.ts +5 -5
  88. package/scripts/check-acp-overlay.ts +13 -3
  89. package/scripts/check-acp-prompt-lifecycle.ts +565 -0
  90. package/scripts/check-acp-stop-reason.ts +342 -0
  91. package/scripts/check-acp-stream-hooks.ts +504 -0
  92. package/scripts/check-elapsed.sh +25 -0
  93. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  94. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  95. package/scripts/check-entwurf-control-rpc.ts +4 -3
  96. package/scripts/check-entwurf-resume-args.ts +72 -70
  97. package/scripts/check-entwurf-session-identity.ts +14 -10
  98. package/scripts/check-entwurf-v2-contract.ts +34 -59
  99. package/scripts/check-entwurf-v2-decider.ts +17 -177
  100. package/scripts/check-entwurf-v2-lock.ts +5 -2
  101. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  102. package/scripts/check-entwurf-v2-production.ts +2 -91
  103. package/scripts/check-entwurf-v2-release.ts +10 -105
  104. package/scripts/check-entwurf-v2-runner.ts +4 -85
  105. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  106. package/scripts/check-entwurf-v2-send.ts +0 -28
  107. package/scripts/check-entwurf-v2-surface.ts +198 -128
  108. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  109. package/scripts/check-fresh-cut-gate.sh +1 -1
  110. package/scripts/check-gate-qualification.ts +101 -7
  111. package/scripts/check-install-container.sh +10 -2
  112. package/scripts/check-install-surface.ts +1 -1
  113. package/scripts/check-keyset-overlap.py +1 -1
  114. package/scripts/check-meta-facts.ts +249 -0
  115. package/scripts/check-meta-identity-consumers.ts +1 -1
  116. package/scripts/check-meta-session.ts +169 -0
  117. package/scripts/check-mux-launch-tmux.ts +316 -0
  118. package/scripts/check-mux-launch.ts +288 -0
  119. package/scripts/check-mux-launcher-fence.ts +264 -0
  120. package/scripts/check-mux-parent-artifact.ts +195 -0
  121. package/scripts/check-mux-placement-tmux.ts +322 -0
  122. package/scripts/check-mux-placement.ts +323 -0
  123. package/scripts/check-mux-resume-call.ts +282 -0
  124. package/scripts/check-probe-cli-shim.ts +25 -22
  125. package/scripts/check-probe-ordering.ts +121 -81
  126. package/scripts/check-release-gate-outcomes.ts +452 -0
  127. package/scripts/check-resume-launch-identity.ts +244 -0
  128. package/scripts/check-socket-discovery.ts +1 -1
  129. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  130. package/scripts/inventory-verification-surface.ts +349 -0
  131. package/scripts/lib/claude-launcher-fence.ts +322 -0
  132. package/scripts/lib/live-skip.ts +33 -0
  133. package/scripts/lib/mutation-qualify.ts +109 -3
  134. package/scripts/lib/probe-acp-turn.ts +33 -12
  135. package/scripts/lib/step-outcome.sh +88 -0
  136. package/scripts/meta-bridge-doctor.sh +6 -8
  137. package/scripts/meta-facts.ts +60 -0
  138. package/scripts/mutants/acp-augment.json +106 -0
  139. package/scripts/mutants/acp-cortex.json +2 -2
  140. package/scripts/mutants/acp-overlay.json +17 -0
  141. package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
  142. package/scripts/mutants/acp-stop-reason.json +80 -0
  143. package/scripts/mutants/acp-stream-hooks.json +158 -0
  144. package/scripts/mutants/bridge-boot-resume.json +45 -0
  145. package/scripts/mutants/meta-facts.json +50 -0
  146. package/scripts/mutants/meta-identity.json +36 -0
  147. package/scripts/mutants/mux-boundary.json +196 -0
  148. package/scripts/mutants/mux-fresh-call.json +185 -0
  149. package/scripts/mutants/mux-launcher-fence.json +123 -0
  150. package/scripts/mutants/mux-parent-artifact.json +39 -0
  151. package/scripts/mutants/mux-resume-call.json +148 -0
  152. package/scripts/mutants/probe-ordering.json +6 -1021
  153. package/scripts/mutants/release-gate.json +140 -0
  154. package/scripts/mutants/resume-args.json +76 -0
  155. package/scripts/mutants/resume-launch-identity.json +96 -0
  156. package/scripts/mutants/v2-surface.json +74 -23
  157. package/scripts/mutants/v2-visible-resume.json +215 -0
  158. package/scripts/new-session-id.ts +9 -4
  159. package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
  160. package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
  161. package/scripts/smoke-acp-cortex-live.ts +8 -14
  162. package/scripts/smoke-acp-long-turn-live.ts +185 -0
  163. package/scripts/smoke-acp-mcp-live.ts +2 -2
  164. package/scripts/smoke-acp-memory-containment-live.ts +2 -2
  165. package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
  166. package/scripts/smoke-acp-overlay-live.ts +2 -2
  167. package/scripts/smoke-acp-provider-live.ts +2 -2
  168. package/scripts/smoke-acp-raw-turn-live.ts +3 -3
  169. package/scripts/smoke-acp-session-reuse-live.ts +2 -2
  170. package/scripts/smoke-acp-skill-live.ts +2 -2
  171. package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
  172. package/scripts/smoke-acp-v2-send-live.ts +4 -3
  173. package/scripts/smoke-agy-native-push-live.ts +10 -20
  174. package/scripts/smoke-claude-native-resume-live.sh +13 -3
  175. package/scripts/smoke-entwurf-chain-live.ts +352 -0
  176. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -3
  177. package/scripts/smoke-meta-honesty.sh +1 -1
  178. package/scripts/smoke-meta-install-state.sh +4 -0
  179. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  180. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  181. package/scripts/smoke-pi-attach.ts +1 -1
  182. package/scripts/smoke-user-scope-citizen.sh +1 -1
  183. package/scripts/tsconfig.json +1 -0
  184. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  185. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  186. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  187. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  188. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  189. package/scripts/check-acp-sdk-surface.ts +0 -236
  190. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  191. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  192. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  193. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -469
@@ -0,0 +1,221 @@
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; the sibling starts wherever the caller
8
+ * happens to be. A resume carries neither: the argv comes from the record (`entwurf-v2-visible-
9
+ * resume` builds it) and the cwd comes from the record too — it is the directory the citizen's
10
+ * own transcript header remembers. Those are different inputs with a different risk, so they get
11
+ * a different module rather than a fourth parameter on a composition whose contract is
12
+ * "identity is an OUTPUT".
13
+ *
14
+ * visible-resume composition → resume-call → placement leaf (unchanged, carrier-free)
15
+ * resume-call -X-> garden identity, records, locks, delivery
16
+ *
17
+ * This module never learns which citizen it is reopening. It sees a cwd and an argv tail.
18
+ *
19
+ * ── The cwd is the whole reason this module owns its own tmux argv ──
20
+ *
21
+ * `mux-placement`'s `buildAppendArgs` deliberately emits no `-c` ("default shell only"), and it
22
+ * stays that way — a resume must not widen the leaf's grammar for the three other callers. So
23
+ * the `-c` shape lives here, with the three refusals MEASURED on tmux 3.6a (2026-08-06, private
24
+ * server):
25
+ *
26
+ * 1. a NONEXISTENT `-c` is silent. tmux exits 0, opens the window, and the child falls back to
27
+ * `$HOME`. A resume whose recorded cwd has been deleted would therefore open a visible
28
+ * window in the wrong project and look successful. Nothing downstream can catch that: the
29
+ * launch receipt would be perfectly well-formed.
30
+ * 2. `-c` is FORMAT-EXPANDED. `#{pane_id}` inside the value silently rewrote the path
31
+ * (`<dir>/#{pane_id}` → `<dir>/%0`), and a `#(…)` value was observed running its command.
32
+ * A path is data; tmux reads it as a format. So `#` is refused outright.
33
+ * 3. whitespace is SAFE — argv is an array and nothing re-splits. A dir named `with space`
34
+ * arrived intact. So there is no quoting grammar here, and none is owed. `|` is fine too:
35
+ * the cwd never enters the `-F` row (see `APPEND_FORMAT` below).
36
+ *
37
+ * That is the entire defence: one existence check and one character. No escaping layer, no
38
+ * sanitiser, no symlink policy — a symlinked project dir is a normal thing to work in.
39
+ *
40
+ * ── What the receipt does NOT say ──
41
+ *
42
+ * The handle carries the shared `APPEND_FORMAT` fields only — native ids and decimals. The cwd
43
+ * is reported as what tmux was ASKED for, exactly as `runtimePath` is: `-P -F` prints its row
44
+ * BEFORE the child has chdir'd (measured — `#{pane_current_path}` in that row still reports the
45
+ * client's cwd), so a start-path field there would be a free-form path that is also racy. Proving
46
+ * where the pane actually landed is a separate query against the stable pane id, and it belongs
47
+ * to acceptance, not to the product's launch receipt.
48
+ */
49
+
50
+ import { statSync } from "node:fs";
51
+ import path from "node:path";
52
+ import {
53
+ assertLaunchTarget,
54
+ LaunchPreconditionError,
55
+ type LaunchRejectReason,
56
+ resolveRuntimeOnPath,
57
+ } from "./mux-launch.ts";
58
+ import {
59
+ APPEND_FORMAT,
60
+ assertSelector,
61
+ assertTmuxOk,
62
+ inspectPlacement,
63
+ type Placement,
64
+ type PlacementRejectReason,
65
+ parseWindowFields,
66
+ requireSameContext,
67
+ runTmux,
68
+ type WindowHandle,
69
+ } from "./mux-placement.ts";
70
+
71
+ /** The fixed runtime a resume reopens. Only pi stands a control socket up, so only pi has a
72
+ * same-id resume at all — the backend boundary is enforced upstream by
73
+ * `resolveResumeLaunchIdentity`, and this constant is the mux half of the same fact. */
74
+ export const RESUME_CALL_RUNTIME = "pi";
75
+
76
+ /** Why a resume window could not be opened. Every value is a NAMED refusal; this module has no
77
+ * fallback launch and no fallback directory. */
78
+ export type ResumeCallRejectReason =
79
+ | PlacementRejectReason
80
+ | LaunchRejectReason
81
+ | "cwd-not-absolute"
82
+ | "cwd-format-token"
83
+ | "cwd-missing"
84
+ | "cwd-not-directory";
85
+
86
+ /** Coordinates plus what was handed to tmux. `cwd` is the REQUESTED start directory — the same
87
+ * kind of fact as `runtimePath`, namely what tmux was asked for, not an observation. */
88
+ export interface ResumeCallReceipt extends WindowHandle {
89
+ runtimePath: string;
90
+ cwd: string;
91
+ }
92
+
93
+ export type ResumeCallResult = { ok: true; receipt: ResumeCallReceipt } | { ok: false; reason: ResumeCallRejectReason };
94
+
95
+ /**
96
+ * Classify a candidate start directory. Split into three reasons rather than one because the
97
+ * operator's next move differs: an absolute-path bug is a caller defect, a missing directory is
98
+ * a moved/deleted project, and a `#` is a path tmux would rewrite under us.
99
+ */
100
+ export function classifyResumeCwd(cwd: string): ResumeCallRejectReason | null {
101
+ if (!path.isAbsolute(cwd)) return "cwd-not-absolute";
102
+ // tmux expands formats inside the `-c` VALUE. `#{…}` rewrote the path silently and `#(…)`
103
+ // was observed executing; neither is something to escape our way out of.
104
+ if (cwd.includes("#")) return "cwd-format-token";
105
+ let st: ReturnType<typeof statSync>;
106
+ try {
107
+ st = statSync(cwd);
108
+ } catch {
109
+ // tmux would NOT report this — it opens the window and lands the child in $HOME.
110
+ return "cwd-missing";
111
+ }
112
+ return st.isDirectory() ? null : "cwd-not-directory";
113
+ }
114
+
115
+ /**
116
+ * Launch argv: the leaf's detached-append shape plus `-c`, the runtime, then the caller's flags.
117
+ * `--` is what keeps tmux from reading the runtime or its flags as tmux options.
118
+ */
119
+ export function buildResumeCallArgs(
120
+ placement: Placement,
121
+ cwd: string,
122
+ runtimePath: string,
123
+ runtimeArgs: readonly string[],
124
+ ): string[] {
125
+ assertSelector("session", placement.sessionId);
126
+ assertLaunchTarget(runtimePath);
127
+ const bad = classifyResumeCwd(cwd);
128
+ if (bad) throw new Error(`mux-resume-call: refusing to build argv with an unusable cwd (${bad}): ${cwd}`);
129
+ return [
130
+ "new-window",
131
+ "-d",
132
+ "-a",
133
+ "-t",
134
+ `${placement.sessionId}:{end}`,
135
+ "-c",
136
+ cwd,
137
+ "-P",
138
+ "-F",
139
+ APPEND_FORMAT,
140
+ "--",
141
+ runtimePath,
142
+ ...runtimeArgs,
143
+ ];
144
+ }
145
+
146
+ /**
147
+ * Open the window. Order is the safety argument, same as `freshCall`: classify the cwd, resolve
148
+ * and prove the runtime, re-read the caller's placement and refuse a changed context — and only
149
+ * then mutate. Nothing above can leave a window behind.
150
+ *
151
+ * `runtimeArgs` is passed through untouched. This module does not know what `--session` means.
152
+ */
153
+ export function resumeCall(
154
+ params: { cwd: string; runtimeArgs: readonly string[] },
155
+ env: NodeJS.ProcessEnv = process.env,
156
+ ): ResumeCallResult {
157
+ const badCwd = classifyResumeCwd(params.cwd);
158
+ if (badCwd) return { ok: false, reason: badCwd };
159
+
160
+ let runtimePath: string;
161
+ try {
162
+ runtimePath = resolveRuntimeOnPath(RESUME_CALL_RUNTIME, env);
163
+ } catch (err) {
164
+ if (err instanceof LaunchPreconditionError) return { ok: false, reason: err.reason };
165
+ throw err;
166
+ }
167
+
168
+ const inspected = inspectPlacement(env);
169
+ if (!inspected.ok) return { ok: false, reason: inspected.reason };
170
+ const placement = inspected.placement;
171
+ requireSameContext("resumeCall", placement, env);
172
+
173
+ const run = runTmux(buildResumeCallArgs(placement, params.cwd, runtimePath, params.runtimeArgs), env);
174
+ assertTmuxOk("new-window", run);
175
+
176
+ let fields: ReturnType<typeof parseWindowFields>;
177
+ try {
178
+ fields = parseWindowFields(run.stdout);
179
+ } catch (err) {
180
+ // The window exists and its id is precisely what could not be read. Diffing the inventory
181
+ // to find "the new one" is the guess this rail forbids everywhere else, so name the orphan.
182
+ throw new Error(
183
+ `mux-resume-call: launched ${runtimePath} but could not read the window handle tmux printed — a window may ` +
184
+ `be open in session ${placement.sessionId} that this call cannot identify or close: ${
185
+ err instanceof Error ? err.message : String(err)
186
+ }`,
187
+ );
188
+ }
189
+
190
+ return {
191
+ ok: true,
192
+ receipt: {
193
+ serverPid: placement.serverPid,
194
+ sessionId: placement.sessionId,
195
+ ...fields,
196
+ runtimePath,
197
+ cwd: params.cwd,
198
+ },
199
+ };
200
+ }
201
+
202
+ /** Why each refusal happened, in the caller's terms. A reason a caller cannot act on is a reason
203
+ * they will guess about. */
204
+ export const RESUME_CALL_REJECT_HINT: Record<ResumeCallRejectReason, string> = {
205
+ "no-tmux-context": "this agent is not running inside tmux, so there is no session to reopen the citizen in",
206
+ "anchor-malformed": "TMUX_PANE is not a native pane id",
207
+ "anchor-unresolved": "tmux resolved no pane for this agent's anchor",
208
+ "anchor-mismatch": "tmux answered about a different pane than the one asked about",
209
+ "cwd-not-absolute": "the recorded cwd is not an absolute path",
210
+ "cwd-format-token":
211
+ "the recorded cwd contains '#', which tmux expands as a format inside -c — it would silently rewrite the path or run a command",
212
+ "cwd-missing":
213
+ "the recorded cwd no longer exists; tmux would not report this, it would open the window in $HOME and look successful",
214
+ "cwd-not-directory": "the recorded cwd exists but is not a directory",
215
+ "runtime-unresolved": "pi is not installed on PATH",
216
+ "runtime-not-absolute": "the resolved runtime path is not absolute",
217
+ "runtime-path-whitespace": "the resolved runtime path contains whitespace, which tmux would re-split",
218
+ "runtime-missing": "nothing exists at the resolved runtime path",
219
+ "runtime-not-regular-file": "the resolved runtime path is not a regular file",
220
+ "runtime-not-executable": "the resolved runtime path is not executable",
221
+ };
@@ -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
@@ -1,11 +1,15 @@
1
1
  <!-- ═══════════════════════════════════════════════════════════════════════
2
2
  SAMPLE — operator engraving carrier (entwurf)
3
3
 
4
- What this is: the markdown body below this comment is loaded VERBATIM
5
- into the backend's identity slot (Claude `_meta.systemPrompt`). It is
6
- the one place an operator stamps a short identity onto every entwurf
7
- ACP turn — replacing Claude's `claude_code` preset (which also strips
8
- the preset's auto-memory advertisement, the memory-containment lever).
4
+ What this is: the engraving file is loaded VERBATIM into the backend's
5
+ identity slot (Claude `_meta.systemPrompt`). It is the one place an
6
+ operator stamps a short identity onto every entwurf ACP turn —
7
+ replacing Claude's `claude_code` preset (which also strips the preset's
8
+ auto-memory advertisement, the memory-containment lever).
9
+
10
+ VERBATIM means the WHOLE file, this comment included — the loader does
11
+ not strip markdown comments. Copy this sample and delete everything you
12
+ do not want on the wire; do not point the runtime at it as-is.
9
13
 
10
14
  Where it loads from:
11
15
  • Runtime default = pi-extensions/lib/acp/prompts/engraving.md
@@ -22,6 +26,10 @@
22
26
  tool catalogs here — a large Claude carrier can route OAuth
23
27
  sessions to metered "extra usage" billing.
24
28
  • An empty or missing file = opt-out (no engraving). That is fine.
29
+ • Do NOT open the file with a blank line to separate yourself from
30
+ Claude's fixed SDK sentence. Your leading/trailing whitespace is
31
+ trimmed (it would otherwise drift the reuse signature) and the
32
+ loader opens the carrier with that blank line itself.
25
33
  ═══════════════════════════════════════════════════════════════════════ -->
26
34
 
27
35
  # Engraving Here