@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
@@ -396,7 +396,23 @@ const META_IDENTITY_KEYS = [
396
396
  "transcriptPath",
397
397
  "createdAt",
398
398
  "recordUpdatedAt",
399
+ "attachIncarnation",
399
400
  ];
401
+ /** `attachIncarnation` grammar: 32 lowercase hex characters (`randomBytes(16)`). */
402
+ const ATTACH_INCARNATION_RE = /^[0-9a-f]{32}$/;
403
+ /** Validate an `attachIncarnation`: null, or the 32-hex grammar. Throws on anything else. */
404
+ export function requireAttachIncarnation(value) {
405
+ if (value === null)
406
+ return null;
407
+ if (typeof value !== "string" || !ATTACH_INCARNATION_RE.test(value)) {
408
+ throw new MetaRecordError(`meta-record field "attachIncarnation" must be null or 32 lowercase hex characters (got ${describe(value)}).`);
409
+ }
410
+ return value;
411
+ }
412
+ /** Mint one arm binding value. Clock-independent: 128 random bits, never derived from time. */
413
+ export function mintAttachIncarnation() {
414
+ return crypto.randomBytes(16).toString("hex");
415
+ }
400
416
  /** Parse + fully validate untrusted JSON into a v3 MetaIdentity. Throws on any drift. */
401
417
  export function parseMetaRecordV3(json) {
402
418
  let raw;
@@ -432,6 +448,10 @@ export function parseMetaRecordV3(json) {
432
448
  transcriptPath: requireNullableString(obj.transcriptPath, "transcriptPath"),
433
449
  createdAt: requireNonEmptyString(obj.createdAt, "createdAt"),
434
450
  recordUpdatedAt: requireNonEmptyString(obj.recordUpdatedAt, "recordUpdatedAt"),
451
+ // The one key a V3 record may omit: a record nothing has touched since the field
452
+ // existed reads its absence as null ("no binding observed"). One schema, one reader —
453
+ // not a legacy alias: every serialize emits the key.
454
+ attachIncarnation: requireAttachIncarnation(obj.attachIncarnation === undefined ? null : obj.attachIncarnation),
435
455
  };
436
456
  }
