@junghanacs/entwurf 0.13.1 → 0.14.1

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 (158) hide show
  1. package/AGENTS.md +59 -15
  2. package/BASELINE.md +3 -3
  3. package/CHANGELOG.md +74 -0
  4. package/CONTRIBUTING.md +13 -9
  5. package/DELIVERY.md +7 -6
  6. package/README.md +37 -28
  7. package/VERIFY.md +22 -14
  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 +9 -4
  12. package/docs/external-mcp-host.md +4 -5
  13. package/docs/setup-clean-host.md +8 -7
  14. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +155 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/classify-tmux-cwd.js +47 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +300 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +141 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  37. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  38. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  39. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  40. package/mcp/entwurf-bridge/src/index.ts +173 -28
  41. package/mcp/entwurf-bridge/start.sh +2 -2
  42. package/mcp/entwurf-bridge/test.sh +23 -9
  43. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  44. package/package.json +22 -11
  45. package/pi-extensions/entwurf-control.ts +227 -40
  46. package/pi-extensions/lib/acp/backend.ts +71 -12
  47. package/pi-extensions/lib/acp/overlay.ts +8 -6
  48. package/pi-extensions/lib/classify-tmux-cwd.ts +50 -0
  49. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  50. package/pi-extensions/lib/entwurf-core.ts +15 -15
  51. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  52. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  53. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  54. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  55. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  56. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  57. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  58. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  59. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  60. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  61. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  62. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  63. package/pi-extensions/lib/meta-session.ts +93 -5
  64. package/pi-extensions/lib/mux-fresh-call.ts +381 -0
  65. package/pi-extensions/lib/mux-launch.ts +267 -0
  66. package/pi-extensions/lib/mux-placement.ts +387 -0
  67. package/pi-extensions/lib/mux-resume-call.ts +189 -0
  68. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  69. package/pi-extensions/lib/session-id.js +8 -5
  70. package/pi-extensions/lib/socket-discovery.ts +3 -3
  71. package/run.sh +471 -201
  72. package/scripts/agy-bridge-config.py +5 -1
  73. package/scripts/check-acp-backend-preflight.ts +1 -1
  74. package/scripts/check-acp-overlay.ts +13 -3
  75. package/scripts/check-acp-stream-hooks.ts +504 -0
  76. package/scripts/check-elapsed.sh +25 -0
  77. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  78. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  79. package/scripts/check-entwurf-control-rpc.ts +4 -3
  80. package/scripts/check-entwurf-resume-args.ts +72 -70
  81. package/scripts/check-entwurf-session-identity.ts +14 -10
  82. package/scripts/check-entwurf-v2-contract.ts +34 -59
  83. package/scripts/check-entwurf-v2-decider.ts +17 -177
  84. package/scripts/check-entwurf-v2-lock.ts +5 -2
  85. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  86. package/scripts/check-entwurf-v2-production.ts +2 -91
  87. package/scripts/check-entwurf-v2-release.ts +10 -105
  88. package/scripts/check-entwurf-v2-runner.ts +4 -85
  89. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  90. package/scripts/check-entwurf-v2-send.ts +0 -28
  91. package/scripts/check-entwurf-v2-surface.ts +157 -128
  92. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  93. package/scripts/check-fresh-cut-gate.sh +1 -1
  94. package/scripts/check-gate-qualification.ts +99 -7
  95. package/scripts/check-install-container.sh +10 -2
  96. package/scripts/check-install-surface.ts +1 -1
  97. package/scripts/check-keyset-overlap.py +1 -1
  98. package/scripts/check-meta-facts.ts +249 -0
  99. package/scripts/check-meta-identity-consumers.ts +1 -1
  100. package/scripts/check-meta-session.ts +169 -0
  101. package/scripts/check-mux-launch-tmux.ts +316 -0
  102. package/scripts/check-mux-launch.ts +288 -0
  103. package/scripts/check-mux-launcher-fence.ts +264 -0
  104. package/scripts/check-mux-parent-artifact.ts +195 -0
  105. package/scripts/check-mux-placement-tmux.ts +322 -0
  106. package/scripts/check-mux-placement.ts +323 -0
  107. package/scripts/check-mux-resume-call.ts +283 -0
  108. package/scripts/check-probe-cli-shim.ts +25 -22
  109. package/scripts/check-probe-ordering.ts +84 -76
  110. package/scripts/check-release-gate-outcomes.ts +127 -7
  111. package/scripts/check-resume-launch-identity.ts +244 -0
  112. package/scripts/check-socket-discovery.ts +1 -1
  113. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  114. package/scripts/inventory-verification-surface.ts +349 -0
  115. package/scripts/lib/claude-launcher-fence.ts +322 -0
  116. package/scripts/lib/mutation-qualify.ts +109 -3
  117. package/scripts/meta-bridge-doctor.sh +6 -8
  118. package/scripts/meta-bridge-state.py +75 -1
  119. package/scripts/meta-facts.ts +60 -0
  120. package/scripts/mutants/acp-overlay.json +17 -0
  121. package/scripts/mutants/acp-stream-hooks.json +158 -0
  122. package/scripts/mutants/bridge-boot-resume.json +45 -0
  123. package/scripts/mutants/meta-facts.json +50 -0
  124. package/scripts/mutants/meta-identity.json +36 -0
  125. package/scripts/mutants/meta-retire.json +47 -0
  126. package/scripts/mutants/mux-boundary.json +196 -0
  127. package/scripts/mutants/mux-fresh-call.json +229 -0
  128. package/scripts/mutants/mux-launcher-fence.json +123 -0
  129. package/scripts/mutants/mux-parent-artifact.json +39 -0
  130. package/scripts/mutants/mux-resume-call.json +148 -0
  131. package/scripts/mutants/probe-ordering.json +0 -1037
  132. package/scripts/mutants/release-gate.json +35 -0
  133. package/scripts/mutants/resume-args.json +76 -0
  134. package/scripts/mutants/resume-launch-identity.json +96 -0
  135. package/scripts/mutants/v2-surface.json +58 -18
  136. package/scripts/mutants/v2-visible-resume.json +215 -0
  137. package/scripts/new-session-id.ts +9 -4
  138. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  139. package/scripts/smoke-agy-native-push-live.ts +6 -17
  140. package/scripts/smoke-entwurf-chain-live.ts +11 -3
  141. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  142. package/scripts/smoke-meta-honesty.sh +1 -1
  143. package/scripts/smoke-meta-install-state.sh +169 -3
  144. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  145. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  146. package/scripts/smoke-pi-attach.ts +1 -1
  147. package/scripts/smoke-user-scope-citizen.sh +1 -1
  148. package/scripts/tsconfig.json +1 -0
  149. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  150. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  151. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  152. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  153. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  154. package/scripts/check-acp-sdk-surface.ts +0 -275
  155. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  156. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  157. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  158. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
