@junghanacs/entwurf 0.12.8 → 0.12.9

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 (52) hide show
  1. package/AGENTS.md +4 -4
  2. package/CHANGELOG.md +20 -1
  3. package/DELIVERY.md +1 -1
  4. package/README.md +122 -10
  5. package/demo/README.md +2 -2
  6. package/docs/setup-clean-host.md +14 -3
  7. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +9 -15
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +12 -12
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +1 -1
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +8 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +15 -4
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +483 -34
  14. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +8 -3
  15. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +14 -2
  16. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +155 -29
  17. package/mcp/entwurf-bridge/src/index.ts +10 -12
  18. package/package.json +7 -7
  19. package/pi-extensions/entwurf-control.ts +12 -12
  20. package/pi-extensions/lib/acp/models.ts +12 -12
  21. package/pi-extensions/lib/entwurf-fact-provider.ts +9 -2
  22. package/pi-extensions/lib/entwurf-v2-production.ts +8 -2
  23. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +8 -2
  24. package/pi-extensions/lib/meta-sender-identity.ts +15 -5
  25. package/pi-extensions/lib/meta-session.ts +526 -38
  26. package/pi-extensions/meta-bridge-hook.ts +8 -2
  27. package/run.sh +30 -26
  28. package/scripts/agy-imprint.ts +15 -1
  29. package/scripts/check-acp-session-reuse.ts +1 -1
  30. package/scripts/check-acp-session-store.ts +3 -3
  31. package/scripts/check-agy-sender-identity.ts +83 -0
  32. package/scripts/check-entwurf-control-rpc.ts +2 -2
  33. package/scripts/check-entwurf-fact-provider.ts +9 -1
  34. package/scripts/check-entwurf-facts.ts +1 -1
  35. package/scripts/check-entwurf-mailbox-guard.ts +6 -2
  36. package/scripts/check-entwurf-resume-args.ts +6 -6
  37. package/scripts/check-entwurf-session-identity.ts +7 -6
  38. package/scripts/check-entwurf-v2-production.ts +4 -2
  39. package/scripts/check-entwurf-v2-spawn-production.ts +2 -2
  40. package/scripts/check-fresh-cut-gate.sh +305 -4
  41. package/scripts/check-meta-identity-consumers.ts +501 -1
  42. package/scripts/check-meta-listing.ts +91 -9
  43. package/scripts/check-meta-receiver-marker.ts +54 -0
  44. package/scripts/check-model-lock.ts +1 -1
  45. package/scripts/meta-bridge-fresh-cut.ts +164 -28
  46. package/scripts/pi_settings_io.py +65 -0
  47. package/scripts/register-pi-package.py +183 -37
  48. package/scripts/register-pi-provider.py +68 -10
  49. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  50. package/scripts/smoke-meta-install-state.sh +1 -1
  51. package/scripts/smoke-pi-attach.ts +7 -2
  52. package/scripts/smoke-user-scope-citizen.sh +177 -0
@@ -45,7 +45,7 @@
45
45
  */
46
46
  import * as fs from "node:fs";
47
47
  import * as path from "node:path";