437
457
  /**
@@ -450,6 +470,7 @@ export function normalizeMetaIdentity(record) {
450
470
  transcriptPath: record.transcriptPath,
451
471
  createdAt: record.createdAt,
452
472
  recordUpdatedAt: record.recordUpdatedAt,
473
+ attachIncarnation: record.attachIncarnation,
453
474
  };
454
475
  }
455
476
  // ---------------------------------------------------------------------------
@@ -474,6 +495,7 @@ export function serializeMetaIdentity(identity) {
474
495
  transcriptPath: identity.transcriptPath,
475
496
  createdAt: identity.createdAt,
476
497
  recordUpdatedAt: identity.recordUpdatedAt,
498
+ attachIncarnation: identity.attachIncarnation,
477
499
  };
478
500
  return `${JSON.stringify(ordered, null, 2)}\n`;
479
501
  }
@@ -497,6 +519,7 @@ export function mintMetaIdentity(input, now = new Date()) {
497
519
  transcriptPath: requireNullableString(input.transcriptPath ?? null, "transcriptPath"),
498
520
  createdAt: ts,
499
521
  recordUpdatedAt: ts,
522
+ attachIncarnation: input.attachIncarnation === undefined ? null : requireAttachIncarnation(input.attachIncarnation),
500
523
  };
501
524
  }
502
525
  /**
@@ -1097,9 +1120,13 @@ export function decideUpsert(existing, input, now = new Date()) {
1097
1120
  const transcriptPath = input.transcriptPath === undefined
1098
1121
  ? existing.transcriptPath
1099
1122
  : requireNullableString(input.transcriptPath, "transcriptPath");
1123
+ // The arm binding is not a merge axis: only a mint replaces it, and nothing clears it.
1124
+ const attachIncarnation = input.attachIncarnation === undefined
1125
+ ? existing.attachIncarnation
1126
+ : requireAttachIncarnation(input.attachIncarnation);
1100
1127
  return {
1101
1128
  action: "attach",
1102
- record: { ...existing, cwd, model, transcriptPath, recordUpdatedAt: isoNow(now) },
1129
+ record: { ...existing, cwd, model, transcriptPath, recordUpdatedAt: isoNow(now), attachIncarnation },
1103
1130
  };
1104
1131
  }
1105
1132
  // ---------------------------------------------------------------------------
@@ -1196,6 +1223,16 @@ export function defaultMetaReceiversDir() {
1196
1223
  return path.resolve(expandTilde(process.env.ENTWURF_META_RECEIVERS_DIR));
1197
1224
  return path.join(piAgentDir(), "meta-receivers");
1198
1225
  }
1226
+ /**
1227
+ * The record-arm lock directory (#147 L2 B1): a sibling of the receivers dir, so a
1228
+ * sandboxed receivers root sandboxes it too and no new environment carrier exists. It is
1229
+ * deliberately NOT the v2 dispatch lock dir — visible resume holds that per-gid lock across
1230
+ * its whole launch + observation window, and the relaunched session's own arm must not wait
1231
+ * on it. Only the Claude Code hook takes this lock; nothing else reads or writes here.
1232
+ */
1233
+ export function defaultMetaRecordArmLocksDir(receiversDir = defaultMetaReceiversDir()) {
1234
+ return path.join(path.dirname(receiversDir), "meta-record-arm-locks");
1235
+ }
1199
1236
  /**
1200
1237
  * The exact provenance label the omp-native MCP entry carries
1201
1238
  * (`scripts/omp-mcp-config.py` `EXTERNAL_AGENT_ID`). It is what lets a bridge CHILD know
@@ -1652,12 +1689,38 @@ export function writeMetaReceiverMarker(opts) {
1652
1689
  ownerKind: requireNonEmptyString(opts.ownerKind ?? "claude-code-cli", "ownerKind"),
1653
1690
  armProvenance: requireArmProvenance(opts.armProvenance),
1654
1691
  updatedAt: isoNow(opts.now ?? new Date()),
1692
+ attachIncarnation: opts.attachIncarnation === undefined ? null : requireAttachIncarnation(opts.attachIncarnation),
1655
1693
  };
1656
1694
  const tmp = `${file}.${crypto.randomBytes(4).toString("hex")}.tmp`;
1657
1695
  fs.writeFileSync(tmp, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 });
1658
1696
  fs.renameSync(tmp, file);
1659
1697
  return file;
1660
1698
  }
1699
+ /**
1700
+ * Validate receiver-marker bytes — the one validator, shared by the path reader below and by
1701
+ * the no-follow fd reader visible resume uses (#147 L2 D3). Throws on a malformed marker;
1702
+ * returns null for an IMPLAUSIBLE owner pid. Same rule as the sender marker, and for the same
1703
+ * reason one layer over: an impossible owner is not a live one, so a refuted marker must never
1704
+ * read back as an ACTIVE RECEIVER and pull the mailbox rail into delivering to a void. An
1705
+ * absent `attachIncarnation` reads as null; a present malformed one throws.
1706
+ */
1707
+ export function parseMetaReceiverMarker(json) {
1708
+ const raw = JSON.parse(json);
1709
+ const marker = {
1710
+ gardenId: requireGardenId(raw.gardenId),
1711
+ backend: requireBackend(raw.backend),
1712
+ nativeSessionId: requireNonEmptyString(raw.nativeSessionId, "nativeSessionId"),
1713
+ ownerPid: typeof raw.ownerPid === "number" ? raw.ownerPid : Number.NaN,
1714
+ ownerStartKey: requireNonEmptyString(raw.ownerStartKey, "ownerStartKey"),
1715
+ ownerKind: requireNonEmptyString(raw.ownerKind, "ownerKind"),
1716
+ armProvenance: requireArmProvenance(raw.armProvenance),
1717
+ updatedAt: requireNonEmptyString(raw.updatedAt, "updatedAt"),
1718
+ attachIncarnation: requireAttachIncarnation(raw.attachIncarnation === undefined ? null : raw.attachIncarnation),
1719
+ };
1720
+ if (!isPlausibleOwnerPid(marker.ownerPid))
1721
+ return null;
1722
+ return marker;
1723
+ }
1661
1724
  /**
1662
1725
  * Read the receiver presence marker for a garden id. Returns null when absent,
1663
1726
  * corrupt, or (under verifyOwner) the owner pid is no longer the same live process —
@@ -1674,21 +1737,8 @@ export function readMetaReceiverMarker(opts) {
1674
1737
  if (!file || !fs.existsSync(file))
1675
1738
  return null;
1676
1739
  try {
1677
- const raw = JSON.parse(fs.readFileSync(file, "utf8"));
1678
- const marker = {
1679
- gardenId: requireGardenId(raw.gardenId),
1680
- backend: requireBackend(raw.backend),
1681
- nativeSessionId: requireNonEmptyString(raw.nativeSessionId, "nativeSessionId"),
1682
- ownerPid: typeof raw.ownerPid === "number" ? raw.ownerPid : Number.NaN,
1683
- ownerStartKey: requireNonEmptyString(raw.ownerStartKey, "ownerStartKey"),
1684
- ownerKind: requireNonEmptyString(raw.ownerKind, "ownerKind"),
1685
- armProvenance: requireArmProvenance(raw.armProvenance),
1686
- updatedAt: requireNonEmptyString(raw.updatedAt, "updatedAt"),
1687
- };
1688
- // Same rule as the sender marker, and for the same reason one layer over: an
1689
- // impossible owner is not a live one, so a refuted marker must never read back
1690
- // as an ACTIVE RECEIVER and pull the mailbox rail into delivering to a void.
1691
- if (!isPlausibleOwnerPid(marker.ownerPid))
1740
+ const marker = parseMetaReceiverMarker(fs.readFileSync(file, "utf8"));
1741
+ if (marker === null)
1692
1742
  return null;
1693
1743
  if (opts.verifyOwner !== false) {
1694
1744
  const liveKey = processStartKey(marker.ownerPid);
@@ -1774,6 +1824,9 @@ export function upsertMetaSession(opts) {
1774
1824
  const target = requireNonEmptyString(opts.input.nativeSessionId, "nativeSessionId");
1775
1825
  const existing = cert.records.find((record) => record.identity.nativeSessionId === target)?.identity ?? null;
1776
1826
  const decision = decideUpsert(existing, opts.input, opts.now);
1827
+ if (opts.expectAction !== undefined && decision.action !== opts.expectAction) {
1828
+ throw new UpsertActionMismatchError(opts.expectAction, decision.action, target);
1829
+ }
1777
1830
  const file = path.join(dir, metaRecordFilename(decision.record));
1778
1831
  if (decision.action === "create")
1779
1832
  publishExclusiveIdentity(file, decision.record);
@@ -1781,6 +1834,33 @@ export function upsertMetaSession(opts) {
1781
1834
  atomicWriteIdentity(file, decision.record);
1782
1835
  return { action: decision.action, record: decision.record, dir, path: file };
1783
1836
  }
1837
+ /** The upsert refused before writing because its decision was not the expected action. */
1838
+ export class UpsertActionMismatchError extends MetaRecordError {
1839
+ expected;
1840
+ actual;
1841
+ constructor(expected, actual, nativeSessionId) {
1842
+ super(`upsert expected to ${expected} the record for nativeSessionId "${nativeSessionId}" but would ${actual} ` +
1843
+ "it; nothing was written.");
1844
+ this.expected = expected;
1845
+ this.actual = actual;
1846
+ }
1847
+ }
1848
+ /**
1849
+ * The certified record a native session id already has, READ ONLY — the same certification
1850
+ * {@link upsertMetaSession} runs (a defective store throws the same refusal), with no write.
1851
+ * The Claude Code hook learns the garden id it must lock this way, so no record write ever
1852
+ * happens outside the record-arm lock for an existing record (#147 L2 B1).
1853
+ */
1854
+ export function findCertifiedIdentityByNativeSessionId(nativeSessionId, dirOverride) {
1855
+ const dir = path.resolve(expandTilde(dirOverride ?? defaultMetaSessionsDir()));
1856
+ if (!fs.existsSync(dir))
1857
+ return null;
1858
+ const cert = certifyActiveStoreDir(dir);
1859
+ if (cert.defects.length > 0)
1860
+ throw new MetaRecordError(activeStoreRefusal(cert));
1861
+ const target = requireNonEmptyString(nativeSessionId, "nativeSessionId");
1862
+ return cert.records.find((record) => record.identity.nativeSessionId === target)?.identity ?? null;
1863
+ }
1784
1864
  /**
1785
1865
  * ATTACH replace primitive: tmp-file + rename so a crash never leaves a
1786
1866
  * half-written record (#30 crash-safety). rename(2) REPLACES an existing final
@@ -91,8 +91,8 @@ import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
91
91
  import { CODEX_CALLER_SEAT_HINT, resolveCodexCallerSeat, } from "./codex-caller-seat.js";
92
92
  import { CODEX_CALLER_PREFLIGHT_HINT, CODEX_LAUNCH_CWD_PREFLIGHT_HINT, CODEX_PREFLIGHT_HINT, codexLaunchCwdFreshPreflight, } from "./codex-fresh-preflight.js";
93
93
  import { COPILOT_PREFLIGHT_HINT, copilotFreshPreflight, } from "./copilot-fresh-preflight.js";
94
- import { buildOmpBootstrapPayload, composeBackendArgs, composeFreshCallPrompt, FRESH_CALL_BACKENDS, FRESH_CALL_CALLBACK_TOOL, FRESH_CALL_DELIVERY_TOOL, isSafeFreshCallModel, MODEL_MAX_CHARS, mintNonce, normalizeFreshCallInputs, OMP_BOOTSTRAP_FLAG, OMP_BOOTSTRAP_VERSION, TASK_MAX_CHARS, } from "./fresh-call-composition.js";
95
- import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
94
+ import { buildOmpBootstrapPayload, composeBackendArgs, composeFreshCallPrompt, FRESH_CALL_BACKENDS, FRESH_CALL_CALLBACK_TOOL, FRESH_CALL_DELIVERY_TOOL, FRESH_CALL_PROVIDER_HINT, isSafeFreshCallModel, MODEL_MAX_CHARS, mintNonce, normalizeFreshCallInputs, OMP_BOOTSTRAP_FLAG, OMP_BOOTSTRAP_VERSION, TASK_MAX_CHARS, } from "./fresh-call-composition.js";
95
+ import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, SCRUBBED_INHERITED_ENV, } from "./mux-launch.js";
96
96
  import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
97
97
  import { resolveCodexDefaultSocketPath } from "./native-push/codex-ws-client.js";
98
98
  import { OMP_PREFLIGHT_HINT, ompFreshPreflight } from "./omp-fresh-preflight.js";
@@ -188,33 +188,8 @@ export function selectFreshCallSeat(placement) {
188
188
  return { tmuxSession: placement.tmuxSession, source: "requested" };
189
189
  return null;
190
190
  }
191
- /**
192
- * The pi identity carrier, scrubbed at the launch seam for EVERY backend (#87 Bundle C).
193
- *
194
- * `[측정]` 2026-08-30, private tmux server: a `new-window` pane inherits the tmux SERVER's
195
- * environment, not the caller's. A server started from a shell that exported `PI_SESSION_ID`
196
- * hands that value to every window it will ever open — the control run printed
197
- * `SID=[leaked-uuid]` in a pane the caller never touched. Nothing about the fresh call creates
198
- * that leak and nothing about it notices: the sibling's own MCP child would read the STALE pair
199
- * as its authoritative identity and call home as a citizen it is not (`mcp/entwurf-bridge/
200
- * src/index.ts:692-698` keeps the measured incident — a fresh cell answering with the uuidv7 it
201
- * found in the environment, confidently and wrong).
202
- *
203
- * `-e VAR=` sets the variable EMPTY rather than unsetting it, which tmux has no per-window form
204
- * for. That is sufficient and not a compromise: every reader of the carrier trims and tests
205
- * truthiness (`index.ts:212-217`), so empty and absent are the same answer by construction.
206
- *
207
- * It is applied to all six backends because the leak is a property of tmux, not of a vendor. A
208
- * scrub only on the backend whose measurement surfaced it would encode the claim that the other
209
- * five are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
210
- * authoritative when the process that owns it exported it ITSELF, and a fresh `pi` sibling does
211
- * exactly that after this argv has run. This is a fixed seam and deliberately NOT a general env
212
- * carrier — an arbitrary `-e` passthrough would hand callers the environment-shaping power this
213
- * rail exists to refuse. Two more `-e` assignments ride beside the scrub: the callback pair
214
- * (`callback-env.ts`), launcher-computed, so the sibling's first action does not retype an
215
- * address out of prose.
216
- */
217
- const SCRUBBED_INHERITED_ENV = ["PI_SESSION_ID=", "PI_AGENT_ID="];
191
+ // The identity scrub every fresh window carries is `SCRUBBED_INHERITED_ENV` (mux-launch.ts), shared
192
+ // with the resume rail because the leak it closes is a property of tmux, not of either verb.
218
193
  /** Launch argv: the leaf's detached-append shape, the identity scrub, optionally `-c` at the
219
194
  * resume-symmetric token position (after `-t`, before `-P -F`), the runtime, then the backend's
220
195
  * dialect. An omitted cwd adds no `-c` carrier at all.
@@ -478,6 +453,7 @@ const REJECT_HINT = {
478
453
  ...CODEX_PREFLIGHT_HINT,
479
454
  ...CODEX_CALLER_PREFLIGHT_HINT,
480
455
  ...CODEX_CALLER_SEAT_HINT,
456
+ ...FRESH_CALL_PROVIDER_HINT,
481
457
  "no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
482
458
  "anchor-malformed": "TMUX_PANE is not a native pane id",
483
459
  "anchor-unresolved": "tmux resolved no pane for this agent's anchor",
@@ -59,6 +59,34 @@ import { APPEND_FORMAT, assertSelector, assertTmuxOk, parseWindowFields, require
59
59
  /** The official runtime this rail opens. FIXED: there is no command carrier, and adding one
60
60
  * is a separate ownership decision (docs §11), not a parameter. */
