@junghanacs/entwurf 0.40.0 → 0.41.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 (118) hide show
  1. package/AGENTS.md +4 -2
  2. package/BASELINE.md +55 -2
  3. package/CHANGELOG.md +321 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +18 -2
  6. package/FAQ.md +8 -4
  7. package/README.md +26 -8
  8. package/VERIFY.md +13 -10
  9. package/docs/acp-backend-rail.md +5 -1
  10. package/docs/external-mcp-host.md +8 -6
  11. package/docs/fresh-cut-policy.md +9 -1
  12. package/docs/setup-clean-host.md +24 -8
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +81 -19
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
  32. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
  33. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  34. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  35. package/mcp/entwurf-bridge/src/index.ts +93 -19
  36. package/mcp/entwurf-bridge/test.sh +2 -2
  37. package/mcp/entwurf-bridge/tsconfig.build.json +10 -1
  38. package/package.json +4 -4
  39. package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
  40. package/pi-extensions/entwurf-control.ts +55 -20
  41. package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  42. package/pi-extensions/lib/cc-retire-request.ts +553 -0
  43. package/pi-extensions/lib/control-retire.ts +195 -0
  44. package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
  45. package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
  46. package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
  48. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
  49. package/pi-extensions/lib/fresh-call-composition.ts +15 -0
  50. package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
  51. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  52. package/pi-extensions/lib/meta-session.ts +140 -16
  53. package/pi-extensions/lib/mux-fresh-call.ts +7 -27
  54. package/pi-extensions/lib/mux-launch.ts +29 -0
  55. package/pi-extensions/lib/mux-resume-call.ts +33 -12
  56. package/pi-extensions/lib/pi-retire-request.ts +111 -0
  57. package/pi-extensions/lib/resume-launch-identity.ts +104 -18
  58. package/pi-extensions/lib/retirement-event.ts +253 -0
  59. package/pi-extensions/meta-bridge-hook.ts +142 -13
  60. package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
  61. package/run.sh +78 -7
  62. package/scripts/agy-bridge-config.py +4 -1
  63. package/scripts/build-bridge.sh +14 -0
  64. package/scripts/check-capability-bundle-reach.ts +135 -2
  65. package/scripts/check-entwurf-bridge-boot.ts +2 -1
  66. package/scripts/check-entwurf-fact-provider.ts +1 -0
  67. package/scripts/check-entwurf-v2-production.ts +2 -0
  68. package/scripts/check-entwurf-v2-surface.ts +7 -0
  69. package/scripts/check-gate-qualification.ts +14 -8
  70. package/scripts/check-herdr-activation.ts +2 -2
  71. package/scripts/check-herdr-placement.ts +1 -0
  72. package/scripts/check-hook-launch-topology.ts +6 -15
  73. package/scripts/check-install-container.sh +2 -2
  74. package/scripts/check-meta-doctor-oracle.sh +27 -1
  75. package/scripts/check-meta-facts.ts +8 -3
  76. package/scripts/check-meta-hook-session-switch.ts +230 -13
  77. package/scripts/check-meta-identity-consumers.ts +1 -0
  78. package/scripts/check-meta-listing.ts +1 -0
  79. package/scripts/check-meta-manifest-schema.py +1 -1
  80. package/scripts/check-meta-session.ts +31 -0
  81. package/scripts/check-meta-v3-record.ts +55 -1
  82. package/scripts/check-mux-resume-call.ts +35 -9
  83. package/scripts/check-peer-facts.ts +1 -0
  84. package/scripts/check-pi-durable-contact.ts +3 -1
  85. package/scripts/check-pi-durable-send.ts +4 -2
  86. package/scripts/check-pi-mcp-bridge.ts +1 -0
  87. package/scripts/check-resume-launch-identity.ts +62 -23
  88. package/scripts/lib/meta-hook-bundle.ts +72 -0
  89. package/scripts/meta-bridge-claude-floor.sh +13 -9
  90. package/scripts/meta-bridge-doctor.sh +17 -0
  91. package/scripts/meta-bridge-install.sh +6 -0
  92. package/scripts/meta-facts.ts +1 -1
  93. package/scripts/mutants/bridge-boot-resume.json +5 -2
  94. package/scripts/mutants/cc-retire-pidfd.json +129 -0
  95. package/scripts/mutants/cc-retire-request.json +171 -0
  96. package/scripts/mutants/control-retire.json +105 -0
  97. package/scripts/mutants/copilot-receive.json +2 -6
  98. package/scripts/mutants/entwurf-v2-retire.json +476 -0
  99. package/scripts/mutants/fresh-call-dispatch.json +37 -0
  100. package/scripts/mutants/meta-hook-session-switch.json +88 -0
  101. package/scripts/mutants/meta-identity.json +57 -0
  102. package/scripts/mutants/mux-fresh-call.json +1 -1
  103. package/scripts/mutants/mux-resume-call.json +40 -4
  104. package/scripts/mutants/pi-durable.json +79 -0
  105. package/scripts/mutants/pi-retire-request.json +72 -0
  106. package/scripts/mutants/resume-launch-identity.json +35 -8
  107. package/scripts/mutants/retirement-event.json +195 -0
  108. package/scripts/mutants/v2-surface.json +16 -0
  109. package/scripts/mutants/v2-visible-resume.json +283 -13
  110. package/scripts/probe-bridge-command.ts +1 -0
  111. package/scripts/smoke-agy-install-state.sh +2 -2
  112. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  113. package/scripts/smoke-herdr-raw-install-live.sh +5 -4
  114. package/scripts/smoke-meta-install-state.sh +4 -4
  115. package/scripts/smoke-mux-lifecycle-live.ts +10 -10
  116. package/scripts/smoke-omp-mcp-state.sh +1 -1
  117. package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
  118. package/scripts/smoke-pi-provider-state.sh +3 -3
