@junghanacs/entwurf 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/AGENTS.md +52 -17
  2. package/BASELINE.md +60 -206
  3. package/CHANGELOG.md +64 -0
  4. package/CONTRIBUTING.md +14 -10
  5. package/DELIVERY.md +118 -261
  6. package/README.md +91 -431
  7. package/VERIFY.md +74 -95
  8. package/demo/README.md +1 -1
  9. package/demo/demo-baseline.sh +1 -3
  10. package/demo/demo.sh +2 -5
  11. package/docs/acp-backend-rail.md +236 -0
  12. package/docs/external-mcp-host.md +131 -0
  13. package/docs/fresh-cut-policy.md +99 -0
  14. package/docs/setup-clean-host.md +124 -328
  15. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +160 -67
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +11 -9
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
  37. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  38. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  39. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
  40. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  41. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  42. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  43. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  44. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  45. package/mcp/entwurf-bridge/src/index.ts +176 -67
  46. package/mcp/entwurf-bridge/start.sh +2 -2
  47. package/mcp/entwurf-bridge/test.sh +23 -9
  48. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  49. package/package.json +25 -11
  50. package/pi-extensions/entwurf-control.ts +220 -71
  51. package/pi-extensions/lib/acp/acp-client.ts +22 -0
  52. package/pi-extensions/lib/acp/augment.ts +42 -3
  53. package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
  54. package/pi-extensions/lib/acp/backend.ts +366 -38
  55. package/pi-extensions/lib/acp/config.ts +1 -1
  56. package/pi-extensions/lib/acp/engraving.ts +43 -1
  57. package/pi-extensions/lib/acp/event-mapper.ts +16 -13
  58. package/pi-extensions/lib/acp/models.ts +4 -4
  59. package/pi-extensions/lib/acp/overlay.ts +11 -9
  60. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  61. package/pi-extensions/lib/entwurf-core.ts +15 -15
  62. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  63. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  64. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  65. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  66. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  67. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  68. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  69. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  70. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  71. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  72. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  73. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  74. package/pi-extensions/lib/meta-session.ts +93 -5
  75. package/pi-extensions/lib/mux-fresh-call.ts +328 -0
  76. package/pi-extensions/lib/mux-launch.ts +267 -0
  77. package/pi-extensions/lib/mux-placement.ts +387 -0
  78. package/pi-extensions/lib/mux-resume-call.ts +221 -0
  79. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  80. package/pi-extensions/lib/session-id.js +8 -5
  81. package/pi-extensions/lib/socket-discovery.ts +3 -3
  82. package/prompts/engraving.md +13 -5
  83. package/run.sh +688 -282
  84. package/scripts/agy-bridge-config.py +5 -1
  85. package/scripts/check-acp-backend-preflight.ts +1 -1
  86. package/scripts/check-acp-carrier-augment.ts +246 -10
  87. package/scripts/check-acp-cortex.ts +5 -5
  88. package/scripts/check-acp-overlay.ts +13 -3
  89. package/scripts/check-acp-prompt-lifecycle.ts +565 -0
  90. package/scripts/check-acp-stop-reason.ts +342 -0
  91. package/scripts/check-acp-stream-hooks.ts +504 -0
  92. package/scripts/check-elapsed.sh +25 -0
  93. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  94. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  95. package/scripts/check-entwurf-control-rpc.ts +4 -3
  96. package/scripts/check-entwurf-resume-args.ts +72 -70
  97. package/scripts/check-entwurf-session-identity.ts +14 -10
  98. package/scripts/check-entwurf-v2-contract.ts +34 -59
  99. package/scripts/check-entwurf-v2-decider.ts +17 -177
  100. package/scripts/check-entwurf-v2-lock.ts +5 -2
  101. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  102. package/scripts/check-entwurf-v2-production.ts +2 -91
  103. package/scripts/check-entwurf-v2-release.ts +10 -105
  104. package/scripts/check-entwurf-v2-runner.ts +4 -85
  105. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  106. package/scripts/check-entwurf-v2-send.ts +0 -28
  107. package/scripts/check-entwurf-v2-surface.ts +198 -128
  108. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  109. package/scripts/check-fresh-cut-gate.sh +1 -1
  110. package/scripts/check-gate-qualification.ts +101 -7
  111. package/scripts/check-install-container.sh +10 -2
  112. package/scripts/check-install-surface.ts +1 -1
  113. package/scripts/check-keyset-overlap.py +1 -1
  114. package/scripts/check-meta-facts.ts +249 -0
  115. package/scripts/check-meta-identity-consumers.ts +1 -1
  116. package/scripts/check-meta-session.ts +169 -0
  117. package/scripts/check-mux-launch-tmux.ts +316 -0
  118. package/scripts/check-mux-launch.ts +288 -0
  119. package/scripts/check-mux-launcher-fence.ts +264 -0
  120. package/scripts/check-mux-parent-artifact.ts +195 -0
  121. package/scripts/check-mux-placement-tmux.ts +322 -0
  122. package/scripts/check-mux-placement.ts +323 -0
  123. package/scripts/check-mux-resume-call.ts +282 -0
  124. package/scripts/check-probe-cli-shim.ts +25 -22
  125. package/scripts/check-probe-ordering.ts +121 -81
  126. package/scripts/check-release-gate-outcomes.ts +452 -0
  127. package/scripts/check-resume-launch-identity.ts +244 -0
  128. package/scripts/check-socket-discovery.ts +1 -1
  129. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  130. package/scripts/inventory-verification-surface.ts +349 -0
  131. package/scripts/lib/claude-launcher-fence.ts +322 -0
  132. package/scripts/lib/live-skip.ts +33 -0
  133. package/scripts/lib/mutation-qualify.ts +109 -3
  134. package/scripts/lib/probe-acp-turn.ts +33 -12
  135. package/scripts/lib/step-outcome.sh +88 -0
  136. package/scripts/meta-bridge-doctor.sh +6 -8
  137. package/scripts/meta-facts.ts +60 -0
  138. package/scripts/mutants/acp-augment.json +106 -0
  139. package/scripts/mutants/acp-cortex.json +2 -2
  140. package/scripts/mutants/acp-overlay.json +17 -0
  141. package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
  142. package/scripts/mutants/acp-stop-reason.json +80 -0
  143. package/scripts/mutants/acp-stream-hooks.json +158 -0
  144. package/scripts/mutants/bridge-boot-resume.json +45 -0
  145. package/scripts/mutants/meta-facts.json +50 -0
  146. package/scripts/mutants/meta-identity.json +36 -0
  147. package/scripts/mutants/mux-boundary.json +196 -0
  148. package/scripts/mutants/mux-fresh-call.json +185 -0
  149. package/scripts/mutants/mux-launcher-fence.json +123 -0
  150. package/scripts/mutants/mux-parent-artifact.json +39 -0
  151. package/scripts/mutants/mux-resume-call.json +148 -0
  152. package/scripts/mutants/probe-ordering.json +6 -1021
  153. package/scripts/mutants/release-gate.json +140 -0
  154. package/scripts/mutants/resume-args.json +76 -0
  155. package/scripts/mutants/resume-launch-identity.json +96 -0
  156. package/scripts/mutants/v2-surface.json +74 -23
  157. package/scripts/mutants/v2-visible-resume.json +215 -0
  158. package/scripts/new-session-id.ts +9 -4
  159. package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
  160. package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
  161. package/scripts/smoke-acp-cortex-live.ts +8 -14
  162. package/scripts/smoke-acp-long-turn-live.ts +185 -0
  163. package/scripts/smoke-acp-mcp-live.ts +2 -2
  164. package/scripts/smoke-acp-memory-containment-live.ts +2 -2
  165. package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
  166. package/scripts/smoke-acp-overlay-live.ts +2 -2
  167. package/scripts/smoke-acp-provider-live.ts +2 -2
  168. package/scripts/smoke-acp-raw-turn-live.ts +3 -3
  169. package/scripts/smoke-acp-session-reuse-live.ts +2 -2
  170. package/scripts/smoke-acp-skill-live.ts +2 -2
  171. package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
  172. package/scripts/smoke-acp-v2-send-live.ts +4 -3
  173. package/scripts/smoke-agy-native-push-live.ts +10 -20
  174. package/scripts/smoke-claude-native-resume-live.sh +13 -3
  175. package/scripts/smoke-entwurf-chain-live.ts +352 -0
  176. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -3
  177. package/scripts/smoke-meta-honesty.sh +1 -1
  178. package/scripts/smoke-meta-install-state.sh +4 -0
  179. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  180. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  181. package/scripts/smoke-pi-attach.ts +1 -1
  182. package/scripts/smoke-user-scope-citizen.sh +1 -1
  183. package/scripts/tsconfig.json +1 -0
  184. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  185. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  186. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  187. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  188. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  189. package/scripts/check-acp-sdk-surface.ts +0 -236
  190. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  191. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  192. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  193. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -469