61
61
  export const PI_RUNTIME_COMMAND = "pi";
62
+ /**
63
+ * The pi identity carrier, scrubbed at the launch seam for EVERY backend (#87 Bundle C).
64
+ *
65
+ * `[측정]` 2026-08-30, private tmux server: a `new-window` pane inherits the tmux SERVER's
66
+ * environment, not the caller's. A server started from a shell that exported `PI_SESSION_ID`
67
+ * hands that value to every window it will ever open — the control run printed
68
+ * `SID=[leaked-uuid]` in a pane the caller never touched. Nothing about the fresh call creates
69
+ * that leak and nothing about it notices: the sibling's own MCP child would read the STALE pair
70
+ * as its authoritative identity and call home as a citizen it is not (`mcp/entwurf-bridge/
71
+ * src/index.ts:692-698` keeps the measured incident — a fresh cell answering with the uuidv7 it
72
+ * found in the environment, confidently and wrong).
73
+ *
74
+ * `-e VAR=` sets the variable EMPTY rather than unsetting it, which tmux has no per-window form
75
+ * for. That is sufficient and not a compromise: every reader of the carrier trims and tests
76
+ * truthiness (`index.ts:212-217`), so empty and absent are the same answer by construction.
77
+ *
78
+ * It is applied to every fresh backend and to every resumed one (#147 L2) because the leak is a
79
+ * property of tmux, not of a vendor or a verb. A
80
+ * scrub only on the backend whose measurement surfaced it would encode the claim that the other
81
+ * five are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
82
+ * authoritative when the process that owns it exported it ITSELF, and a fresh `pi` sibling does
83
+ * exactly that after this argv has run. This is a fixed seam and deliberately NOT a general env
84
+ * carrier — an arbitrary `-e` passthrough would hand callers the environment-shaping power this
85
+ * rail exists to refuse. Two more `-e` assignments ride beside the scrub: the callback pair
86
+ * (`callback-env.ts`), launcher-computed, so the sibling's first action does not retype an
87
+ * address out of prose.
88
+ */
89
+ export const SCRUBBED_INHERITED_ENV = ["PI_SESSION_ID=", "PI_AGENT_ID="];
62
90
  /** A named precondition failure. Callers surface `reason`; they never soften it into a
63
91
  * fallback launch of something else. */