@@ -1,6 +1,28 @@
1
1
  /**
2
- * entwurf-v2-visible-resume — stand a dormant pi citizen back up in a window the operator can
3
- * see, under the same garden id, and say only what was actually observed.
2
+ * entwurf-v2-visible-resume — stand a dormant pi or Claude Code citizen back up in a window the
3
+ * operator can see, under the same garden id, and say only what was actually observed.
4
+ *
5
+ * ── Two backends, one shape (#147 L2) ──
6
+ *
7
+ * Both own their transcript and re-attach to their record when the same native session reopens.
8
+ * What differs is the rail each one proves itself on, so the dormancy proof and the observation are
9
+ * per backend and nothing else is:
10
+ *
11
+ * pi dormant = its control socket is dead; back = that socket answers again.
12
+ * claude-code dormant = the LAST VALIDATED owner the Entwurf hook armed for this session is
13
+ * proven gone: the receiver marker names an owner `classifyMarkerOwner` calls DEAD,
14
+ * AND the record re-read after that verdict still carries the same non-null
15
+ * `attachIncarnation` the marker copied (#147 L2 B1) — so no arm-capable attach
16
+ * committed after that owner's. An absent, unreadable, mismatched, live,
17
+ * inconclusive or unbound marker proves nothing and refuses. A process that holds
18
+ * the session without a committed hook attach (hooks disabled, a record-arm lock
19
+ * timeout, a store write failure, a launch after the re-read) is invisible to this
20
+ * proof. Back = a NEW live owner, in the pane this call opened, re-armed the marker
21
+ * for the same session with a fresh binding the current record also carries (same
22
+ * createdAt). The old marker read again is never success.
23
+ *
24
+ * Every other backend is the leaf's named refusal. The argv is a fixed table per backend; the caller
25
+ * supplies only the target.
4
26
  *
5
27
  * ── Where this sits ──
6
28
  *
@@ -41,10 +63,14 @@
41
63
  *
42
64
  * ── Two receipts, never merged ──
43
65
  *
44
- * `launch` says tmux made a window and was asked to start pi. `observation` says whether the
45
- * citizen's socket came back under the same id. Neither says the resumed citizen did anything:
46
- * a resume runs no turn (measured — the transcript stayed byte-identical), so there is no turn
47
- * outcome for this module to claim.
66
+ * `launch` says tmux made a window and was asked to start the runtime. `observation` says whether
67
+ * the citizen came back under the same id (pi: its socket; Claude Code: its receiver marker).
68
+ * Neither says the resumed citizen did anything. This verb submits no prompt. For pi a resume ran
69
+ * no turn (measured 2026-08-06, pi only — the transcript stayed byte-identical); for Claude Code
70
+ * the runtime's own activity after resume (its SessionStart hook re-arms the doorbell, and unread
71
+ * mail may then wake a turn) is unmeasured, so this module claims no turn outcome either way.
72
+ * The socket-only description in the observation section above is the pi half; the Claude Code
73
+ * half reads the receiver marker and the record instead.
48
74
  */
49
75
 
50
76
  import { ACP_MODEL_NOT_CURATED_HINT, AcpModelNotCuratedError } from "./acp/model-ids.js";
@@ -58,7 +84,18 @@ import {
58
84
  releaseLock as realReleaseLock,
59
85
  } from "./entwurf-v2-lock.ts";
60
86
  import {
87
+ classifyMarkerOwner,
88
+ type MarkerOwnerVerdict,
89
+ metaReceiverMarkerPath,
90
+ parseMetaReceiverMarker,
91
+ probePidExistence,
92
+ processStartKey,
93
+ readStoreRecordFile,
94
+ } from "./meta-session.ts";
95
+ import {
96
+ type ClaudeCodeLaunchIdentity,
61
97
  type LaunchIdentity,
98
+ type ResumeBackend,
62
99
  ResumeBackendUnsupportedError,
63
100
  resolveResumeLaunchIdentity,
64
101
  } from "./resume-launch-identity.ts";