48
- import { defaultMetaMailboxDir, defaultMetaSessionsDir, upsertMetaSession, writeMetaReceiverMarker, writeMetaSenderMarker, } from "./lib/meta-session.js";
48
+ import { defaultMetaMailboxDir, defaultMetaSessionsDir, isPlausibleOwnerPid, upsertMetaSession, writeMetaReceiverMarker, writeMetaSenderMarker, } from "./lib/meta-session.js";
49
49
  function logLine(level, message) {
50
50
  try {
51
51
  // dirname(meta-sessions) == the pi agent dir — no extra resolver export needed.
@@ -117,7 +117,12 @@ const META_HOOK_LAUNCH_TOKEN = "hook-launch/v1";
117
117
  * no token means we do not know what our parent is, so we claim nothing.
118
118
  * 2. A PLAUSIBLE LIVE PARENT. A reparented orphan (ppid 0/1) is not an owner, and
119
119
  * minting a marker for init would be exactly the "blind ancestor" false-positive
120
- * the old ancestry walk existed to prevent.
120
+ * the old ancestry walk existed to prevent. The rule is `isPlausibleOwnerPid`,
121
+ * shared with the other writer and with every reader: this side already refused
122
+ * what all three readers accepted, and one host's leftover `ownerPid: 1` marker
123
+ * then blocked its fresh-cut until the file was deleted by hand (#53 A). A
124
+ * predicate only one layer knows is how that drift happened, so it is no longer
125
+ * written out by hand here.
121
126
  *
122
127
  * Failing closed costs only reply-addressability, and the doctor sees the ERROR. The
123
128
  * opposite — a marker keyed to a transient wrapper — is a lie a sender acts on.
@@ -126,7 +131,7 @@ function resolveMetaHookOwnerPid() {
126
131
  if (process.env[META_HOOK_LAUNCH_ENV] !== META_HOOK_LAUNCH_TOKEN)
127
132
  return null;
128
133
  const ownerPid = process.ppid;
129
- if (!Number.isSafeInteger(ownerPid) || ownerPid <= 1)
134
+ if (!isPlausibleOwnerPid(ownerPid))
130
135
  return null;
131
136
  return ownerPid;
132
137
  }
@@ -15,7 +15,7 @@
15
15
  import * as fs from "node:fs";
16
16
  import * as os from "node:os";
17
17
  import * as path from "node:path";
18
- import { parentPid, processStartKey, upsertMetaSession, writeMetaSenderMarker, } from "../pi-extensions/lib/meta-session.js";
18
+ import { isPlausibleOwnerPid, parentPid, processStartKey, upsertMetaSession, writeMetaSenderMarker, } from "../pi-extensions/lib/meta-session.js";
19
19
  const NEUTRAL_RESPONSE = '{"injectSteps":[]}';
20
20
  function logPath() {
21
21
  const root = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
@@ -141,8 +141,16 @@ function imprint(raw) {
141
141
  // Written only after the upsert above: the record store is the identity authority, and a
142
142
  // marker pointing at a garden-id with no record would be a window of un-backed identity.
143
143
  // A failed marker costs reply-addressability, never the session — log and move on.
144
+ //
145
+ // The owner rule is `isPlausibleOwnerPid`, the SAME predicate the Claude hook
146
+ // and every reader hold. This writer used to ask `> 0` on its own, so THIS hook
147
+ // could mint `meta-senders/antigravity/1.json` through the very reparenting that
148
+ // stranded a host's fresh-cut (#53 A): agy exits, the imprint is reparented to
149
+ // init, and `process.ppid` reads 1. Refusing is fail-closed — it costs reply
150
+ // addressability for that turn and is logged; honoring it would cost the operator
151
+ // their generation cut, with no in-band way to get it back.
144
152
  const ownerPid = process.ppid;
145
- if (typeof ownerPid === "number" && ownerPid > 0) {
153
+ if (isPlausibleOwnerPid(ownerPid)) {
146
154
  try {
147
155
  writeMetaSenderMarker({
148
156
  backend: "antigravity",
@@ -157,6 +165,10 @@ function imprint(raw) {
157
165
  logLine(`sender-marker-failed pid=${ownerPid} gardenId=${result.record.gardenId} ${err instanceof Error ? err.message : String(err)}`);
158
166
  }
159
167
  }
168
+ else {
169
+ logLine(`sender-marker-refused pid=${ownerPid} gardenId=${result.record.gardenId} implausible owner pid ` +
170
+ "(<= 1: this imprint was reparented, so its parent is not the agy process that owns the turn)");
171
+ }
160
172
  }
161
173
  catch (err) {
162
174
  logLine(`upsert-failed conversationId=${conversationId} ${err instanceof Error ? err.stack || err.message : String(err)}`);
@@ -24,7 +24,8 @@
24
24
  * - meta-sessions/ (identity records) → ARCHIVED (`<dir>.archive-<ts>`)
25
25
  * - meta-mailbox/ (messages+receipts) → ARCHIVED (`<dir>.archive-<ts>`)
26
26
  * - meta-senders/, meta-receivers/ markers → CLEARED (disposable process
27
- * state; the quiesce gate has already proven every owner pid is gone)
27
+ * state; the quiesce gate has already proven every owner pid is gone — or,
28
+ * for a refuted marker, that no such owner could ever have existed)
28
29
  * - control sockets (*.sock) → CLEARED if probed dead (the GC
29
30
  * rule: only a demonstrably dead socket may be unlinked)
30
31
  * - native transcripts, andenken/embedding axes, install ownership state
@@ -50,6 +51,28 @@
50
51
  * Only a marker whose named owner demonstrably no longer holds that pid is dead,
51
52
  * and only a dead marker/socket is cleared.
52
53
  *
54
+ * ONE class is neither live, uncertain nor dead: a marker whose `ownerPid` cannot
55
+ * own anything (`<= 1` — init, or a non-pid number). It is REFUTED BY CONSTRUCTION
56
+ * rather than proven dead: after #53 A no writer in this tree can mint one
57
+ * (`isPlausibleOwnerPid` at both writers and at the write boundary), so on a current
58
+ * install it is LEGACY or CORRUPT residue — a pre-fix writer whose parent had been
59
+ * reparented to init (the retired shell-form Claude hook; the agy imprint, which
60
+ * asked only `> 0` until this cut), or a foreign/damaged marker, which is the only
61
+ * way a non-integer pid can appear at all. The one file actually observed was a
62
+ * shell-form Claude hook reparented to init.
63
+ *
64
+ * Reading it as an owner is not merely wrong, it is a trap: init runs for the whole
65
+ * boot and its start-key does not change while it does, so `classifyMarkerOwner`
66
+ * answers `live` and THE ACTION THIS REFUSAL PRESCRIBES CANNOT CHANGE THAT — the
67
+ * operator quiesces every session, exactly as told, and the cut refuses again.
68
+ * (Deleting the marker removes the claim rather than refuting the verdict, and a
69
+ * reboot recomputes the key with no contract either way — neither is a remedy this
70
+ * cut may lean on.) Meanwhile 0.12.8 names this very cut as the one repair for a
71
+ * pre-v3 store, so the host was stuck until the file was deleted by hand (#53 A,
72
+ * measured 2026-07-25). Such a marker is therefore clearable residue, swept with the
73
+ * dead ones but COUNTED AND REPORTED APART from them, because invalidity and death
74
+ * are different findings.
75
+ *
53
76
  * THE MARKER WALK IS NOT THE WHOLE WORLD. A native-push (agy) citizen is registered
54
77
  * with NO marker of any kind and is dispatched straight off its record, so marker
55
78
  * absence is its NORMAL deliverable state — a socket+marker scan would call such a host
@@ -69,13 +92,22 @@
69
92
  * renames reports exactly what already moved (`archived so far:`) and a re-run
70
93
  * finishes the cut with its own stamp.
71
94
  * Running on a clean/empty host just opens a fresh generation and says so.
95
+ *
96
+ * EXIT CONTRACT (#54) — `FRESH_CUT_EXIT`, defined beside the verb's own name in
97
+ * meta-session.ts because a runbook and a gate must read the same numbers:
98
+ * `0` complete · `1` nothing moved · `2` usage · `3` cut transition incomplete ·
99
+ * `4` cut complete but marker/socket cleanup incomplete. The three failure states
100
+ * were all hidden behind one `1` before, which is why the documented `fresh-cut && setup` chain stopped
101
+ * identically on a refusal that changed nothing and on a cut that had already
102
+ * unblocked the install. Only `0` is success — #54 asked for the states to become
103
+ * DISTINGUISHABLE, not for a failed sweep to become a pass.
72
104
  */
73
105
  import * as fs from "node:fs";
74
106
  import * as os from "node:os";
75
107
  import * as path from "node:path";
76
108
  import { CONTROL_SOCKET_SUFFIX, defaultControlSocketDir } from "../pi-extensions/lib/control-socket-path.js";
77
109
  import { nativePushSupported } from "../pi-extensions/lib/entwurf-v2-contract.js";
78
- import { classifyMarkerOwner, defaultMetaMailboxDir, defaultMetaReceiversDir, defaultMetaSendersDir, defaultMetaSessionsDir, parseMetaIdentity, probePidExistence, processStartKey, } from "../pi-extensions/lib/meta-session.js";
110
+ import { classifyMarkerOwner, defaultMetaMailboxDir, defaultMetaReceiversDir, defaultMetaSendersDir, defaultMetaSessionsDir, FRESH_CUT_EXIT, isPlausibleOwnerPid, midCutExit, parseMetaIdentity, probePidExistence, processStartKey, } from "../pi-extensions/lib/meta-session.js";
79
111
  import { resolveNativePushAdapter } from "../pi-extensions/lib/native-push/adapter.js";
80
112
  import { probeSocketLiveness } from "../pi-extensions/lib/socket-probe.js";
81
113
  function usage(code) {
@@ -91,6 +123,20 @@ function usage(code) {
91
123
  "store = ENTWURF_META_SESSIONS_DIR || <PI_CODING_AGENT_DIR|~/.pi/agent>/meta-sessions",
92
124
  "mailbox = ENTWURF_META_MAILBOX_DIR || <PI_CODING_AGENT_DIR|~/.pi/agent>/meta-mailbox",
93
125
  "sockets = ENTWURF_DIR || ~/.pi/entwurf-control",
126
+ "",
127
+ "EXIT CONTRACT (#54) — what already moved, not how bad it was:",
128
+ ` ${FRESH_CUT_EXIT.COMPLETE} cut complete: generation archived (or none existed), fresh v3 store open,`,
129
+ " residue cleared. `&& setup` is correct here.",
130
+ ` ${FRESH_CUT_EXIT.NO_MOVE} NOTHING MOVED: a live/unprovable surface, an occupied archive destination,`,
131
+ " an unreadable surface, or a first rename that failed. The host is unchanged —",
132
+ " fix the named cause and re-run. Do NOT run setup: the store it refused is still there.",
133
+ ` ${FRESH_CUT_EXIT.USAGE} usage.`,
134
+ ` ${FRESH_CUT_EXIT.HALF_CUT} CUT TRANSITION INCOMPLETE: at least one archive move happened,`,
135
+ " but the fresh generation is not confirmed open. Inspect, or re-run to finish under a new stamp.",
136
+ ` ${FRESH_CUT_EXIT.CLEANUP_INCOMPLETE} CUT COMPLETE, CLEANUP INCOMPLETE: the generation IS archived and the fresh`,
137
+ " generation IS open — install and citizen birth are unblocked, so setup may run —",
138
+ " but marker/socket residue survived. Prefer fixing it and re-running BEFORE setup; after new citizen birth,",
139
+ " remove the named residue manually instead of archiving the new generation with another cut.",
94
140
  ].join("\n"));
95
141
  process.exit(code);
96
142
  }
@@ -163,16 +209,17 @@ function classifySurfaceDir(dir) {
163
209
  function inspectMarkers(dir, label) {
164
210
  const violations = [];
165
211
  const deadFiles = [];
212
+ const refutedFiles = [];
166
213
  const markerFiles = [];
167
214
  const uncertain = (shown, why) => {
168
215
  violations.push({ surface: label, detail: `${shown} — ${why}`, kind: "uncertain" });
169
216
  };
170
217
  const dirState = classifySurfaceDir(dir);
171
218
  if (dirState.state === "absent")
172
- return { violations, deadFiles };
219
+ return { violations, deadFiles, refutedFiles };
173
220
  if (dirState.state !== "directory") {
174
221
  uncertain(dir, `surface could not be inspected (${dirState.detail}): an unreadable surface is not a quiesced one`);
175
- return { violations, deadFiles };
222
+ return { violations, deadFiles, refutedFiles };
176
223
  }
177
224
  // depth 0 = the marker root, depth 1 = a backend subdir. Nothing deeper is part
178
225
  // of any marker layout, so it is inspected by hand rather than swept.
@@ -213,12 +260,28 @@ function inspectMarkers(dir, label) {
213
260
  uncertain(shown, `unreadable marker (${err instanceof Error ? err.message : String(err)}): owner unprovable`);
214
261
  continue;
215
262
  }
216
- const ownerPid = typeof raw.ownerPid === "number" && Number.isInteger(raw.ownerPid) && raw.ownerPid > 0 ? raw.ownerPid : null;
217
- const ownerStartKey = typeof raw.ownerStartKey === "string" ? raw.ownerStartKey : "";
218
- if (ownerPid === null) {
219
- uncertain(shown, "no positive-integer `ownerPid`: owner unprovable");
263
+ // Two different failures, deliberately kept apart. NO numeric `ownerPid` means the
264
+ // marker never NAMED an owner unprovable, so it refuses the cut like every other
265
+ // unreadable marker. A numeric one that cannot own (`<= 1`, non-integer, unsafe)
266
+ // IS a claim, and it is refuted BY CONSTRUCTION: no writer in this tree can mint it
267
+ // any more, and on the axis this repo certifies "this session is owned by init" is
268
+ // false on its face (a container harness running AS pid 1 is a real but UNSUPPORTED
269
+ // shape — see isPlausibleOwnerPid; it fails closed at the writers). That is a
270
+ // proof of INVALIDITY — strictly stronger than the proof of death this loop already
271
+ // acts on — so it is clearable residue, never `live` and never `uncertain`. Without
272
+ // this row a single reparented-owner marker left quiescence unprovable by the ONE
273
+ // action the refusal prescribes, and the repair 0.12.8 names could not run until
274
+ // the file was deleted by hand (#53 A, measured 2026-07-25).
275
+ if (typeof raw.ownerPid !== "number") {
276
+ uncertain(shown, "no numeric `ownerPid`: owner unprovable");
277
+ continue;
278
+ }
279
+ if (!isPlausibleOwnerPid(raw.ownerPid)) {
280
+ refutedFiles.push(file);
220
281
  continue;
221
282
  }
283
+ const ownerPid = raw.ownerPid;
284
+ const ownerStartKey = typeof raw.ownerStartKey === "string" ? raw.ownerStartKey : "";
222
285
  if (ownerStartKey === "") {
223
286
  uncertain(shown, "no `ownerStartKey`: a bare pid cannot distinguish the owner from a reused pid");
224
287
  continue;
@@ -242,7 +305,7 @@ function inspectMarkers(dir, label) {
242
305
  deadFiles.push(file);
243
306
  }
244
307
  }
245
- return { violations, deadFiles };
308
+ return { violations, deadFiles, refutedFiles };
246
309
  }
247
310
  /**
248
311
  * The native-push surface — the ONE live citizen kind that leaves no marker behind.
@@ -266,7 +329,8 @@ function inspectMarkers(dir, label) {
266
329
  * record this walk skips, and the claim is deliberately narrow: NOT "that native session
267
330
  * has exited", only "these bytes are not an address authority in the live runtime, so
268
331
  * they front no current-generation garden surface". Every path that ADDRESSES a citizen
269
- * goes through the live schema — `readMetaIdentityByGardenId`, the v2 `resolveTarget`,
332
+ * goes through the live schema — `readAddressableMetaIdentity` (v2 `resolveTarget` and
333
+ * the pi resume), `readMetaIdentityByGardenId` (the relay reads),
270
334
  * the sender-marker trust — and each THROWS on a record this parser refuses, so nothing
271
335
  * can dispatch to it. (`entwurf_peers` still LISTS it as a diagnostic; a facts surface
272
336
  * reporting what it could not read is not an address.) A record we CAN read is the
@@ -352,12 +416,41 @@ async function inspectNativePushCitizens(storeDir) {
352
416
  }
353
417
  return violations;
354
418
  }
419
+ /**
420
+ * Remove the files a completed cut is contracted to clear, and report what it could
421
+ * not. ENOENT is the ONLY tolerated failure — the file raced away, which is the goal
422
+ * state. Every other errno is a real refusal to delete (EACCES, EPERM under a
423
+ * sticky-bit parent, EROFS, an immutable attribute, an LSM denial), and a bare
424
+ * `catch {}` here would launder all of them into "raced away" while the command's own
425
+ * output still said `cleared:`. That is the Crash-Don't-Warn shape this repo removes,
426
+ * not a shortcut it tolerates — the caller decides what to do with the failures,
427
+ * because by this point the archive has already moved and throwing would cost the
428
+ * operator the story of what DID happen.
429
+ */
430
+ function clearFiles(files) {
431
+ const failures = [];
432
+ let cleared = 0;
433
+ for (const file of files) {
434
+ try {
435
+ fs.unlinkSync(file);
436
+ cleared += 1;
437
+ }
438
+ catch (err) {
439
+ if (err.code === "ENOENT")
440
+ continue; // raced away — already gone is the goal state
441
+ failures.push({ file, reason: err instanceof Error ? err.message : String(err) });
442
+ }
443
+ }
444
+ return { cleared, failures };
445
+ }
446
+ /** Surfaces this process has already renamed into an archive — see the rename loop. */
447
+ let archivedSoFar = 0;
355
448
  async function main() {
356
449
  const args = process.argv.slice(2);
357
450
  if (args.includes("-h") || args.includes("--help"))
358
- usage(0);
451
+ usage(FRESH_CUT_EXIT.COMPLETE);
359
452
  if (args.length > 0)
360
- usage(2);
453
+ usage(FRESH_CUT_EXIT.USAGE);
361
454
  const storeDir = defaultMetaSessionsDir();
362
455
  const mailboxDir = defaultMetaMailboxDir();
363
456
  const sendersDir = defaultMetaSendersDir();
@@ -438,7 +531,7 @@ async function main() {
438
531
  "address archived out from under it, and an unprovable owner is not a dead one. " +
439
532
  "Quiesce those sessions (close them / let them exit), inspect anything listed UNCERTAIN, " +
440
533
  "then re-run the same command. Nothing was moved.");
441
- return 1;
534
+ return FRESH_CUT_EXIT.NO_MOVE;
442
535
  }
443
536
  // ── the cut: plan the moves, preflight the whole plan, then rename ───────
444
537
  const ts = stamp();
@@ -484,9 +577,15 @@ async function main() {
484
577
  console.error(`REFUSE: ${collisions.length} archive destination(s) above already exist — this generation's stamp is ` +
485
578
  "taken (a cut in the same second, or leftovers from an interrupted one). Nothing was moved: " +
486
579
  "wait a second and re-run, or move those directories aside first.");
487
- return 1;
580
+ return FRESH_CUT_EXIT.NO_MOVE;
488
581
  }
489
582
  const archived = [];
583
+ // Mirrored to module scope for the ONE reader that cannot see this array: the
584
+ // top-level rejection handler. Everything from here on can still throw — the
585
+ // `mkdirSync` that opens the fresh generation, most obviously — and a handler that
586
+ // answered a flat `1` there would tell a runbook "nothing moved" about a host whose
587
+ // generation is already in an archive. A single-shot CLI may hold one fact in module
588
+ // scope when the alternative is an exit status that lies (#54).
490
589
  for (const { src, dest } of plan) {
491
590
  try {
492
591
  fs.renameSync(src, dest);
@@ -510,20 +609,20 @@ async function main() {
510
609
  "not. Fix the cause and re-run the same command — the re-run archives the remainder under its " +
511
610
  "own stamp — or inspect by hand.");
512
611
  }
513
- return 1;
612
+ // The prose above already distinguished these two; the STATUS did not, and a
613
+ // runbook reads the status (#54). `midCutExit` is the SSOT so the words and the
614
+ // number cannot drift apart.
615
+ return midCutExit(archived.length);
514
616
  }
515
617
  archived.push(dest);
618
+ archivedSoFar = archived.length;
516
619
  }
517
- let cleared = 0;
518
- for (const file of [...senders.deadFiles, ...receivers.deadFiles, ...deadSockets]) {
519
- try {
520
- fs.unlinkSync(file);
521
- cleared += 1;
522
- }
523
- catch {
524
- // raced away — already gone is the goal state
525
- }
526
- }
620
+ // Both sweeps go through ONE remover so they cannot drift to different meanings of
621
+ // "could not remove". Counted APART, because a dead marker is an owner we PROVED
622
+ // left while a refuted one never named an owner that could exist, and folding the
623
+ // two would dress a proof of invalidity up as a proof of death.
624
+ const dead = clearFiles([...senders.deadFiles, ...receivers.deadFiles, ...deadSockets]);
625
+ const refuted = clearFiles([...senders.refutedFiles, ...receivers.refutedFiles]);
527
626
  fs.mkdirSync(storeDir, { recursive: true });
528
627
  fs.mkdirSync(mailboxDir, { recursive: true });
529
628
  if (archived.length === 0) {
@@ -533,15 +632,42 @@ async function main() {
533
632
  for (const dir of archived)
534
633
  console.log(`archived: ${dir}`);
535
634
  }
536
- if (cleared > 0)
537
- console.log(`cleared: ${cleared} dead marker/socket file(s) (disposable process state)`);
635
+ if (dead.cleared > 0)
636
+ console.log(`cleared: ${dead.cleared} dead marker/socket file(s) (disposable process state)`);
637
+ if (refuted.cleared > 0) {
638
+ console.log(`refuted: ${refuted.cleared} legacy/corrupt marker(s) named an owner pid that cannot own a session ` +
639
+ "(<= 1, or not a pid at all); REFUTED BY CONSTRUCTION, not proven dead, and cleared. The observed " +
640
+ "case was a hook reparented to init before it read its own parent.");
641
+ }
538
642
  console.log(`fresh generation open: ${storeDir} (empty, v3-only)`);
539
643
  console.log("untouched: native transcripts, andenken/embedding memory axes, install state. " +
540
644
  "The archive is forensic only — no runtime reads it and there is no restore verb.");
541
- return 0;
645
+ // The generation IS cut by now, so a cleanup failure must not throw away that
646
+ // story — but it must not be swallowed either: this command's own report claims
647
+ // the residue was "cleared". Say exactly which files survived and why, keep the
648
+ // success lines above (this is not a half-cut), and exit nonzero so no caller
649
+ // reads a partial sweep as a clean one.
650
+ const cleanupFailures = [...dead.failures, ...refuted.failures];
651
+ if (cleanupFailures.length > 0) {
652
+ for (const { file, reason } of cleanupFailures)
653
+ console.error(`FAIL post-cut cleanup: ${file} — ${reason}`);
654
+ console.error(`FAIL post-cut cleanup: ${cleanupFailures.length} marker/socket file(s) above could NOT be removed. The ` +
655
+ "generation was archived and the fresh generation is open — this is not a half-cut, and install/citizen " +
656
+ "birth are no longer blocked by the store. What survived is disposable process state sitting in the new " +
657
+ "generation's surfaces, and it will refuse the NEXT cut. Fix the cause (permissions, a read-only mount, " +
658
+ `an immutable attribute) and re-run the same command BEFORE \`setup\`, or remove those files by hand. EXIT ` +
659
+ `${FRESH_CUT_EXIT.CLEANUP_INCOMPLETE}: the cut is DONE, so \`setup\` may proceed — but once a new citizen is ` +
660
+ "born, another fresh-cut would archive that new generation too; repair the named residue manually instead.");
661
+ return FRESH_CUT_EXIT.CLEANUP_INCOMPLETE;
662
+ }
663
+ return FRESH_CUT_EXIT.COMPLETE;
542
664
  }
543
665
  main().then((code) => process.exit(code), (err) => {
544
666
  console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
545
667
  console.error("FAIL: fresh-cut did not complete — the message above names the cause; re-run after fixing it.");
546
- process.exit(1);
668
+ // Not a flat 1: the ONE thing a caller must know from a crash is whether the host
669
+ // still holds its generation. Every refusal that reaches here from before the first
670
+ // rename really is a no-op (its own message says so); anything after it is a
671
+ // an incomplete cut transition and must not be mistaken for a no-op (#54).
672
+ process.exit(midCutExit(archivedSoFar));
547
673
  });
@@ -40,8 +40,7 @@
40
40
  * - no user-specific paths baked in; env-configurable with safe defaults
41
41
  */
42
42
 
43
- import { existsSync, readFileSync } from "node:fs";
44
- import * as fs from "node:fs/promises";
43
+ import { existsSync } from "node:fs";
45
44
  import * as os from "node:os";
46
45
  import * as path from "node:path";
47
46
  import * as process from "node:process";
@@ -63,6 +62,8 @@ import {
63
62
  import {
64
63
  defaultMetaMailboxDir,
65
64
  defaultMetaSessionsDir,
65
+ makeStoreRecordReader,
66
+ readActiveStoreEntries,
66
67
  readMetaInbox,
67
68
  readMetaReceiverMarker,
68
69
  } from "../../../pi-extensions/lib/meta-session.ts";
@@ -441,18 +442,15 @@ server.tool(
441
442
  {},
442
443
  async () => {
443
444
  try {
444
- // Meta-store axis: list `.meta.json` entries (ENOENT = fresh install =
445
- // empty; any other readdir failure is a real error, not a silent empty).
445
+ // Meta-store axis: entries WITH their kind (ENOENT = fresh install = empty; any
446
+ // other readdir failure is a real error, not a silent empty). The name-only
447
+ // readdir that used to live here made rule 1 unenforceable on this surface —
448
+ // `readRecord` would follow a symlinked `.meta.json` to bytes the store does not
449
+ // own, while the doctor refused that same entry. One store, one contract.
446
450
  const sessionsDir = defaultMetaSessionsDir();
447
- let metaEntries: string[] = [];
448
- try {
449
- metaEntries = (await fs.readdir(sessionsDir)).filter((n) => n.endsWith(".meta.json"));
450
- } catch (err) {
451
- if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") throw err;
452
- }
453
451
  const result = await listEntwurfFacts({
454
- metaEntries,
455
- readRecord: (filename) => readFileSync(path.join(sessionsDir, filename), "utf8"),
452
+ metaEntries: readActiveStoreEntries(sessionsDir),
453
+ readRecord: makeStoreRecordReader(sessionsDir),
456
454
  // Socket axis: the same dir dispatch uses (grammar SSOT), scan-internal only.
457
455
  socket: { dir: ENTWURF_DIR },
458
456
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.12.8",
3
+ "version": "0.12.9",
4
4
  "description": "Garden-citizen dispatch substrate and meta-bridge for Claude Code, Codex, Antigravity, and pi harnesses.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -78,16 +78,16 @@
78
78
  "zod": "^3.25.0 || ^4.0.0"
79
79
  },
80
80
  "peerDependencies": {
81
- "@earendil-works/pi-ai": ">=0.82.0 <0.83",
82
- "@earendil-works/pi-coding-agent": ">=0.82.0 <0.83",
83
- "@earendil-works/pi-tui": ">=0.82.0 <0.83",
81
+ "@earendil-works/pi-ai": ">=0.82.1 <0.83",
82
+ "@earendil-works/pi-coding-agent": ">=0.82.1 <0.83",
83
+ "@earendil-works/pi-tui": ">=0.82.1 <0.83",
84
84
  "typebox": "*"
85
85
  },
86
86
  "devDependencies": {
87
87
  "@biomejs/biome": "^2.4.13",
88
- "@earendil-works/pi-ai": "0.82.0",
89
- "@earendil-works/pi-coding-agent": "0.82.0",
90
- "@earendil-works/pi-tui": "0.82.0",
88
+ "@earendil-works/pi-ai": "0.82.1",
89
+ "@earendil-works/pi-coding-agent": "0.82.1",
90
+ "@earendil-works/pi-tui": "0.82.1",
91
91
  "@types/node": "^24.3.0",
92
92
  "husky": "^9.1.7",
93
93
  "typescript": "^6.0.2"
@@ -61,7 +61,7 @@
61
61
  * Send-is-throw cleanup; see note above.)
62
62
  */
63
63
 
64
- import { existsSync, promises as fs, readFileSync } from "node:fs";
64
+ import { existsSync, promises as fs } from "node:fs";
65
65
  import * as net from "node:net";
66
66
  import * as os from "node:os";
67
67
  import * as path from "node:path";
@@ -525,7 +525,7 @@ const renderSessionMessage: MessageRenderer = (message, { expanded }, theme) =>
525
525
  // from the native call site until the schema grows the field.
526
526
  interface SentBoxData {
527
527
  to: string; // target sessionId
528
- from?: string; // sender agentId, e.g. "entwurf/claude-opus-4-8"
528
+ from?: string; // sender agentId, e.g. "entwurf/claude-opus-5"
529
529
  cwd?: string; // sender cwd (raw, abbreviateHome applied at render)
530
530
  timestamp?: string; // ISO 8601 UTC; rendered in KST
531
531
  mode?: string; // "steer" | "follow_up" | string passed through
@@ -1396,7 +1396,7 @@ The decider — not this surface — chooses the transport.`,
1396
1396
 
1397
1397
  interface EntwurfFactProviderModule {
1398
1398
  listEntwurfFacts(params: {
1399
- metaEntries: readonly string[];
1399
+ metaEntries: readonly { filename: string; regularFile: boolean }[];
1400
1400
  readRecord: (filename: string) => string;
1401
1401
  socket: { dir: string };
1402
1402
  }): Promise<unknown>;
@@ -1408,22 +1408,22 @@ interface EntwurfPeersRenderModule {
1408
1408
 
1409
1409
  interface MetaSessionModule {
1410
1410
  defaultMetaSessionsDir(): string;
1411
+ readActiveStoreEntries(dir: string): { filename: string; regularFile: boolean }[];
1412
+ makeStoreRecordReader(dir: string): (filename: string) => string;
1411
1413
  }
1412
1414
 
1413
1415
  async function renderEntwurfPeersForSurface(): Promise<{ text: string; payload: unknown }> {
1414
1416
  const meta = (await import(META_SESSION_MODULE)) as unknown as MetaSessionModule;
1415
1417
  const sessionsDir = meta.defaultMetaSessionsDir();
1416
- let metaEntries: string[] = [];
1417
- try {
1418
- metaEntries = (await fs.readdir(sessionsDir)).filter((name) => name.endsWith(".meta.json"));
1419
- } catch (err) {
1420
- if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") throw err;
1421
- }
1422
-
1418
+ // Entries carry their KIND. The name-only readdir this used to do left `readRecord`
1419
+ // free to follow a symlinked `.meta.json` into bytes the store does not own — rule 1
1420
+ // held in the doctor and not on the surface operators actually read (pre-existing;
1421
+ // surfaced by the #52 duplicate pass, which would let such a symlink quarantine the
1422
+ // healthy record it shadowed).
1423
1423
  const provider = (await import(ENTWURF_FACT_PROVIDER_MODULE)) as unknown as EntwurfFactProviderModule;
1424
1424
  const result = await provider.listEntwurfFacts({
1425
- metaEntries,
1426
- readRecord: (filename) => readFileSync(path.join(sessionsDir, filename), "utf8"),
1425
+ metaEntries: meta.readActiveStoreEntries(sessionsDir),
1426
+ readRecord: meta.makeStoreRecordReader(sessionsDir),
1427
1427
  // Same socket axis as the legacy live-session scan, but merged with the
1428
1428
  // meta-record rail by listEntwurfFacts so meta-mailbox citizens are discoverable too.
1429
1429
  socket: { dir: ENTWURF_DIR },
@@ -18,14 +18,14 @@
18
18
  // Why `/compat` and NOT the 0.80 provider-factory subpath (the pi-ai
19
19
  // providers/anthropic subpath): this file is loaded by pi's
20
20
  // EXTENSION loader (pi-coding-agent `core/extensions/loader.ts`), whose jiti
21
- // alias map resolves ONLY three pi-ai specifiers for extensions — the bare root,
22
- // `/compat`, and `/oauth` all to `ai/dist/compat.js`. A `providers/*` subpath
23
- // is NOT in that map: jiti prefix-matches the bare `@earendil-works/pi-ai` alias
24
- // and appends the remainder, yielding the unresolvable
25
- // `…/dist/compat.js/providers/anthropic` (verified live: extension load crash,
26
- // invisible to static typecheck which resolves against node_modules `exports`).
27
- // So `/compat` is the SINGLE sanctioned extension entrypoint for the old global
28
- // model-catalog API, and the SINGLE allowlisted exception in
21
+ // alias map resolves FOUR pi-ai specifiers for extensions — the bare root,
22
+ // `/compat`, `/oauth`, and (since pi 0.81) `/providers/all`. Other `providers/*`
23
+ // subpaths are NOT in that map: jiti prefix-matches the bare
24
+ // `@earendil-works/pi-ai` alias and appends the remainder, yielding the
25
+ // unresolvable `…/dist/compat.js/providers/anthropic` (verified live: extension
26
+ // load crash, invisible to static typecheck which resolves against node_modules
27
+ // `exports`). So `/compat` remains the SINGLE sanctioned extension entrypoint
28
+ // this repo uses for the old global model-catalog API, and the SINGLE allowlisted exception in
29
29
  // `run.sh check-pi-import-surface`. `getModels` here is compat's deprecated
30
30
  // re-export of `getBuiltinModels`. When pi removes compat we migrate to whatever
31
31
  // the loader then exposes.
@@ -50,13 +50,13 @@ export const ENTWURF_ACP_NO_AUTH_SENTINEL = "entwurf-no-auth";
50
50
  // both axes (protocol smoke + agent interview) — do not extend casually.
51
51
  // Exported so the claude backend adapter (backend-adapter.ts) can answer
52
52
  // `routeModel` without re-deriving the set from curatedClaudeModels().
53
- export const SUPPORTED_ANTHROPIC_MODEL_IDS = ["claude-sonnet-5", "claude-opus-4-8"] as const;
53
+ export const SUPPORTED_ANTHROPIC_MODEL_IDS = ["claude-sonnet-5", "claude-opus-5"] as const;
54
54
 
55
55
  /** The anchor model whose absence is a hard registry regression, not a soft skip. */
56
- export const CURATED_ANCHOR_MODEL_ID = "claude-opus-4-8";
56
+ export const CURATED_ANCHOR_MODEL_ID = "claude-opus-5";
57
57
 
58
- // Anthropic's registry reports 1M for both Sonnet 5 and Opus 4.8, and the
59
- // entwurf surface now exposes the full 1M for BOTH. Sonnet 5's 1M window is the
58
+ // Anthropic's registry reports 1M for both Sonnet 5 and Opus 5, and the
59
+ // entwurf surface exposes the full 1M for BOTH. Sonnet 5's 1M window is the
60
60
  // whole point of the 0.12.3 bump — it is the compact-free long-context floor the
61
61
  // earlier 200K Sonnet cap could not provide. We still clamp to a 1M ceiling so a
62
62
  // future registry value can't silently inflate the surface past what we verify.
@@ -29,7 +29,12 @@
29
29
 
30
30
  import { type FactList, isNonPiGardenIdSocketConflict, resolveFactList } from "./entwurf-facts.ts";
31
31
  import { isLivenessSupported } from "./entwurf-v2-contract.ts";
32
- import { FRESH_CUT_PRESCRIPTION, listAllMetaIdentities, type MetaCitizenBackend } from "./meta-session.ts";
32
+ import {
33
+ type ActiveStoreEntry,
34
+ FRESH_CUT_PRESCRIPTION,
35
+ listAllMetaIdentities,
36
+ type MetaCitizenBackend,
37
+ } from "./meta-session.ts";
33
38
  import { type SocketScanDeps, scanSocketProbes } from "./socket-discovery.ts";
34
39
  import type { SocketLiveness } from "./socket-probe.ts";
35
40
 
@@ -82,7 +87,9 @@ export interface EntwurfFactsResult {
82
87
 
83
88
  export interface EntwurfFactsDeps {
84
89
  /** Meta-store axis: the `.meta.json` entry names + a record reader. */
85
- metaEntries: readonly string[];
90
+ /** Store entries WITH their kind (`readActiveStoreEntries`), never bare names: the
91
+ * listing must be able to refuse a symlinked record without following it. */
92
+ metaEntries: readonly ActiveStoreEntry[];
86
93
  readRecord: (filename: string) => string;
87
94
  /** Socket axis: injected into scanSocketProbes (controlDir/readdir/probe). */
88
95
  socket?: Partial<SocketScanDeps>;
@@ -21,6 +21,9 @@
21
21
  *
22
22
  * `resolveTarget` (QB1): a MISSING meta-record is a soft `bad-target` (identity:null); a
23
23
  * PRESENT-but-corrupt record fails LOUD (the read throws drift) — never matched by string.
24
+ * "Corrupt" includes a record that does not hold its `nativeSessionId` alone (#52): the read
25
+ * here is the ADDRESSABLE one, so the store-wide uniqueness half runs at this boundary and
26
+ * nowhere cheaper. Absent stays soft; ambiguous never does.
24
27
  * `preProbeAddressConflict` (QB2): a single record-side `lstat` (NO connect) of the target's
25
28
  * canonical socket path; `indeterminate` (EACCES/unknown) fails loud rather than silently
26
29
  * claiming "no conflict". The decider's later `inspectSocket` probe is a SEPARATE step.
@@ -84,7 +87,7 @@ import {
84
87
  type MetaReceiverMarker,
85
88
  metaCapabilityFor,
86
89
  metaRecordExistsByGardenId,
87
- readMetaIdentityByGardenId,
90
+ readAddressableMetaIdentity,
88
91
  readMetaReceiverMarker,
89
92
  } from "./meta-session.ts";
90
93
  import {
@@ -110,6 +113,9 @@ import { classifyConnectError, probeSocketLiveness, type SocketLiveness } from "
110
113
  */
111
114
  export interface ProductionEntwurfV2Seams {
112
115
  metaRecordExists: (gid: string, sessionsDir: string) => boolean;
116
+ /** The ADDRESSABLE read (#52): the per-entry contract plus store-wide `nativeSessionId`
117
+ * uniqueness. This is a dispatch, so the id it returns is about to become an address —
118
+ * a duplicate here would direct-inject one native session under two garden ids. */
113
119
  readIdentity: (gid: string, sessionsDir: string) => MetaIdentity;
114
120
  /** Read the target's receiver presence marker (null = absent / dead owner / corrupt). The
115
121
  * SE-2 2d-3 active-receiver source; the factory's `mailboxDeliverabilityFor` closure verifies
@@ -222,7 +228,7 @@ export function makeProductionEntwurfV2Deps(opts: ProductionEntwurfV2Opts): Entw
222
228
  const s = opts.seams ?? {};
223
229
  const io: ProductionEntwurfV2Seams = {
224
230
  metaRecordExists: s.metaRecordExists ?? metaRecordExistsByGardenId,
225
- readIdentity: s.readIdentity ?? readMetaIdentityByGardenId,
231
+ readIdentity: s.readIdentity ?? readAddressableMetaIdentity,
226
232
  readReceiverMarker: s.readReceiverMarker ?? ((gid: string) => readMetaReceiverMarker({ gardenId: gid })),
227
233
  inspectPath: s.inspectPath ?? inspectControlSocketPath,
228
234
  acquireLock: s.acquireLock ?? realAcquireLock,