64
92
  export class LaunchPreconditionError extends Error {
@@ -1,6 +1,6 @@
1
1
  /**
2
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.
3
+ * not choose, start the backend's FIXED runtime there, and hand it argv this module did not author.
4
4
  *
5
5
  * ── Why this is a module and not a parameter on fresh-call ──
6
6
  *
@@ -15,7 +15,15 @@
15
15
  * visible-resume composition → resume-call → placement leaf (unchanged, carrier-free)
16
16
  * resume-call -X-> garden identity, records, locks, delivery
17
17
  *
18
- * This module never learns which citizen it is reopening. It sees a cwd and an argv tail.
18
+ * This module never learns which citizen it is reopening. It sees a backend, a cwd and an argv
19
+ * tail; the backend only selects a runtime from a closed table (#147 L2).
20
+ *
21
+ * ── Tmux only, and said so inside herdr ──
22
+ *
23
+ * This is the tmux rail. A caller inside herdr (`HERDR_ENV=1`, herdr's own marker, read for its
24
+ * exact value as `fresh-call-dispatch` does) is refused by name before anything else: herdr owns
25
+ * placement there, and an ambient `TMUX` that happens to be set would otherwise put the window in
26
+ * a session the operator is not looking at. There is no herdr resume rail yet and no fallback.
19
27
  *
20
28
  * ── The cwd is the whole reason this module owns its own tmux argv ──
21
29
  *
@@ -41,15 +49,18 @@
41
49
  * to acceptance, not to the product's launch receipt.
42
50
  */
43
51
  import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
44
- import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
52
+ import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, SCRUBBED_INHERITED_ENV, } from "./mux-launch.js";
45
53
  import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