@@ -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,381 @@
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
+ * ── The optional REQUESTED cwd (issue #73) ──
30
+ *
31
+ * A fresh sibling starts wherever the caller happens to be — unless the caller names ONE
32
+ * literal start directory. That input exists so a cross-repo fresh consultation never has to
33
+ * ride `entwurf_resume_call` for a dormant record's recorded cwd: resume stays a continuity
34
+ * verb, and placement pressure stays here. The rules are deliberately narrow:
35
+ *
36
+ * - `undefined` and the exact empty string mean OMIT: no `-c` reaches tmux and the argv is
37
+ * byte-identical to the pre-#73 shape. Anything else is taken LITERALLY — no trim, no
38
+ * realpath, no project-name resolution, no store/peers/record lookup. The caller is the
39
+ * only cwd authority this module knows.
40
+ * - the value is classified by the shared `classify-tmux-cwd.ts` leaf BEFORE any mutation
41
+ * (same four stable reasons as resume; the measured tmux 3.6a facts live on that leaf).
42
+ * This module's hints phrase them as the REQUESTED cwd; resume's say RECORDED.
43
+ * - the receipt echoes what was REQUESTED, exactly as `runtimePath` does. It never reports
44
+ * `pane_current_path`: proving where the pane actually landed belongs to acceptance, not
45
+ * to the launch receipt.
46
+ */
47
+
48
+ import { randomBytes } from "node:crypto";
49
+ import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
50
+ import {
51
+ assertLaunchTarget,
52
+ LaunchPreconditionError,
53
+ type LaunchRejectReason,
54
+ resolveRuntimeOnPath,
55
+ } from "./mux-launch.ts";
56
+ import {
57
+ APPEND_FORMAT,
58
+ assertSelector,
59
+ assertTmuxOk,
60
+ inspectPlacement,
61
+ type Placement,
62
+ type PlacementRejectReason,
63
+ parseWindowFields,
64
+ requireSameContext,
65
+ runTmux,
66
+ type WindowHandle,
67
+ } from "./mux-placement.ts";
68
+
69
+ /** The two backends this rail can open. Fixed set, not a profile — a third one is a decision,
70
+ * not a config entry. */
71
+ export const FRESH_CALL_BACKENDS = ["pi", "claude-code"] as const;
72
+ export type FreshCallBackend = (typeof FRESH_CALL_BACKENDS)[number];
73
+
74
+ /** The fixed runtime each backend resolves on PATH. Same reason `mux-launch` uses PATH rather
75
+ * than a compiled-in location: the official binary is whatever the operator's environment gives
76
+ * when they type the name. */
77
+ export const FRESH_CALL_RUNTIME: Record<FreshCallBackend, string> = {
78
+ pi: "pi",
79
+ "claude-code": "claude",
80
+ };
81
+
82
+ /**
83
+ * The callback tool NAME differs per backend and that is not cosmetic: native pi exposes the
84
+ * capability directly (`entwurf_v2`), while a Claude Code session reaches it through the MCP
85
+ * bridge under its namespaced name. Naming the wrong one costs the whole first turn.
86
+ */
87
+ export const FRESH_CALL_CALLBACK_TOOL: Record<FreshCallBackend, string> = {
88
+ pi: "entwurf_v2",
89
+ "claude-code": "mcp__entwurf-bridge__entwurf_v2",
90
+ };
91
+
92
+ /** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
93
+ * delivery surface, not a claim that a task of this size was measured through tmux. An argv
94
+ * that the OS refuses is a launch failure and fails loud — it never reads as a delivered task. */
95
+ export const TASK_MAX_CHARS = 16000;
96
+ export const MODEL_MAX_CHARS = 200;
97
+ const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:\[\]-]*$/;
98
+
99
+ /** A model is an explicit launch input, not ambient process state. The grammar admits canonical
100
+ * pi provider/model ids, Claude model ids/aliases, and bracketed context variants, while refusing
101
+ * whitespace and tmux control syntax. It is passed without a shell using each runtime's measured
102
+ * CLI dialect: Pi takes `--model`, value; Claude Code takes `--model=value`. */
103
+ export function isSafeFreshCallModel(model: string): boolean {
104
+ return model.length > 0 && model.length <= MODEL_MAX_CHARS && MODEL_PATTERN.test(model);
105
+ }
106
+
107
+ /**
108
+ * Backend argv AFTER the runtime path. Both orders are MEASURED, and both were measured by
109
+ * getting them wrong first (rail §6-a):
110
+ *
111
+ * pi — prompt BEFORE `--entwurf-control`, then `--model`, value as TWO tokens.
112
+ * Flag-first submitted no message; Pi rejects the equals form for `--model`.
113
+ * claude-code — prompt, then `--allowedTools=` and `--model=` as ONE token each. The space form
114
+ * for allowedTools is variadic and eats the prompt as an option value.
115
+ *
116
+ * Both failures looked identical from outside: window open, record and socket minted, no turn.
117
+ *
118
+ * The equals form is NOT a permission guarantee — on the measured host the tool was already
119
+ * permitted, so the option's effect was unobservable. What was observed is that it does no harm
120
+ * to the argv. Permission stays a documented host precondition.
121
+ */
122
+ export function buildBackendArgs(backend: FreshCallBackend, prompt: string, model: string): string[] {
123
+ switch (backend) {
124
+ case "pi":
125
+ return [prompt, "--entwurf-control", "--model", model];
126
+ case "claude-code":
127
+ return [prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
128
+ }
129
+ }
130
+
131
+ /**
132
+ * The first-turn framing. Order is the contract: the callback is the FIRST action and the task
133
+ * follows it, so a sibling that gets stuck in the task has already told the caller who it is.
134
+ *
135
+ * The three prohibitions are not politeness. Each names a detour that was measured to produce a
136
+ * confidently wrong answer or a wasted turn.
137
+ */
138
+ export function buildFreshCallPrompt(params: {
139
+ backend: FreshCallBackend;
140
+ task: string;
141
+ callerGardenId: string;
142
+ nonce: string;
143
+ }): string {
144
+ const tool = FRESH_CALL_CALLBACK_TOOL[params.backend];
145
+ return [
146
+ "You are a fresh visible citizen that entwurf opened in the operator's tmux session.",
147
+ "",
148
+ `FIRST ACTION, before reading files or anything else: call ${tool} with ` +
149
+ `target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
150
+ `message set to exactly ${params.nonce} — that string alone, nothing added.`,
151
+ "That call is how the agent that opened you learns your address. Do not skip it, do not",
152
+ "defer it until the task is done, and do not reword the message.",
153
+ "",
154
+ "Do not inspect environment variables, do not call entwurf_self, and do not start an MCP",
155
+ "server yourself. Your own report of your identity is not the address anyone needs.",
156
+ "",
157
+ "After the tool receipt, carry out this task:",
158
+ "",
159
+ params.task,
160
+ ].join("\n");
161
+ }
162
+
163
+ /** A launch that was refused, or a placement that could not be established. Every value is a
164
+ * NAMED refusal — this module has no fallback launch and no fallback directory. The cwd members
165
+ * come from the shared classification leaf and their string values are stable contract. */
166
+ export type FreshCallRejectReason =
167
+ | PlacementRejectReason
168
+ | LaunchRejectReason
169
+ | TmuxCwdRejectReason
170
+ | "caller-identity-unavailable"
171
+ | "model-empty"
172
+ | "model-invalid"
173
+ | "task-empty"
174
+ | "task-too-long";
175
+
176
+ /** Coordinates plus what was handed to tmux. Read `runtimePath` as "what we asked to start".
177
+ * There is deliberately NO field here for the callback, the nonce's arrival, or the sibling's
178
+ * garden id — see the module header. */
179
+ export interface FreshCallReceipt extends WindowHandle {
180
+ backend: FreshCallBackend;
181
+ model: string;
182
+ /** The REQUESTED start directory — present only when the caller supplied one. The same kind
183
+ * of fact as `runtimePath`: what tmux was asked for, never an observation of where the pane
184
+ * landed. */
185
+ cwd?: string;
186
+ runtimePath: string;
187
+ nonce: string;
188
+ }
189
+
190
+ export type FreshCallResult = { ok: true; receipt: FreshCallReceipt } | { ok: false; reason: FreshCallRejectReason };
191
+
192
+ /** Correlation tag only. Random, never derived from time, cwd or a peer listing — a nonce that
193
+ * encoded any of those would invite exactly the guessing this rail exists to refuse. */
194
+ export function mintNonce(randomHex: () => string = defaultRandomHex): string {
195
+ return `mux-fresh-call-${randomHex()}`;
196
+ }
197
+
198
+ function defaultRandomHex(): string {
199
+ return randomBytes(12).toString("hex");
200
+ }
201
+
202
+ /** Launch argv: the leaf's detached-append shape, optionally `-c` at the resume-symmetric token
203
+ * position (after `-t`, before `-P -F`), the runtime, then the backend's dialect. An omitted cwd
204
+ * yields the exact pre-#73 argv — no carrier at all. */
205
+ export function buildFreshCallArgs(
206
+ placement: Placement,
207
+ runtimePath: string,
208
+ backendArgs: readonly string[],
209
+ cwd?: string,
210
+ ): string[] {
211
+ assertSelector("session", placement.sessionId);
212
+ assertLaunchTarget(runtimePath);
213
+ if (cwd !== undefined) {
214
+ const bad = classifyTmuxCwd(cwd);
215
+ if (bad) throw new Error(`mux-fresh-call: refusing to build argv with an unusable cwd (${bad}): ${cwd}`);
216
+ }
217
+ return [
218
+ "new-window",
219
+ "-d",
220
+ "-a",
221
+ "-t",
222
+ `${placement.sessionId}:{end}`,
223
+ ...(cwd === undefined ? [] : ["-c", cwd]),
224
+ "-P",
225
+ "-F",
226
+ APPEND_FORMAT,
227
+ "--",
228
+ runtimePath,
229
+ ...backendArgs,
230
+ ];
231
+ }
232
+
233
+ /**
234
+ * Open the sibling. Order is the safety argument, same as `launchPi`: validate the caller's
235
+ * identity and task, resolve and prove the runtime, re-read the caller's placement and refuse a
236
+ * changed context — and only then mutate. Nothing above can leave a window behind.
237
+ *
238
+ * `callerGardenId` is supplied by the SURFACE that registered this tool, from its own
239
+ * record-backed context. It is not a tool parameter and this module never derives, validates
240
+ * against a store, or guesses it: an empty value is a named refusal, not a lookup.
241
+ */
242
+ export function freshCall(
243
+ params: { backend: FreshCallBackend; model: string; task: string; cwd?: string; callerGardenId: string | null },
244
+ env: NodeJS.ProcessEnv = process.env,
245
+ nonce: string = mintNonce(),
246
+ ): FreshCallResult {
247
+ if (typeof params.callerGardenId !== "string" || params.callerGardenId.length === 0) {
248
+ return { ok: false, reason: "caller-identity-unavailable" };
249
+ }
250
+ const model = params.model.trim();
251
+ if (model.length === 0) return { ok: false, reason: "model-empty" };
252
+ if (!isSafeFreshCallModel(model)) return { ok: false, reason: "model-invalid" };
253
+ const task = params.task.trim();
254
+ if (task.length === 0) return { ok: false, reason: "task-empty" };
255
+ if (task.length > TASK_MAX_CHARS) return { ok: false, reason: "task-too-long" };
256
+ // ONLY `undefined` and the exact empty string mean "no cwd". Everything else is the literal
257
+ // value — deliberately untrimmed, so a whitespace-mangled path is refused loudly by the
258
+ // classification below instead of being silently repaired into a different directory.
259
+ const cwd = params.cwd === undefined || params.cwd === "" ? undefined : params.cwd;
260
+ if (cwd !== undefined) {
261
+ const badCwd = classifyTmuxCwd(cwd);
262
+ if (badCwd) return { ok: false, reason: badCwd };
263
+ }
264
+
265
+ let runtimePath: string;
266
+ try {
267
+ runtimePath = resolveRuntimeOnPath(FRESH_CALL_RUNTIME[params.backend], env);
268
+ } catch (err) {
269
+ if (err instanceof LaunchPreconditionError) return { ok: false, reason: err.reason };
270
+ throw err;
271
+ }
272
+
273
+ const inspected = inspectPlacement(env);
274
+ if (!inspected.ok) return { ok: false, reason: inspected.reason };
275
+ const placement = inspected.placement;
276
+ requireSameContext("freshCall", placement, env);
277
+
278
+ const prompt = buildFreshCallPrompt({
279
+ backend: params.backend,
280
+ task,
281
+ callerGardenId: params.callerGardenId,
282
+ nonce,
283
+ });
284
+ const run = runTmux(
285
+ buildFreshCallArgs(placement, runtimePath, buildBackendArgs(params.backend, prompt, model), cwd),
286
+ env,
287
+ );
288
+ assertTmuxOk("new-window", run);
289
+
290
+ let fields: ReturnType<typeof parseWindowFields>;
291
+ try {
292
+ fields = parseWindowFields(run.stdout);
293
+ } catch (err) {
294
+ // The window exists and its id is precisely what could not be read. Diffing the inventory
295
+ // to find "the new one" is the guess this rail forbids everywhere else, so name the orphan.
296
+ throw new Error(
297
+ `mux-fresh-call: launched ${runtimePath} but could not read the window handle tmux printed — a window may ` +
298
+ `be open in session ${placement.sessionId} that this call cannot identify or close: ${
299
+ err instanceof Error ? err.message : String(err)
300
+ }`,
301
+ );
302
+ }
303
+
304
+ return {
305
+ ok: true,
306
+ receipt: {
307
+ serverPid: placement.serverPid,
308
+ sessionId: placement.sessionId,
309
+ ...fields,
310
+ backend: params.backend,
311
+ model,
312
+ ...(cwd === undefined ? {} : { cwd }),
313
+ runtimePath,
314
+ nonce,
315
+ },
316
+ };
317
+ }
318
+
319
+ /** Why each refusal happened, in the caller's terms. A reason a caller cannot act on is a reason
320
+ * they will guess about. */
321
+ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
322
+ "no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
323
+ "anchor-malformed": "TMUX_PANE is not a native pane id",
324
+ "anchor-unresolved": "tmux resolved no pane for this agent's anchor",
325
+ "anchor-mismatch": "tmux answered about a different pane than the one asked about",
326
+ "caller-identity-unavailable":
327
+ "this surface has no record-backed garden id for the caller, so the sibling would have no address to call back to",
328
+ "cwd-not-absolute":
329
+ "the requested cwd is not an absolute path (the value is taken literally — nothing trims or resolves it)",
330
+ "cwd-format-token":
331
+ "the requested cwd contains '#', which tmux expands as a format inside -c — it would silently rewrite the path or run a command",
332
+ "cwd-missing":
333
+ "the requested cwd does not exist; tmux would not report this, it would open the window in $HOME and look successful",
334
+ "cwd-not-directory": "the requested cwd exists but is not a directory",
335
+ "model-empty": "model is empty after trimming; fresh calls require an explicit model",
336
+ "model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
337
+ "task-empty": "task is empty after trimming",
338
+ "task-too-long": `task exceeds ${TASK_MAX_CHARS} characters`,
339
+ "runtime-unresolved": "the backend's runtime is not installed on PATH",
340
+ "runtime-not-absolute": "the resolved runtime path is not absolute",
341
+ "runtime-path-whitespace": "the resolved runtime path contains whitespace, which tmux would re-split",
342
+ "runtime-missing": "nothing exists at the resolved runtime path",
343
+ "runtime-not-regular-file": "the resolved runtime path is not a regular file",
344
+ "runtime-not-executable": "the resolved runtime path is not executable",
345
+ };
346
+
347
+ /**
348
+ * ONE renderer for both surfaces. Not a convenience: the two registrations are separate literals
349
+ * (that is this repo's shape), so a shared renderer is what keeps the operator-visible answer
350
+ * from drifting apart between native pi and the MCP bridge.
351
+ *
352
+ * The success text states the boundary out loud. A caller that reads "launched" and assumes
353
+ * "delivered" is the exact confusion the two-receipt split exists to prevent, so the text refuses
354
+ * to imply it.
355
+ */
356
+ export function renderFreshCall(result: FreshCallResult): { text: string; isError: boolean } {
357
+ if (!result.ok) {
358
+ return {
359
+ text: `entwurf_fresh_call rejected: ${result.reason} — ${REJECT_HINT[result.reason]}. No window was opened.`,
360
+ isError: true,
361
+ };
362
+ }
363
+ const r = result.receipt;
364
+ return {
365
+ text:
366
+ `[entwurf fresh call →]\n` +
367
+ ` backend: ${r.backend} (${r.runtimePath})\n` +
368
+ ` model: ${r.model} (requested on the runtime CLI)\n` +
369
+ (r.cwd === undefined ? "" : ` cwd: ${r.cwd} (requested start directory — not an observation)\n`) +
370
+ ` window: ${r.windowId} (index ${r.windowIndex}) in session ${r.sessionId}\n` +
371
+ ` pane: ${r.paneId} pid ${r.panePid}\n` +
372
+ ` nonce: ${r.nonce}\n` +
373
+ `\n` +
374
+ `This is a LAUNCH receipt: tmux created that window and was asked to start the runtime with the model above. It does ` +
375
+ `NOT mean the sibling is running, that its first turn ran, or that the task was delivered.\n` +
376
+ `The sibling's garden id arrives separately — it calls entwurf_v2 back with the nonce above as its ` +
377
+ `first action, and the sender envelope of THAT message is the address. Nothing is polling for it; ` +
378
+ `if it never comes, the window is visible and can be read directly.`,
379
+ isError: false,
380
+ };
381
+ }