@@ -85,18 +122,23 @@ export const RESUME_OBSERVE_INTERVAL_MS = 500;
85
122
  * ACP bridge", …), and copying those causes into an enum would duplicate a truth that already
86
123
  * fails loud — the second copy is what goes stale.
87
124
  *
88
- * `target-not-pi` IS in here, and the line between it and those throws is worth stating: asking
89
- * to resume a Claude Code or agy citizen is not a defect at all. Nothing is corrupt, the record
90
- * is exactly right, and the operator has simply reached past a capability boundary — only pi
91
- * stands a control socket up, so only pi has a same-id resume. That deserves a named refusal a
92
- * caller can act on, not an error report about a store that is fine.
125
+ * `target-backend-unsupported` IS in here, and the line between it and those throws is worth
126
+ * stating: asking to resume a Codex or agy citizen is not a defect at all. Nothing is corrupt, the
127
+ * record is exactly right, and the operator has simply reached past a capability boundary — only
128
+ * pi and Claude Code have a same-id resume. That deserves a named refusal a caller can act on, not
129
+ * an error report about a store that is fine. The four `target-receiver-*` reasons are the Claude
130
+ * Code dormancy proof failing: nothing on disk proves the citizen is not running.
93
131
  */
94
132
  export type VisibleResumeRejectReason =
95
133
  | "target-invalid"
96
- | "target-not-pi"
134
+ | "target-backend-unsupported"
97
135
  | "acp-model-not-curated"
98
136
  | "target-locked"
99
137
  | "target-live"
138
+ | "target-receiver-absent"
139
+ | "target-receiver-corrupt"
140
+ | "target-receiver-mismatch"
141
+ | "target-receiver-unbound"
100
142
  | "target-indeterminate"
101
143
  | "target-address-conflict"
102
144
  | "launch-refused";
@@ -118,14 +160,12 @@ export type VisibleResumeLaunchOutcome =
118
160
  | { ok: true; handle: VisibleResumeLaunchHandle }
119
161
  | { ok: false; reason: string; hint: string };
120
162
 
121
- /** Fact 1: tmux created a window and was asked to start pi there. `cwd` is the REQUESTED start
122
- * directory (what tmux was told), the same kind of fact as `runtimePath` — not an observation of
123
- * where the pane landed. */
124
- export interface ResumeLaunchReceipt {
125
- targetGardenId: string;
126
- sessionFile: string;
127
- handle: VisibleResumeLaunchHandle;
128
- }
163
+ /** Fact 1: tmux created a window and was asked to start the citizen's runtime there. `cwd` is the
164
+ * REQUESTED start directory (what tmux was told), the same kind of fact as `runtimePath` — not an
165
+ * observation of where the pane landed. */
166
+ export type ResumeLaunchReceipt =
167
+ | { backend: "pi"; targetGardenId: string; sessionFile: string; handle: VisibleResumeLaunchHandle }
168
+ | { backend: "claude-code"; targetGardenId: string; nativeSessionId: string; handle: VisibleResumeLaunchHandle };
129
169
 
130
170
  /** Fact 2: the citizen's control socket came back under the SAME garden id, or it did not. This
131
171
  * is the only fact in this module that says a citizen is back. */
@@ -135,8 +175,39 @@ export interface ResumeObservationReceipt {
135
175
  waitedMs: number;
136
176
  }
137
177
 
178
+ /** Fact 2 for Claude Code: a NEW live owner in the opened pane re-armed the receiver marker for the
179
+ * same native session with a fresh binding the current record carries, or that was not seen in
180
+ * time, or the record could not be read (a store defect, reported at once with its cause — the
181
+ * window stays open). The pid is the new owner's, read from the marker and joined to the pane
182
+ * this call opened; it is evidence, not an address. */
183
+ export type ReceiverObservationReceipt =
184
+ | { kind: "receiver-armed"; ownerPid: number; waitedMs: number }
185
+ | { kind: "resume-unobserved"; waitedMs: number }
186
+ | { kind: "record-unreadable"; cause: string; waitedMs: number };
187
+
188
+ /** What the receiver marker under a garden id says, read raw and judged by the shared owner rule. */
189
+ export type ReceiverInspection =
190
+ | { kind: "absent" }
191
+ | { kind: "corrupt" }
192
+ | {
193
+ kind: "marker";
194
+ marker: {
195
+ gardenId: string;
196
+ backend: string;
197
+ nativeSessionId: string;
198
+ ownerPid: number;
199
+ ownerStartKey: string;
200
+ attachIncarnation: string | null;
201
+ /** Already parsed from the same fd; exposed for callers that must join the owner's domain
202
+ * (#147 G3 retire). The resume verdict does not read them. */
203
+ ownerKind: string;
204
+ armProvenance: string;
205
+ };
206
+ owner: MarkerOwnerVerdict;
207
+ };
208
+
138
209
  export type VisibleResumeResult =