46
- /** The fixed runtime a resume reopens. Only pi stands a control socket up, so only pi has a
47
- * same-id resume at all — the backend boundary is enforced upstream by
48
- * `resolveResumeLaunchIdentity`, and this constant is the mux half of the same fact. */
49
- export const RESUME_CALL_RUNTIME = "pi";
54
+ /** The fixed runtime each resumable backend reopens with. A closed table, not a command input: the
55
+ * backend boundary is enforced upstream by `resolveResumeLaunchIdentity`, and this is the mux half
56
+ * of the same fact. */
57
+ export const RESUME_CALL_RUNTIME = { pi: "pi", "claude-code": "claude" };
50
58
  /**
51
- * Launch argv: the leaf's detached-append shape plus `-c`, the runtime, then the caller's flags.
52
- * `--` is what keeps tmux from reading the runtime or its flags as tmux options.
59
+ * Launch argv: the leaf's detached-append shape, the fixed identity scrub (`SCRUBBED_INHERITED_ENV`,
60
+ * the same two assignments every fresh window carries), `-c`, the runtime, then the caller's flags.
61
+ * `--` is what keeps tmux from reading the runtime or its flags as tmux options. The scrub is not a
62
+ * carrier: it only empties identity a tmux server may have inherited, and a resumed Claude Code
63
+ * (which, unlike pi, never re-exports its own) would otherwise present a stale one to its bridge.
53
64
  */