@@ -514,7 +514,7 @@ export function parseMetaIdentity(json: string): MetaIdentity {
514
514
  // `wakeMode` describes the last centimetre, not citizen rank. The control-socket
515
515
  // adapter injects the body into a pi turn, so backend `pi` is `direct-inject`;
516
516
  // Claude's mailbox is `self-fetch`; native-push adapters are direct injection.
517
- // Dormant spawn-bg resume is a separate transport/ownership decision and does not
517
+ // Resuming a dormant citizen is a separate lifecycle capability and does not
518
518
  // turn the pi record into a mailbox citizen.
519
519
  // ---------------------------------------------------------------------------
520
520
 
@@ -1090,6 +1090,22 @@ export function listAllMetaIdentities(
1090
1090
  return { identities, errors };
1091
1091
  }
1092
1092
 
1093
+ /**
1094
+ * The fs-bound listing — the same binding shape {@link certifyActiveStoreDir} gives the
1095
+ * certification: one resolved dir, kind-carrying entries, the fd-guarded record reader.
1096
+ * Exists so an in-repo facts surface (`meta-facts`, #65) reuses THE listing contract
1097
+ * instead of re-binding readdir/read by hand — the exact drift that made external
1098
+ * consumers carry a decaying copy of the certification. No semantics of its own:
1099
+ * everything it returns is {@link listAllMetaIdentities} over the real store.
1100
+ */
1101
+ export function listAllMetaIdentitiesDir(dir: string): ListIdentitiesResult & { dir: string } {
1102
+ const resolved = path.resolve(expandTilde(dir));
1103
+ return {
1104
+ dir: resolved,
1105
+ ...listAllMetaIdentities(readActiveStoreEntries(resolved), makeStoreRecordReader(resolved)),
1106
+ };
1107
+ }
1108
+
1093
1109
  export type UpsertAction = "create" | "attach";
1094
1110
 
1095
1111
  export interface UpsertDecision {
@@ -1747,8 +1763,13 @@ export interface UpsertMetaSessionResult {
1747
1763
  * A narrower scan is not a smaller version of this: asking only about MY
1748
1764
  * `nativeSessionId` writes happily beside a drifted or duplicated record that the
1749
1765
  * doctor refuses, which is how a host ends up certified by one surface and not
1750
- * the other. The write is tmp-file + rename so a crash never leaves a
1751
- * half-written record (#30 crash-safety).
1766
+ * the other.
1767
+ *
1768
+ * The write is split by ACTION (#66): ATTACH rewrites its OWN record atomically
1769
+ * in place (tmp + rename, #30 crash-safety); CREATE publishes exclusively
1770
+ * (tmp + link) and fails loud if the minted garden id's final path already
1771
+ * exists — certification cannot see a future minted gid, so replacement-free
1772
+ * publish is the only collision safety a create has.
1752
1773
  */
1753
1774
  export function upsertMetaSession(opts: UpsertMetaSessionOptions): UpsertMetaSessionResult {
1754
1775
  const dir = path.resolve(expandTilde(opts.dir ?? defaultMetaSessionsDir()));
@@ -1759,17 +1780,84 @@ export function upsertMetaSession(opts: UpsertMetaSessionOptions): UpsertMetaSes
1759
1780
  const existing = cert.records.find((record) => record.identity.nativeSessionId === target)?.identity ?? null;
1760
1781
  const decision = decideUpsert(existing, opts.input, opts.now);
1761
1782
  const file = path.join(dir, metaRecordFilename(decision.record));
1762
- atomicWriteIdentity(file, decision.record);
1783
+ if (decision.action === "create") publishExclusiveIdentity(file, decision.record);
1784
+ else atomicWriteIdentity(file, decision.record);
1763
1785
  return { action: decision.action, record: decision.record, dir, path: file };
1764
1786
  }
1765
1787
 
1766
- /** tmp-file + rename so a crash never leaves a half-written record (v3 identity write). */
1788
+ /**
1789
+ * ATTACH replace primitive: tmp-file + rename so a crash never leaves a
1790
+ * half-written record (#30 crash-safety). rename(2) REPLACES an existing final
1791
+ * path, which is exactly right for attach (the record being rewritten is the
1792
+ * caller's own) and exactly wrong for create — a freshly minted garden id must
1793
+ * never land on this path (#66).
1794
+ */
1767
1795
  function atomicWriteIdentity(file: string, identity: MetaIdentity): void {
1768
1796
  const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
1769
1797
  fs.writeFileSync(tmp, serializeMetaIdentity(identity), { mode: 0o600 });
1770
1798
  fs.renameSync(tmp, file);
1771
1799
  }
1772
1800
 
1801
+ /**
1802
+ * CREATE publish primitive: same-directory tmp + `link(2)` to the final path.
1803
+ * link fails with EEXIST when the final path already exists — the kernel makes
1804
+ * the existence check and the publish one atomic step, so there is no
1805
+ * check-then-rename TOCTOU window and a same-second 24-bit garden-id suffix
1806
+ * collision can never silently replace whatever already holds the final path
1807
+ * (#66). The occupied entry's bytes are untouched on refusal; tmp cleanup is
1808
+ * attempted on every path, and a published-with-residue state fails loud.
1809
+ *
1810
+ * @internal Exported only as the production subject for the meta-session gate's
1811
+ * deterministic collision oracle (the minted suffix is random, so the public
1812
+ * upsert seam cannot force a collision deterministically). Not an
1813
+ * operator/package API.
1814
+ */
1815
+ export function publishExclusiveIdentity(file: string, identity: MetaIdentity): void {
1816
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
1817
+ fs.writeFileSync(tmp, serializeMetaIdentity(identity), { mode: 0o600 });
1818
+ try {
1819
+ fs.linkSync(tmp, file);
1820
+ } catch (err) {
1821
+ try {
1822
+ fs.unlinkSync(tmp);
1823
+ } catch (cleanupErr) {
1824
+ // The primary link error stays the truth of this publish; a cleanup errno
1825
+ // never masks it. ENOENT is silent (no residue = desired state); any other
1826
+ // cleanup failure is an operator diagnostic on stderr, then the primary throws.
1827
+ if ((cleanupErr as { code?: unknown }).code !== "ENOENT") {
1828
+ process.stderr.write(
1829
+ `[meta-session] tmp cleanup after a failed CREATE publish also failed (${tmp}): ` +
1830
+ `${(cleanupErr as Error).message}\n`,
1831
+ );
1832
+ }
1833
+ }
1834
+ if ((err as { code?: unknown }).code === "EEXIST") {
1835
+ throw new MetaRecordError(
1836
+ `meta-record CREATE collision: garden id "${identity.gardenId}" already has an entry at ` +
1837
+ `${file}. Exclusive publish refused to replace it — the occupied final-path entry was ` +
1838
+ `not modified and no ACTIVE record was published for nativeSessionId ` +
1839
+ `"${identity.nativeSessionId}".`,
1840
+ );
1841
+ }
1842
+ throw err;
1843
+ }
1844
+ try {
1845
+ fs.unlinkSync(tmp);
1846
+ } catch (err) {
1847
+ // ENOENT is the goal state, not a failure: the record is published and no
1848
+ // tmp remains, so there is nothing left to clean.
1849
+ if ((err as { code?: unknown }).code === "ENOENT") return;
1850
+ // Never roll the final back: the record IS published (link succeeded).
1851
+ throw new MetaRecordError(
1852
+ `meta-record CREATE for garden id "${identity.gardenId}" is already published at ${file}, ` +
1853
+ `but temp cleanup failed (${tmp}): ${(err as Error).message}. The caller aborts here, so ` +
1854
+ `it received no successful birth/registration receipt; remove the temp file and retry — ` +
1855
+ `a retry with the same nativeSessionId will ATTACH to the published record, not mint a ` +
1856
+ `second identity.`,
1857
+ );
1858
+ }
1859
+ }
1860
+
1773
1861
  // ---------------------------------------------------------------------------
1774
1862
  // Mailbox delivery (step 6) — addressed by GARDEN ID. The record store is the
1775
1863
  // authority (a sender may only deliver to a known garden citizen); the mailbox
@@ -0,0 +1,328 @@
1
+ /**
2
+ * mux-fresh-call — open ONE visible sibling in the caller's own tmux session, hand it its first
3
+ * task in the launch argv, and let it name itself back to the caller.
4
+ *
5
+ * ── Why this is a third module and not a parameter on the leaf ──
6
+ *
7
+ * `mux-placement` owns place and only place; `mux-launch` owns "start the fixed official pi in
8
+ * a window I placed" and refuses a command carrier (docs §11). Both stay that way. The carrier
9
+ * lives HERE, one layer up, because the thing being carried is not a command in the shell sense
10
+ * — it is a TASK plus the instruction that makes the new citizen announce itself. Putting it on
11
+ * the leaf would make every future placement caller inherit a prompt argument it has no use for.
12
+ *
13
+ * caller agent → fresh-call → fixed backend runtime + first-turn framing
14
+ * fresh-call → placement leaf (unchanged, carrier-free)
15
+ * fresh-call -X-> identity minting, delivery transport, task planning
16
+ *
17
+ * ── Invariants ──
18
+ *
19
+ * 1. The first turn does not MINT the garden id — record and socket exist from session start.
20
+ * It exists to SAY that id to the caller, who is the one who cannot see it. (rail §6-a)
21
+ * 2. Correlation is what the DELIVERY layer stamps on the callback, never what the sibling
22
+ * says about itself — asked directly, a fresh cell answers with a uuidv7. (rail §6-b)
23
+ * 3. The two receipts are separate objects and stay that way: `freshCall` returns tmux
24
+ * coordinates synchronously and nothing else; the correlation receipt arrives later on the
25
+ * caller's own inbound surface. Merging them would claim knowledge this module cannot have.
26
+ * 4. A launch with no callback is a REAL outcome, not an error to retry. No watcher, no poll,
27
+ * no timeout supervisor. The window is visible; the operator can look.
28
+ */
29
+
30
+ import { randomBytes } from "node:crypto";
31
+ import {
32
+ assertLaunchTarget,
33
+ LaunchPreconditionError,
34
+ type LaunchRejectReason,
35
+ resolveRuntimeOnPath,
36
+ } from "./mux-launch.ts";
37
+ import {
38
+ APPEND_FORMAT,
39
+ assertSelector,
40
+ assertTmuxOk,
41
+ inspectPlacement,
42
+ type Placement,
43
+ type PlacementRejectReason,
44
+ parseWindowFields,
45
+ requireSameContext,
46
+ runTmux,
47
+ type WindowHandle,
48
+ } from "./mux-placement.ts";
49
+
50
+ /** The two backends this rail can open. Fixed set, not a profile — a third one is a decision,
51
+ * not a config entry. */
52
+ export const FRESH_CALL_BACKENDS = ["pi", "claude-code"] as const;
53
+ export type FreshCallBackend = (typeof FRESH_CALL_BACKENDS)[number];
54
+
55
+ /** The fixed runtime each backend resolves on PATH. Same reason `mux-launch` uses PATH rather
56
+ * than a compiled-in location: the official binary is whatever the operator's environment gives
57
+ * when they type the name. */
58
+ export const FRESH_CALL_RUNTIME: Record<FreshCallBackend, string> = {
59
+ pi: "pi",
60
+ "claude-code": "claude",
61
+ };
62
+
63
+ /**
64
+ * The callback tool NAME differs per backend and that is not cosmetic: native pi exposes the
65
+ * capability directly (`entwurf_v2`), while a Claude Code session reaches it through the MCP
66
+ * bridge under its namespaced name. Naming the wrong one costs the whole first turn.
67
+ */
68
+ export const FRESH_CALL_CALLBACK_TOOL: Record<FreshCallBackend, string> = {
69
+ pi: "entwurf_v2",
70
+ "claude-code": "mcp__entwurf-bridge__entwurf_v2",
71
+ };
72
+
73
+ /** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
74
+ * delivery surface, not a claim that a task of this size was measured through tmux. An argv
75
+ * that the OS refuses is a launch failure and fails loud — it never reads as a delivered task. */
76
+ export const TASK_MAX_CHARS = 16000;
77
+ export const MODEL_MAX_CHARS = 200;
78
+ const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:\[\]-]*$/;
79
+
80
+ /** A model is an explicit launch input, not ambient process state. The grammar admits canonical
81
+ * pi provider/model ids, Claude model ids/aliases, and bracketed context variants, while refusing
82
+ * whitespace and tmux control syntax. It is passed without a shell using each runtime's measured
83
+ * CLI dialect: Pi takes `--model`, value; Claude Code takes `--model=value`. */
84
+ export function isSafeFreshCallModel(model: string): boolean {
85
+ return model.length > 0 && model.length <= MODEL_MAX_CHARS && MODEL_PATTERN.test(model);
86
+ }
87
+
88
+ /**
89
+ * Backend argv AFTER the runtime path. Both orders are MEASURED, and both were measured by
90
+ * getting them wrong first (rail §6-a):
91
+ *
92
+ * pi — prompt BEFORE `--entwurf-control`, then `--model`, value as TWO tokens.
93
+ * Flag-first submitted no message; Pi rejects the equals form for `--model`.
94
+ * claude-code — prompt, then `--allowedTools=` and `--model=` as ONE token each. The space form
95
+ * for allowedTools is variadic and eats the prompt as an option value.
96
+ *
97
+ * Both failures looked identical from outside: window open, record and socket minted, no turn.
98
+ *
99
+ * The equals form is NOT a permission guarantee — on the measured host the tool was already
100
+ * permitted, so the option's effect was unobservable. What was observed is that it does no harm
101
+ * to the argv. Permission stays a documented host precondition.
102
+ */
103
+ export function buildBackendArgs(backend: FreshCallBackend, prompt: string, model: string): string[] {
104
+ switch (backend) {
105
+ case "pi":
106
+ return [prompt, "--entwurf-control", "--model", model];
107
+ case "claude-code":
108
+ return [prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
109
+ }
110
+ }
111
+
112
+ /**
113
+ * The first-turn framing. Order is the contract: the callback is the FIRST action and the task
114
+ * follows it, so a sibling that gets stuck in the task has already told the caller who it is.
115
+ *
116
+ * The three prohibitions are not politeness. Each names a detour that was measured to produce a
117
+ * confidently wrong answer or a wasted turn.
118
+ */
119
+ export function buildFreshCallPrompt(params: {
120
+ backend: FreshCallBackend;
121
+ task: string;
122
+ callerGardenId: string;
123
+ nonce: string;
124
+ }): string {
125
+ const tool = FRESH_CALL_CALLBACK_TOOL[params.backend];
126
+ return [
127
+ "You are a fresh visible citizen that entwurf opened in the operator's tmux session.",
128
+ "",
129
+ `FIRST ACTION, before reading files or anything else: call ${tool} with ` +
130
+ `target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
131
+ `message set to exactly ${params.nonce} — that string alone, nothing added.`,
132
+ "That call is how the agent that opened you learns your address. Do not skip it, do not",
133
+ "defer it until the task is done, and do not reword the message.",
134
+ "",
135
+ "Do not inspect environment variables, do not call entwurf_self, and do not start an MCP",
136
+ "server yourself. Your own report of your identity is not the address anyone needs.",
137
+ "",
138
+ "After the tool receipt, carry out this task:",
139
+ "",
140
+ params.task,
141
+ ].join("\n");
142
+ }
143
+
144
+ /** A launch that was refused, or a placement that could not be established. Every value is a
145
+ * NAMED refusal — this module has no fallback launch. */
146
+ export type FreshCallRejectReason =
147
+ | PlacementRejectReason
148
+ | LaunchRejectReason
149
+ | "caller-identity-unavailable"
150
+ | "model-empty"
151
+ | "model-invalid"
152
+ | "task-empty"
153
+ | "task-too-long";
154
+
155
+ /** Coordinates plus what was handed to tmux. Read `runtimePath` as "what we asked to start".
156
+ * There is deliberately NO field here for the callback, the nonce's arrival, or the sibling's
157
+ * garden id — see the module header. */
158
+ export interface FreshCallReceipt extends WindowHandle {
159
+ backend: FreshCallBackend;
160
+ model: string;
161
+ runtimePath: string;
162
+ nonce: string;
163
+ }
164
+
165
+ export type FreshCallResult = { ok: true; receipt: FreshCallReceipt } | { ok: false; reason: FreshCallRejectReason };
166
+
167
+ /** Correlation tag only. Random, never derived from time, cwd or a peer listing — a nonce that
168
+ * encoded any of those would invite exactly the guessing this rail exists to refuse. */
169
+ export function mintNonce(randomHex: () => string = defaultRandomHex): string {
170
+ return `mux-fresh-call-${randomHex()}`;
171
+ }
172
+
173
+ function defaultRandomHex(): string {
174
+ return randomBytes(12).toString("hex");
175
+ }
176
+
177
+ /** Launch argv: the leaf's detached-append shape, the runtime, then the backend's dialect. */
178
+ export function buildFreshCallArgs(
179
+ placement: Placement,
180
+ runtimePath: string,
181
+ backendArgs: readonly string[],
182
+ ): string[] {
183
+ assertSelector("session", placement.sessionId);
184
+ assertLaunchTarget(runtimePath);
185
+ return [
186
+ "new-window",
187
+ "-d",
188
+ "-a",
189
+ "-t",
190
+ `${placement.sessionId}:{end}`,
191
+ "-P",
192
+ "-F",
193
+ APPEND_FORMAT,
194
+ "--",
195
+ runtimePath,
196
+ ...backendArgs,
197
+ ];
198
+ }
199
+
200
+ /**
201
+ * Open the sibling. Order is the safety argument, same as `launchPi`: validate the caller's
202
+ * identity and task, resolve and prove the runtime, re-read the caller's placement and refuse a
203
+ * changed context — and only then mutate. Nothing above can leave a window behind.
204
+ *
205
+ * `callerGardenId` is supplied by the SURFACE that registered this tool, from its own
206
+ * record-backed context. It is not a tool parameter and this module never derives, validates
207
+ * against a store, or guesses it: an empty value is a named refusal, not a lookup.
208
+ */
209
+ export function freshCall(
210
+ params: { backend: FreshCallBackend; model: string; task: string; callerGardenId: string | null },
211
+ env: NodeJS.ProcessEnv = process.env,
212
+ nonce: string = mintNonce(),
213
+ ): FreshCallResult {
214
+ if (typeof params.callerGardenId !== "string" || params.callerGardenId.length === 0) {
215
+ return { ok: false, reason: "caller-identity-unavailable" };
216
+ }
217
+ const model = params.model.trim();
218
+ if (model.length === 0) return { ok: false, reason: "model-empty" };
219
+ if (!isSafeFreshCallModel(model)) return { ok: false, reason: "model-invalid" };
220
+ const task = params.task.trim();
221
+ if (task.length === 0) return { ok: false, reason: "task-empty" };
222
+ if (task.length > TASK_MAX_CHARS) return { ok: false, reason: "task-too-long" };
223
+
224
+ let runtimePath: string;
225
+ try {
226
+ runtimePath = resolveRuntimeOnPath(FRESH_CALL_RUNTIME[params.backend], env);
227
+ } catch (err) {
228
+ if (err instanceof LaunchPreconditionError) return { ok: false, reason: err.reason };
229
+ throw err;
230
+ }
231
+
232
+ const inspected = inspectPlacement(env);
233
+ if (!inspected.ok) return { ok: false, reason: inspected.reason };
234
+ const placement = inspected.placement;
235
+ requireSameContext("freshCall", placement, env);
236
+
237
+ const prompt = buildFreshCallPrompt({
238
+ backend: params.backend,
239
+ task,
240
+ callerGardenId: params.callerGardenId,
241
+ nonce,
242
+ });
243
+ const run = runTmux(buildFreshCallArgs(placement, runtimePath, buildBackendArgs(params.backend, prompt, model)), env);
244
+ assertTmuxOk("new-window", run);
245
+
246
+ let fields: ReturnType<typeof parseWindowFields>;
247
+ try {
248
+ fields = parseWindowFields(run.stdout);
249
+ } catch (err) {
250
+ // The window exists and its id is precisely what could not be read. Diffing the inventory
251
+ // to find "the new one" is the guess this rail forbids everywhere else, so name the orphan.
252
+ throw new Error(
253
+ `mux-fresh-call: launched ${runtimePath} but could not read the window handle tmux printed — a window may ` +
254
+ `be open in session ${placement.sessionId} that this call cannot identify or close: ${
255
+ err instanceof Error ? err.message : String(err)
256
+ }`,
257
+ );
258
+ }
259
+
260
+ return {
261
+ ok: true,
262
+ receipt: {
263
+ serverPid: placement.serverPid,
264
+ sessionId: placement.sessionId,
265
+ ...fields,
266
+ backend: params.backend,
267
+ model,
268
+ runtimePath,
269
+ nonce,
270
+ },
271
+ };
272
+ }
273
+
274
+ /** Why each refusal happened, in the caller's terms. A reason a caller cannot act on is a reason
275
+ * they will guess about. */
276
+ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
277
+ "no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
278
+ "anchor-malformed": "TMUX_PANE is not a native pane id",
279
+ "anchor-unresolved": "tmux resolved no pane for this agent's anchor",
280
+ "anchor-mismatch": "tmux answered about a different pane than the one asked about",
281
+ "caller-identity-unavailable":
282
+ "this surface has no record-backed garden id for the caller, so the sibling would have no address to call back to",
283
+ "model-empty": "model is empty after trimming; fresh calls require an explicit model",
284
+ "model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
285
+ "task-empty": "task is empty after trimming",
286
+ "task-too-long": `task exceeds ${TASK_MAX_CHARS} characters`,
287
+ "runtime-unresolved": "the backend's runtime is not installed on PATH",
288
+ "runtime-not-absolute": "the resolved runtime path is not absolute",
289
+ "runtime-path-whitespace": "the resolved runtime path contains whitespace, which tmux would re-split",
290
+ "runtime-missing": "nothing exists at the resolved runtime path",
291
+ "runtime-not-regular-file": "the resolved runtime path is not a regular file",
292
+ "runtime-not-executable": "the resolved runtime path is not executable",
293
+ };
294
+
295
+ /**
296
+ * ONE renderer for both surfaces. Not a convenience: the two registrations are separate literals
297
+ * (that is this repo's shape), so a shared renderer is what keeps the operator-visible answer
298
+ * from drifting apart between native pi and the MCP bridge.
299
+ *
300
+ * The success text states the boundary out loud. A caller that reads "launched" and assumes
301
+ * "delivered" is the exact confusion the two-receipt split exists to prevent, so the text refuses
302
+ * to imply it.
303
+ */
304
+ export function renderFreshCall(result: FreshCallResult): { text: string; isError: boolean } {
305
+ if (!result.ok) {
306
+ return {
307
+ text: `entwurf_fresh_call rejected: ${result.reason} — ${REJECT_HINT[result.reason]}. No window was opened.`,
308
+ isError: true,
309
+ };
310
+ }
311
+ const r = result.receipt;
312
+ return {
313
+ text:
314
+ `[entwurf fresh call →]\n` +
315
+ ` backend: ${r.backend} (${r.runtimePath})\n` +
316
+ ` model: ${r.model} (requested on the runtime CLI)\n` +
317
+ ` window: ${r.windowId} (index ${r.windowIndex}) in session ${r.sessionId}\n` +
318
+ ` pane: ${r.paneId} pid ${r.panePid}\n` +
319
+ ` nonce: ${r.nonce}\n` +
320
+ `\n` +
321
+ `This is a LAUNCH receipt: tmux created that window and was asked to start the runtime with the model above. It does ` +
322
+ `NOT mean the sibling is running, that its first turn ran, or that the task was delivered.\n` +
323
+ `The sibling's garden id arrives separately — it calls entwurf_v2 back with the nonce above as its ` +
324
+ `first action, and the sender envelope of THAT message is the address. Nothing is polling for it; ` +
325
+ `if it never comes, the window is visible and can be read directly.`,
326
+ isError: false,
327
+ };
328
+ }