139
- | { ok: true; launch: ResumeLaunchReceipt; observation: ResumeObservationReceipt }
210
+ | { ok: true; launch: ResumeLaunchReceipt; observation: ResumeObservationReceipt | ReceiverObservationReceipt }
140
211
  | { ok: false; reason: VisibleResumeRejectReason; detail: string };
141
212
 
142
213
  export interface VisibleResumeDeps {
@@ -145,8 +216,14 @@ export interface VisibleResumeDeps {
145
216
  resolveIdentity: (gardenId: string) => LaunchIdentity;
146
217
  inspectSocket: (gardenId: string) => Promise<TargetSocketInspection>;
147
218
  probeSocket: (socketPath: string) => Promise<SocketLiveness>;
219
+ /** The Claude Code dormancy proof and observation read this; pi never does. */
220
+ inspectReceiver: (gardenId: string) => ReceiverInspection;
148
221
  /** The ONLY way this module reaches tmux. The surface supplies it. */
149
- launch: (input: { cwd: string; runtimeArgs: readonly string[] }) => VisibleResumeLaunchOutcome;
222
+ launch: (input: {
223
+ backend: ResumeBackend;
224
+ cwd: string;
225
+ runtimeArgs: readonly string[];
226
+ }) => VisibleResumeLaunchOutcome;
150
227
  sleep: (ms: number) => Promise<void>;
151
228
  now: () => number;
152
229
  }
@@ -163,6 +240,7 @@ export function makeVisibleResumeDeps(
163
240
  resolveIdentity: resolveResumeLaunchIdentity,
164
241
  inspectSocket: (gid) => inspectTargetControlSocket(gid, CONTROL_SOCKET_DIR),
165
242
  probeSocket: (socketPath) => probeSocketLiveness(socketPath),
243
+ inspectReceiver: (gid) => inspectReceiverMarker(gid),
166
244
  launch,
167
245
  sleep: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
168
246
  now: () => Date.now(),
@@ -170,6 +248,50 @@ export function makeVisibleResumeDeps(
170
248
  };
171
249
  }
172
250
 
251
+ /**
252
+ * The receiver marker under `gardenId`, read RAW (no owner verification inside the reader — the
253
+ * path reader's `null` folds absent, corrupt and dead into one answer, and only "dead" may permit
254
+ * a resume) and judged by `classifyMarkerOwner`, the one place a dead verdict is produced. The
255
+ * bytes come from `readStoreRecordFile` — `O_NOFOLLOW` open, `fstat` kind and read on that one fd
256
+ * (#147 L2 D3) — so a symlink or a non-regular entry is corrupt and never followed, with no
257
+ * window between classifying the entry and reading it. `ENOENT` alone is absent.
258
+ */
259
+ export function inspectReceiverMarker(gardenId: string, receiversDir?: string): ReceiverInspection {
260
+ const markerPath = metaReceiverMarkerPath(gardenId, receiversDir);
261
+ let bytes: string;
262
+ try {
263
+ bytes = readStoreRecordFile(markerPath);
264
+ } catch (err) {
265
+ if ((err as { code?: unknown }).code === "ENOENT") return { kind: "absent" };
266
+ return { kind: "corrupt" };
267
+ }
268
+ let marker: ReturnType<typeof parseMetaReceiverMarker>;
269
+ try {
270
+ marker = parseMetaReceiverMarker(bytes);
271
+ } catch {
272
+ return { kind: "corrupt" };
273
+ }
274
+ if (marker === null) return { kind: "corrupt" };
275
+ const owner = classifyMarkerOwner(marker.ownerStartKey, {
276
+ currentStartKey: processStartKey(marker.ownerPid),
277
+ pidExists: probePidExistence(marker.ownerPid),
278
+ });
279
+ return {
280
+ kind: "marker",
281
+ marker: {
282
+ gardenId: marker.gardenId,
283
+ backend: marker.backend,
284
+ nativeSessionId: marker.nativeSessionId,
285
+ ownerPid: marker.ownerPid,
286
+ ownerStartKey: marker.ownerStartKey,
287
+ attachIncarnation: marker.attachIncarnation,
288
+ ownerKind: marker.ownerKind,
289
+ armProvenance: marker.armProvenance,
290
+ },
291
+ owner,
292
+ };
293
+ }
294
+
173
295
  /**
174
296
  * Reopen `targetGardenId` in a visible window.
175
297
  *
@@ -230,7 +352,7 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
230
352
  identity = deps.resolveIdentity(targetGardenId);
231
353
  } catch (err) {
232
354
  // #128: the recorded model is not an exact curated ACP id, so Pi would pick another row
233
- // by substring. Same shape as `target-not-pi`: a named refusal matched on its FIELD.
355
+ // by substring. Same shape as `target-backend-unsupported`: a named refusal matched on its FIELD.
234
356
  if (err instanceof AcpModelNotCuratedError) {
235
357
  return {
236
358
  ok: false,
@@ -243,13 +365,15 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
243
365
  ok: false,
244
366
  reason: err.reason,
245
367
  detail:
246
- `${targetGardenId} is a ${err.backend} citizen; only pi citizens stand a control socket up, ` +
247
- `so only they can be reopened under the same garden id`,
368
+ `${targetGardenId} is a ${err.backend} citizen; only pi and Claude Code citizens can be reopened ` +
369
+ `under the same garden id`,
248
370
  };
249
371
  }
250
372
  throw err;
251
373
  }
252
374
 
375
+ if (identity.backend === "claude-code") return await resumeClaudeCode(targetGardenId, identity, deps);
376
+
253
377
  // Liveness preflight, still under the lock. A live citizen is addressed with
254
378
  // `entwurf_v2 fire-and-forget`, not reopened — and refusing here is also what stops two
255
379
  // windows existing for one garden id.
@@ -287,7 +411,7 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
287
411
  provider: identity.provider,
288
412
  model: identity.model,
289
413
  });
290
- const launched = deps.launch({ cwd: identity.cwd, runtimeArgs });
414
+ const launched = deps.launch({ backend: "pi", cwd: identity.cwd, runtimeArgs });
291
415
  if (!launched.ok) {
292
416
  return { ok: false, reason: "launch-refused", detail: `${launched.reason} — ${launched.hint}` };
293
417
  }
@@ -295,7 +419,7 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
295
419
  const observation = await observeSameGidSocket(targetGardenId, mapped.socketPath, deps);
296
420
  return {
297
421
  ok: true,
298
- launch: { targetGardenId, sessionFile: identity.sessionFile, handle: launched.handle },
422
+ launch: { backend: "pi", targetGardenId, sessionFile: identity.sessionFile, handle: launched.handle },
299
423
  observation,
300
424
  };
301
425
  } finally {
@@ -303,6 +427,186 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
303
427
  }
304
428
  }
305
429
 
430
+ /**
431
+ * The Claude Code half, still under the caller's lock. Dormancy needs a DEAD owner verdict on the
432
+ * receiver marker AND the binding: the record re-read AFTER that verdict must still name the same
433
+ * session and createdAt, and carry the same non-null `attachIncarnation` the marker copied. An
434
+ * absent marker (never armed, or retired when its process switched session), an unreadable one,
435
+ * one that names another session, a live owner, an inconclusive one and an unbound one each refuse
436
+ * by name, with no window. The re-read catches every attach whose atomic record write committed
437
+ * before it; a launch or attach that begins after it is not predicted. The launch uses the
438
+ * re-read record (its cwd), never the first read. The marker and the record are never removed or
439
+ * rewritten here — they are evidence, not something this verb may clear.
440
+ */
441
+ async function resumeClaudeCode(
442
+ targetGardenId: string,
443
+ identity: ClaudeCodeLaunchIdentity,
444
+ deps: VisibleResumeDeps,
445
+ ): Promise<VisibleResumeResult> {
446
+ const before = deps.inspectReceiver(targetGardenId);
447
+ if (before.kind === "absent") {
448
+ return {
449
+ ok: false,
450
+ reason: "target-receiver-absent",
451
+ detail: `no receiver marker names ${targetGardenId}, so nothing proves its Claude Code process is gone`,
452
+ };
453
+ }
454
+ if (before.kind === "corrupt") {
455
+ return {
456
+ ok: false,
457
+ reason: "target-receiver-corrupt",
458
+ detail: `the receiver marker for ${targetGardenId} is not a readable regular marker, so its owner cannot be judged`,
459
+ };
460
+ }
461
+ const marker = before.marker;
462
+ if (
463
+ marker.gardenId !== targetGardenId ||
464
+ marker.backend !== "claude-code" ||
465
+ marker.nativeSessionId !== identity.nativeSessionId
466
+ ) {
467
+ return {
468
+ ok: false,
469
+ reason: "target-receiver-mismatch",
470
+ detail:
471
+ `the receiver marker for ${targetGardenId} names ${marker.backend}/${marker.nativeSessionId}, not the ` +
472
+ `record's claude-code/${identity.nativeSessionId}`,
473
+ };
474
+ }
475
+ if (before.owner === "live") {
476
+ return {
477
+ ok: false,
478
+ reason: "target-live",
479
+ detail: `${targetGardenId}'s Claude Code process (pid ${marker.ownerPid}) is still running — send to it with entwurf_v2 fire-and-forget instead`,
480
+ };
481
+ }
482
+ if (before.owner !== "dead") {
483
+ return {
484
+ ok: false,
485
+ reason: "target-indeterminate",
486
+ detail:
487
+ `the receiver marker's owner (pid ${marker.ownerPid}) for ${targetGardenId} could not be proven gone, ` +
488
+ `and resuming a session that is still open would put two processes on one transcript`,
489
+ };
490
+ }
491
+
492
+ // The binding, read AFTER the dead verdict. A store or record defect here throws, exactly as
493
+ // the first read does: no window exists yet.
494
+ const current = deps.resolveIdentity(targetGardenId);
495
+ if (
496
+ current.backend !== "claude-code" ||
497
+ current.nativeSessionId !== identity.nativeSessionId ||
498
+ current.createdAt !== identity.createdAt
499
+ ) {
500
+ return {
501
+ ok: false,
502
+ reason: "target-receiver-unbound",
503
+ detail: `the record under ${targetGardenId} changed between the two reads, so the dead marker cannot be bound to it`,
504
+ };
505
+ }
506
+ if (current.attachIncarnation === null || marker.attachIncarnation === null) {
507
+ return {
508
+ ok: false,
509
+ reason: "target-receiver-unbound",
510
+ detail:
511
+ `no arm binding was observed for ${targetGardenId} (record ${current.attachIncarnation === null ? "has none" : "has one"}, ` +
512
+ `marker ${marker.attachIncarnation === null ? "has none" : "has one"}), so the dead owner is not proven to be the last attach`,
513
+ };
514
+ }
515
+ if (current.attachIncarnation !== marker.attachIncarnation) {
516
+ return {
517
+ ok: false,
518
+ reason: "target-receiver-unbound",
519
+ detail:
520
+ `the record of ${targetGardenId} carries a later arm binding than its receiver marker — an attach committed ` +
521
+ `after the dead owner's, and its process is not proven gone`,
522
+ };
523
+ }
524
+
525
+ const launched = deps.launch({
526
+ backend: "claude-code",
527
+ cwd: current.cwd,
528
+ runtimeArgs: ["--resume", current.nativeSessionId],
529
+ });
530
+ if (!launched.ok) {
531
+ return { ok: false, reason: "launch-refused", detail: `${launched.reason} — ${launched.hint}` };
532
+ }
533
+ const observation = await observeReceiverReattached(
534
+ targetGardenId,
535
+ current,
536
+ { ownerPid: marker.ownerPid, ownerStartKey: marker.ownerStartKey, attachIncarnation: current.attachIncarnation },
537
+ launched.handle.panePid,
538
+ deps,
539
+ );
540
+ return {
541
+ ok: true,
542
+ launch: {
543
+ backend: "claude-code",
544
+ targetGardenId,
545
+ nativeSessionId: current.nativeSessionId,
546
+ handle: launched.handle,
547
+ },
548
+ observation,
549
+ };
550
+ }
551
+
552
+ /**
553
+ * ONE bounded operation, the Claude Code twin of `observeSameGidSocket`: wait for a NEW live owner
554
+ * — in the pane this call opened (`panePid`), a join, not an address — to re-arm this garden id's
555
+ * receiver marker for the same native session with a fresh binding the current record carries
556
+ * (same createdAt). The dead marker read again is never success: the owner must differ from the one
557
+ * the dormancy proof judged dead, and the binding from the one it bound. A marker armed by some
558
+ * other process is simply not-yet, and ends as `resume-unobserved`. A record or store defect is not
559
+ * a startup state: it stops the wait at once as `record-unreadable`, with its cause.
560
+ */
561
+ export async function observeReceiverReattached(
562
+ targetGardenId: string,
563
+ identity: ClaudeCodeLaunchIdentity,
564
+ previous: { ownerPid: number; ownerStartKey: string; attachIncarnation: string },
565
+ panePid: string,
566
+ deps: Pick<VisibleResumeDeps, "inspectReceiver" | "resolveIdentity" | "sleep" | "now">,
567
+ timeoutMs: number = RESUME_OBSERVE_TIMEOUT_MS,
568
+ intervalMs: number = RESUME_OBSERVE_INTERVAL_MS,
569
+ ): Promise<ReceiverObservationReceipt> {
570
+ const started = deps.now();
571
+ for (;;) {
572
+ const seen = deps.inspectReceiver(targetGardenId);
573
+ if (
574
+ seen.kind === "marker" &&
575
+ seen.owner === "live" &&
576
+ seen.marker.gardenId === targetGardenId &&
577
+ seen.marker.backend === "claude-code" &&
578
+ seen.marker.nativeSessionId === identity.nativeSessionId &&
579
+ String(seen.marker.ownerPid) === panePid &&
580
+ (seen.marker.ownerPid !== previous.ownerPid || seen.marker.ownerStartKey !== previous.ownerStartKey) &&
581
+ seen.marker.attachIncarnation !== null &&
582
+ seen.marker.attachIncarnation !== previous.attachIncarnation
583
+ ) {
584
+ let current: LaunchIdentity;
585
+ try {
586
+ current = deps.resolveIdentity(targetGardenId);
587
+ } catch (err) {
588
+ return {
589
+ kind: "record-unreadable",
590
+ cause: err instanceof Error ? err.message : String(err),
591
+ waitedMs: deps.now() - started,
592
+ };
593
+ }
594
+ if (
595
+ current.backend === "claude-code" &&
596
+ current.nativeSessionId === identity.nativeSessionId &&
597
+ current.createdAt === identity.createdAt &&
598
+ current.attachIncarnation === seen.marker.attachIncarnation
599
+ ) {
600
+ return { kind: "receiver-armed", ownerPid: seen.marker.ownerPid, waitedMs: deps.now() - started };
601
+ }
602
+ }
603
+ if (deps.now() - started >= timeoutMs) {
604
+ return { kind: "resume-unobserved", waitedMs: deps.now() - started };
605
+ }
606
+ await deps.sleep(intervalMs);
607
+ }
608
+ }
609
+
306
610
  /**
307
611
  * ONE bounded operation: wait for the SAME garden id's control socket to answer. Alive is the
308
612
  * only early exit — an inspection that comes back absent, corrupt or inconclusive during startup
@@ -331,11 +635,20 @@ export async function observeSameGidSocket(
331
635
 
332
636
  const REJECT_HINT: Record<VisibleResumeRejectReason, string> = {
333
637
  "target-invalid": "no citizen has that id; discover targets with entwurf_peers",
334
- "target-not-pi": "that citizen's backend has no same-id resume — reach it with entwurf_v2, or open a fresh sibling",
638
+ "target-backend-unsupported":
639
+ "that citizen's backend has no same-id resume (only pi and Claude Code do) — reach it with entwurf_v2, or open a fresh sibling",
335
640
  "acp-model-not-curated": ACP_MODEL_NOT_CURATED_HINT,
336
641
  "target-locked": "another resume or dispatch holds this citizen's lock right now",
337
642
  "target-live": "that citizen is already running — this verb only reopens dormant ones",
338
- "target-indeterminate": "the socket probe could not prove the citizen is dormant, so nothing was started",
643
+ "target-indeterminate":
644
+ "the citizen could not be proven dormant (an inconclusive socket probe or receiver owner), so nothing was started",
645
+ "target-receiver-absent":
646
+ "a Claude Code citizen is proven dormant only by its receiver marker, and it has none — if its window is closed, reopen it yourself",
647
+ "target-receiver-corrupt": "the citizen's receiver marker cannot be read as a marker, so its owner cannot be judged",
648
+ "target-receiver-mismatch":
649
+ "the receiver marker under this garden id names another backend or native session than the record — look at the store before anything reopens",
650
+ "target-receiver-unbound":
651
+ "the dead receiver owner is not bound to the record's last arm (none observed, a later attach, or the record moved), so a Claude Code process may still hold the session — if its window is closed, reopen it yourself",
339
652
  "target-address-conflict": "that citizen's control-socket path is corrupt",
340
653
  "launch-refused": "tmux was never asked to open a window",
341
654
  };
@@ -349,7 +662,10 @@ const REJECT_HINT: Record<VisibleResumeRejectReason, string> = {
349
662
  * who sees "window opened" and "socket alive" as one sentence has been told the resume worked;
350
663
  * a reader who sees them as two facts can tell which one is missing when only one is there.
351
664
  */
352
- export function renderVisibleResume(result: VisibleResumeResult): { text: string; isError: boolean } {
665
+ export function renderVisibleResume(
666
+ result: VisibleResumeResult,
667
+ attribution: { actor: string | null } = { actor: null },
668
+ ): { text: string; isError: boolean } {
353
669
  if (!result.ok) {
354
670
  return {
355
671
  text:
@@ -359,23 +675,61 @@ export function renderVisibleResume(result: VisibleResumeResult): { text: string
359
675
  };
360
676
  }
361
677
  const h = result.launch.handle;
362
- const observed = result.observation.kind === "socket-alive";
678
+ // Who asked is recorded, and it is not authority: the verb decides from the target's record and
679
+ // live rail facts alone, whoever the caller is.
680
+ const actor =
681
+ attribution.actor === null
682
+ ? ` actor: unattributed (the caller has no record-backed garden identity)\n`
683
+ : ` actor: ${attribution.actor} (record-backed caller; attribution, not authority)\n`;
684
+ const window =
685
+ ` runtime: ${h.runtimePath} (requested start cwd ${h.cwd})\n` +
686
+ ` window: ${h.windowId} (index ${h.windowIndex}) in session ${h.sessionId}\n` +
687
+ ` pane: ${h.paneId} pid ${h.panePid}\n`;
688
+ if (result.launch.backend === "claude-code") {
689
+ const observation = result.observation as ReceiverObservationReceipt;
690
+ return {
691
+ text:
692
+ `[entwurf resume call ↻]\n` +
693
+ ` target: ${result.launch.targetGardenId}\n` +
694
+ ` backend: claude-code\n` +
695
+ ` session: ${result.launch.nativeSessionId}\n` +
696
+ window +
697
+ actor +
698
+ `\n` +
699
+ `LAUNCH receipt: tmux created that window and was asked to start claude --resume on the session above.\n` +
700
+ (observation.kind === "receiver-armed"
701
+ ? `OBSERVATION receipt: receiver-armed — a NEW live Claude Code process in the window this call opened ` +
702
+ `(pid ${observation.ownerPid}) re-armed the receiver marker of ${result.launch.targetGardenId} for the ` +
703
+ `same session after ${observation.waitedMs}ms, with a fresh arm binding the record also carries ` +
704
+ `(same createdAt). entwurf_v2 fire-and-forget now reaches its mailbox. This call submitted no ` +
705
+ `prompt; the runtime's own activity after resume (e.g. a doorbell on unread mail) is unmeasured.`
706
+ : observation.kind === "record-unreadable"
707
+ ? `OBSERVATION receipt: record-unreadable — the record of ${result.launch.targetGardenId} could not be ` +
708
+ `read after ${observation.waitedMs}ms: ${observation.cause}. The window is open and visible; the ` +
709
+ `lock was released, nothing was retried, and nothing was killed.`
710
+ : `OBSERVATION receipt: resume-unobserved — no new live receiver marker from the window this call ` +
711
+ `opened, bound to the record, appeared within ${observation.waitedMs}ms. The window is open and ` +
712
+ `visible; read it directly. The lock was released, nothing was retried, and nothing was killed.`),
713
+ isError: false,
714
+ };
715
+ }
716
+ const observation = result.observation as ResumeObservationReceipt;
717
+ const observed = observation.kind === "socket-alive";
363
718
  return {
364
719
  text:
365
720
  `[entwurf resume call ↻]\n` +
366
721
  ` target: ${result.launch.targetGardenId}\n` +
367
722
  ` session: ${result.launch.sessionFile}\n` +
368
- ` runtime: ${h.runtimePath} (requested start cwd ${h.cwd})\n` +
369
- ` window: ${h.windowId} (index ${h.windowIndex}) in session ${h.sessionId}\n` +
370
- ` pane: ${h.paneId} pid ${h.panePid}\n` +
723
+ window +
724
+ actor +
371
725
  `\n` +
372
726
  `LAUNCH receipt: tmux created that window and was asked to start pi on the transcript above.\n` +
373
727
  (observed
374
728
  ? `OBSERVATION receipt: the control socket for ${result.launch.targetGardenId} answered after ` +
375
- `${result.observation.waitedMs}ms — that citizen is addressable again, and entwurf_v2 fire-and-forget ` +
729
+ `${observation.waitedMs}ms — that citizen is addressable again, and entwurf_v2 fire-and-forget ` +
376
730
  `now reaches it. No turn was run by this call.`
377
731
  : `OBSERVATION receipt: resume-unobserved — the control socket did not answer within ` +
378
- `${result.observation.waitedMs}ms. The window is open and visible; read it directly. The lock was ` +
732
+ `${observation.waitedMs}ms. The window is open and visible; read it directly. The lock was ` +
379
733
  `released, nothing was retried, and nothing was killed.`),
380
734
  isError: false,
381
735
  };
@@ -526,6 +526,21 @@ export type FreshCallInputRejectReason =
526
526
  | "task-empty"
527
527
  | "task-too-long";
528
528
 
529
+ /** #76: the provider a Pi-dialect model string NAMES, refused at the dispatch root before either
530
+ * rail is asked anything (`fresh-call-dispatch.ts` decides; the words live here so both rails, one
531
+ * of which admits no other shared module, answer with the same sentence). */
532
+ export type FreshCallProviderRejectReason = "model-openrouter-forbidden" | "pi-model-not-provider-qualified";
533
+
534
+ /** No model list on purpose: a hand-kept list of "good" ids would be a second catalog that rots
535
+ * on the next model version. The repair is to name a provider, and both sentences say what the
536
+ * refusal does NOT prove. */
537
+ export const FRESH_CALL_PROVIDER_HINT: Record<FreshCallProviderRejectReason, string> = {
538
+ "model-openrouter-forbidden":
539
+ "OpenRouter is not a sibling rail — name a subscription or direct provider instead. This refuses the provider name written in the request (in any letter case); it does not inspect which endpoint a differently named provider resolves to",
540
+ "pi-model-not-provider-qualified":
541
+ "a pi model is `<provider>/<model id>` — a bare alias resolves to whichever row Pi's catalog prefers, which is not a provider you chose. `entwurf_peers` shows the model each running citizen recorded; that is a record fact, not proof of the endpoint that bills it",
542
+ };
543
+
529
544
  export interface FreshCallInputs {
530
545
  readonly callerGardenId: string;
531
546
  readonly model: string;