54
65
  export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs) {
55
66
  assertSelector("session", placement.sessionId);
@@ -61,6 +72,7 @@ export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs) {
61
72
  "new-window",
62
73
  "-d",
63
74
  "-a",
75
+ ...SCRUBBED_INHERITED_ENV.flatMap((assignment) => ["-e", assignment]),
64
76
  "-t",
65
77
  `${placement.sessionId}:{end}`,
66
78
  "-c",
@@ -81,12 +93,14 @@ export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs) {
81
93
  * `runtimeArgs` is passed through untouched. This module does not know what `--session` means.
82
94
  */
83
95
  export function resumeCall(params, env = process.env) {
96
+ if (env.HERDR_ENV === "1")
97
+ return { ok: false, reason: "herdr-resume-unsupported" };
84
98
  const badCwd = classifyTmuxCwd(params.cwd);
85
99
  if (badCwd)
86
100
  return { ok: false, reason: badCwd };
87
101
  let runtimePath;
88
102
  try {
89
- runtimePath = resolveRuntimeOnPath(RESUME_CALL_RUNTIME, env);
103
+ runtimePath = resolveRuntimeOnPath(RESUME_CALL_RUNTIME[params.backend], env);
90
104
  }
91
105
  catch (err) {
92
106
  if (err instanceof LaunchPreconditionError)
@@ -132,7 +146,8 @@ export const RESUME_CALL_REJECT_HINT = {
132
146
  "cwd-format-token": "the recorded cwd contains '#', which tmux expands as a format inside -c — it would silently rewrite the path or run a command",
133
147
  "cwd-missing": "the recorded cwd no longer exists; tmux would not report this, it would open the window in $HOME and look successful",
134
148
  "cwd-not-directory": "the recorded cwd exists but is not a directory",
135
- "runtime-unresolved": "pi is not installed on PATH",
149
+ "herdr-resume-unsupported": "this agent runs inside herdr, and same-id resume has only a tmux rail — nothing was opened in any ambient tmux session",
150
+ "runtime-unresolved": "the citizen's runtime (pi or claude) is not installed on PATH",
136
151
  "runtime-not-absolute": "the resolved runtime path is not absolute",
137
152
  "runtime-path-whitespace": "the resolved runtime path contains whitespace, which tmux would re-split",
138
153
  "runtime-missing": "nothing exists at the resolved runtime path",
@@ -0,0 +1,76 @@
1
+ /** Pi core-side request adapter, not a public tool or authorization boundary.
2
+ * The caller supplies a CERTIFIED MetaIdentity and owns authorization, self-retire refusal,
3
+ * target locking and record freshness. No second lock, record scan or receiver admission
4
+ * implementation lives here. The receiver owns generation/TUI/idle/pending validation.
5
+ */
6
+ import * as os from "node:os";
7
+ import { controlSocketPathIn, defaultControlSocketDir } from "./control-socket-path.js";
8
+ import { sendRpcCommand } from "./entwurf-control-rpc.js";
9
+ import { SESSION_ID_RE } from "./session-id.js";
10
+ const RECEIVER_REFUSALS = [
11
+ "target-closing",
12
+ "target-stale",
13
+ "target-context-unavailable",
14
+ "target-non-tui",
15
+ "target-busy",
16
+ "target-pending",
17
+ ];
18
+ function object(value) {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+ function exactKeys(value, keys) {
22
+ const actual = Object.keys(value);
23
+ return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
24
+ }
25
+ /** Closed wire combinations only. A malformed answer AFTER sending cannot prove no action.
26
+ * SDK request acceptance is not exit, persistence/flush, or no-further-model-activity evidence.
27
+ */
28
+ export function parsePiRetireResponse(response) {
29
+ const malformed = { boundary: "unknown", reason: "malformed-response" };
30
+ if (!object(response) || response.type !== "response" || response.command !== "retire")
31
+ return malformed;
32
+ if (Object.keys(response).some((key) => !["type", "command", "success", "data", "error", "id"].includes(key))) {
33
+ return malformed;
34
+ }
35
+ if (Object.hasOwn(response, "id") && typeof response.id !== "string")
36
+ return malformed;
37
+ const data = response.data;
38
+ if (!object(data))
39
+ return malformed;
40
+ if (response.success === true && data.boundary === "shutdown-requested") {
41
+ if (!exactKeys(data, ["boundary"]) || Object.hasOwn(response, "error"))
42
+ return malformed;
43
+ return { boundary: "shutdown-requested" };
44
+ }
45
+ if (response.success !== false || !exactKeys(data, ["boundary", "reason"]) || response.error !== data.reason) {
46
+ return malformed;
47
+ }
48
+ if (data.boundary === "refused" && RECEIVER_REFUSALS.includes(data.reason)) {
49
+ return { boundary: "refused", phase: "receiver", reason: data.reason };
50
+ }
51
+ if (data.boundary === "unknown" && data.reason === "shutdown-call-failed") {
52
+ return { boundary: "unknown", reason: "shutdown-call-failed" };
53
+ }
54
+ return malformed;
55
+ }
56
+ export async function requestPiRetire(target, expected, options = {}) {
57
+ if (target.backend !== "pi") {
58
+ return { boundary: "refused", phase: "preflight", reason: "target-backend-unsupported" };
59
+ }
60
+ if (target.gardenId !== expected.gardenId ||
61
+ target.nativeSessionId !== expected.nativeSessionId ||
62
+ !SESSION_ID_RE.test(target.gardenId) ||
63
+ target.nativeSessionId.length === 0) {
64
+ return { boundary: "refused", phase: "preflight", reason: "target-identity-mismatch" };
65
+ }
66
+ const socketPath = controlSocketPathIn(options.controlSocketDir ?? defaultControlSocketDir(os.homedir()), target.gardenId);
67
+ try {
68
+ const { response } = await (options.sendRpc ?? sendRpcCommand)(socketPath, { type: "retire", gardenId: target.gardenId, nativeSessionId: target.nativeSessionId }, { timeout: options.timeoutMs ?? 5000 });
69
+ return parsePiRetireResponse(response);
70
+ }
71
+ catch {
72
+ // The RPC API does not expose whether bytes were written. Even connection errors
73
+ // are conservatively unknown here; do not infer a safe pre-send refusal or retry.
74
+ return { boundary: "unknown", reason: "transport-error" };
75
+ }
76
+ }
@@ -1,27 +1,33 @@
1
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.
2
+ * resume-launch-identity — the RECORD-AUTHORITATIVE half of a visible same-id resume: garden id →
3
+ * the meta-record → which native session is this citizen's, and where it lives.
4
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.
5
+ * It began as the preamble of the retired `spawn-bg` watcher (garden id → the meta-record → the
6
+ * transcript that record claims → the provider/model/cwd that transcript's own header carries).
7
+ * None of that is about being hidden or visible; it is the answer to "which being is this, and
8
+ * which conversation is theirs". `entwurf-v2-visible-resume` is its consumer, and
9
+ * `check-resume-launch-identity` holds the record-integrity rules below — the transcript header id
10
+ * must equal `record.nativeSessionId`, an addressable read (#52) rather than a plain targeted one,
11
+ * a recorded ACP provider must resolve its bridge.
10
12
  *
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.
13
+ * The domain is pi and claude-code (#147 L2). Both own their transcript and re-attach to the same
14
+ * record when their native session reopens; this leaf only names that session. For pi it reads
15
+ * the transcript header (the launch needs its provider/model/cwd). For claude-code it reads no
16
+ * transcript at all — the vendor owns it, and `claude --resume <id>` needs nothing from it — and
17
+ * checks only that the recorded path is an absolute file that still exists. Every other backend
18
+ * is a named refusal: native-push citizens have no resume authority (AGENTS Hard Rule 10), and a
19
+ * durable host is not reopened through Entwurf.
16
20
  *
17
21
  * It takes a bare garden id, not a plan: the plan type it used to destructure belonged to
18
22
  * the removed transport, and the identity question never needed the rest of it.
19
23
  */
20
- import { existsSync } from "node:fs";
24
+ import { existsSync, statSync } from "node:fs";
21
25
  import path from "node:path";
22
26
  import { AcpModelNotCuratedError, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
23
27
  import { getEntwurfExplicitExtensions, readSessionIdentity } from "./entwurf-core.js";
24
28
  import { readAddressableMetaIdentity } from "./meta-session.js";
29
+ /** The backends with a visible same-id resume. A closed set: nothing outside it is reopened. */
30
+ export const RESUME_BACKENDS = ["pi", "claude-code"];
25
31
  /**
26
32
  * One of the TWO expected refusals on this leaf: the target is a citizen of a backend that has no
27
33
  * same-id resume at all. The other is `AcpModelNotCuratedError` (#128, `acp/model-ids.js`).
@@ -31,23 +37,64 @@ import { readAddressableMetaIdentity } from "./meta-session.js";
31
37
  * bridge — and those keep their bare cause-rich `Error`, because a caller cannot act on them
32
38
  * except by looking. A Claude Code or agy garden id is different in kind: nothing is broken,
33
39
  * the operator simply asked a capability boundary to do something it does not cover, and the
34
- * honest answer is a named refusal rather than an error report about a store that is fine.
40
+ * honest answer is a named refusal rather than an error report about a store that is fine. Since
41
+ * #147 L2 the boundary is `RESUME_BACKENDS`; a Codex, agy, Copilot, OMP or pi-durable garden id is
42
+ * this refusal.
35
43
  *
36
44
  * It carries a `reason` so the caller matches on a FIELD. Parsing the message string would make
37
45
  * the wording load-bearing, and re-reading the record to ask "was it pi?" would read an address
38
46
  * twice and could answer differently the second time.
39
47
  */
40
48
  export class ResumeBackendUnsupportedError extends Error {
41
- reason = "target-not-pi";
49
+ reason = "target-backend-unsupported";
42
50
  backend;
43
51
  constructor(gardenId, backend) {
44
52
  super(`resume-launch-identity: ${gardenId} is a ${backend} citizen — same-id resume is a host-adapter relaunch ` +
45
- `capability, and its domain currently contains backend pi only (only pi stands a control socket up). ` +
46
- `This is a capability boundary, not the control-socket rail and not citizen rank.`);
53
+ `capability, and its domain is ${RESUME_BACKENDS.join(" and ")}. ` +
54
+ `This is a capability boundary, not citizen rank.`);
47
55
  this.name = "ResumeBackendUnsupportedError";
48
56
  this.backend = backend;
49
57
  }
50
58
  }
59
+ /**
60
+ * Claude Code's session id grammar: a lowercase UUID. The value becomes an argv token after
61
+ * `--resume`, so anything else — a leading `-`, whitespace, a path — is refused as a bad record
62
+ * rather than handed to the vendor CLI as something it would parse differently.
63
+ */
64
+ const CLAUDE_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
65
+ /** A claude-code record → its resume facts. Throws, with its own cause, on a record that cannot be resumed. */
66
+ function resolveClaudeCodeLaunchIdentity(gardenId, record) {
67
+ if (!CLAUDE_SESSION_ID.test(record.nativeSessionId)) {
68
+ throw new Error(`resume-launch-identity: ${gardenId} records nativeSessionId ${JSON.stringify(record.nativeSessionId)}, ` +
69
+ `which is not a Claude Code session id (lowercase UUID) — refusing to hand it to claude --resume.`);
70
+ }
71
+ const transcript = record.transcriptPath;
72
+ if (!transcript || !path.isAbsolute(transcript)) {
73
+ throw new Error(`resume-launch-identity: ${gardenId} records no absolute transcriptPath (${JSON.stringify(transcript)}) — ` +
74
+ `the birth hook writes one for every Claude Code session, so this record cannot name a session to resume.`);
75
+ }
76
+ let isFile = false;
77
+ try {
78
+ isFile = statSync(transcript).isFile();
79
+ }
80
+ catch {
81
+ isFile = false;
82
+ }
83
+ if (!isFile) {
84
+ throw new Error(`resume-launch-identity: ${gardenId} recorded transcriptPath "${transcript}" is not a file on disk — ` +
85
+ `the session's transcript is gone, so claude --resume would open nothing.`);
86
+ }
87
+ if (!path.isAbsolute(record.cwd)) {
88
+ throw new Error(`resume-launch-identity: ${gardenId} records a relative cwd ${JSON.stringify(record.cwd)}.`);
89
+ }
90
+ return {
91
+ backend: "claude-code",
92
+ nativeSessionId: record.nativeSessionId,
93
+ cwd: record.cwd,
94
+ createdAt: record.createdAt,
95
+ attachIncarnation: record.attachIncarnation,
96
+ };
97
+ }
51
98
  /**
52
99
  * Resolve launch identity for a resume. The TARGET is now resolved through the
53
100
  * meta-record (#50 C2): `gardenId → record.transcriptPath`. It used to be a global
@@ -79,6 +126,8 @@ export class ResumeBackendUnsupportedError extends Error {
79
126
  */
80
127
  export function resolveResumeLaunchIdentity(gardenId) {
81
128
  const record = readAddressableMetaIdentity(gardenId);
129
+ if (record.backend === "claude-code")
130
+ return resolveClaudeCodeLaunchIdentity(gardenId, record);
82
131
  if (record.backend !== "pi") {
83
132
  throw new ResumeBackendUnsupportedError(gardenId, record.backend);
84
133
  }
@@ -136,6 +185,7 @@ export function resolveResumeLaunchIdentity(gardenId) {
136
185
  if (provider === PROVIDER_ID && !isCuratedAcpModelId(model))
137
186
  throw new AcpModelNotCuratedError(model);
138
187
  return {
188
+ backend: "pi",
139
189
  sessionFile,
140
190
  cwd: identity.cwd,
141
191
  explicitExtensionArgs: explicitExtensions.args,