@junghanacs/entwurf 0.22.0 → 0.23.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 (135) hide show
  1. package/AGENTS.md +4 -1
  2. package/BASELINE.md +1 -0
  3. package/CHANGELOG.md +514 -0
  4. package/DELIVERY.md +10 -2
  5. package/README.md +43 -4
  6. package/VERIFY.md +47 -0
  7. package/docs/acp-backend-rail.md +96 -17
  8. package/docs/setup-clean-host.md +11 -1
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
  27. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
  28. package/mcp/entwurf-bridge/src/index.ts +41 -41
  29. package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
  30. package/package.json +9 -8
  31. package/pi-extensions/entwurf-control.ts +91 -52
  32. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  33. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  34. package/pi-extensions/lib/acp/backend.ts +4 -4
  35. package/pi-extensions/lib/acp/event-mapper.ts +5 -5
  36. package/pi-extensions/lib/acp/tool-surface.ts +2 -1
  37. package/pi-extensions/lib/codex-declaration.js +612 -0
  38. package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
  39. package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
  40. package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
  41. package/pi-extensions/lib/entwurf-facts.ts +14 -1
  42. package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
  43. package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
  44. package/pi-extensions/lib/entwurf-self-address.ts +33 -0
  45. package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
  46. package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
  47. package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
  48. package/pi-extensions/lib/fresh-call-composition.ts +493 -0
  49. package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
  50. package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
  51. package/pi-extensions/lib/herdr-placement.ts +276 -0
  52. package/pi-extensions/lib/mux-fresh-call.ts +95 -301
  53. package/run.sh +588 -204
  54. package/scripts/check-acp-usage-accounting.ts +9 -9
  55. package/scripts/check-codex-birth-hook.ts +187 -0
  56. package/scripts/check-entwurf-control-rpc.ts +138 -0
  57. package/scripts/check-entwurf-fact-provider.ts +4 -2
  58. package/scripts/check-entwurf-peers-surface.ts +3 -1
  59. package/scripts/check-entwurf-self-address.ts +68 -0
  60. package/scripts/check-fresh-call-dispatch.ts +301 -0
  61. package/scripts/check-gate-qualification.ts +17 -4
  62. package/scripts/check-herdr-activation.ts +1293 -0
  63. package/scripts/check-herdr-fresh-call.ts +1250 -0
  64. package/scripts/check-herdr-placement.ts +383 -0
  65. package/scripts/check-herdr-plugin-build.ts +842 -0
  66. package/scripts/check-herdr-plugin-profile.ts +371 -0
  67. package/scripts/check-herdr-plugin.ts +523 -0
  68. package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
  69. package/scripts/check-herdr-sandbox.ts +456 -0
  70. package/scripts/check-herdr-supply.ts +184 -0
  71. package/scripts/check-mux-launcher-fence.ts +138 -0
  72. package/scripts/check-peer-facts.ts +331 -0
  73. package/scripts/check-pi-launch.ts +358 -0
  74. package/scripts/check-release-gate-outcomes.ts +19 -0
  75. package/scripts/check-typing-call-fence.ts +296 -0
  76. package/scripts/codex-birth-doctor.sh +159 -16
  77. package/scripts/codex-birth-install.sh +178 -72
  78. package/scripts/codex-birth-uninstall.sh +162 -7
  79. package/scripts/fixtures/herdr-supply.json +29 -0
  80. package/scripts/herdr-activation.mjs +536 -0
  81. package/scripts/herdr-plugin-activate.mjs +270 -0
  82. package/scripts/herdr-plugin-deactivate.mjs +193 -0
  83. package/scripts/herdr-runtime.mjs +1203 -0
  84. package/scripts/install-herdr-ci.sh +96 -0
  85. package/scripts/inventory-verification-surface.ts +86 -10
  86. package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
  87. package/scripts/lib/tree-digest.ts +97 -0
  88. package/scripts/meta-bridge-install.sh +19 -2
  89. package/scripts/meta-bridge-state.py +167 -11
  90. package/scripts/mutants/codex-caller-seat.json +6 -8
  91. package/scripts/mutants/codex-native.json +195 -29
  92. package/scripts/mutants/control-socket-disconnect.json +57 -0
  93. package/scripts/mutants/fresh-call-dispatch.json +154 -0
  94. package/scripts/mutants/herdr-activation.json +305 -0
  95. package/scripts/mutants/herdr-fresh-call.json +448 -0
  96. package/scripts/mutants/herdr-placement.json +161 -0
  97. package/scripts/mutants/herdr-plugin-build.json +160 -0
  98. package/scripts/mutants/herdr-plugin-profile.json +202 -0
  99. package/scripts/mutants/herdr-plugin.json +133 -0
  100. package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
  101. package/scripts/mutants/herdr-supply.json +133 -0
  102. package/scripts/mutants/meta-hook-session-switch.json +2 -2
  103. package/scripts/mutants/mux-fresh-call.json +104 -17
  104. package/scripts/mutants/mux-launcher-fence.json +13 -0
  105. package/scripts/mutants/omp-fresh.json +4 -4
  106. package/scripts/mutants/peer-facts.json +98 -0
  107. package/scripts/mutants/pi-launch.json +113 -0
  108. package/scripts/mutants/resume-args.json +6 -6
  109. package/scripts/mutants/self-address.json +33 -0
  110. package/scripts/mutants/typing-call-fence.json +17 -0
  111. package/scripts/mutants/v2-surface.json +2 -2
  112. package/scripts/mutants/v2-visible-resume.json +17 -17
  113. package/scripts/peer-facts.ts +120 -0
  114. package/scripts/register-pi-package.py +8 -0
  115. package/scripts/register-pi-provider.py +88 -12
  116. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  117. package/scripts/smoke-codex-birth.sh +281 -12
  118. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  119. package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
  120. package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
  121. package/scripts/smoke-herdr-raw-install-live.sh +849 -0
  122. package/scripts/tsconfig.json +5 -0
  123. package/scripts/check-entwurf-facts.ts +0 -385
  124. package/scripts/check-entwurf-resume-args.ts +0 -122
  125. package/scripts/check-entwurf-v2-decider.ts +0 -657
  126. package/scripts/check-entwurf-v2-lock.ts +0 -371
  127. package/scripts/check-entwurf-v2-mailbox.ts +0 -261
  128. package/scripts/check-entwurf-v2-matrix.ts +0 -401
  129. package/scripts/check-entwurf-v2-native-push.ts +0 -221
  130. package/scripts/check-entwurf-v2-release.ts +0 -151
  131. package/scripts/check-entwurf-v2-runner.ts +0 -448
  132. package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
  133. package/scripts/check-entwurf-v2-send.ts +0 -458
  134. package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
  135. package/scripts/check-socket-discovery.ts +0 -401
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * peer-facts — the read-only JSON projection of the OBSERVED peer listing (#116 M2-a).
4
+ *
5
+ * `meta-facts` (#65) emits what the meta-record STORE owns. This emits what the fact
6
+ * PROVIDER owns: the same `listEntwurfFacts` assembly `entwurf_peers` renders, serialized
7
+ * instead of drawn. It exists for the same reason #65 exists — so a consumer stops
8
+ * carrying a decaying copy of a join it does not own.
9
+ *
10
+ * THE COPY THIS REFUSES. The placement axis joins a herdr pane to a citizen on
11
+ * `nativeSessionId`, and on the pi side that key is recovered from a session FILENAME by a
12
+ * strict conversion measured against one vendor floor (`herdr-placement.ts`
13
+ * piNativeSessionIdFromPath). A consumer that re-implements that join forks a vendor floor
14
+ * into a file no gate covers and no document owns (`docs/mux-launch-rail.md` §7). With this
15
+ * verb the consumer's whole join becomes ONE opaque pane-id string equality, which is a
16
+ * thing a shell script may correctly do.
17
+ *
18
+ * ONE PROVIDER, ONE RENDERER, NO SECOND JOIN. The payload is `renderEntwurfPeers().payload`
19
+ * — the same provider (`listEntwurfFacts`) through the same renderer the MCP `entwurf_peers`
20
+ * surface uses, so the SHAPE is that surface's payload shape and each row is a `PeerFact`
21
+ * whose keyset `check-entwurf-peers-surface` pins to `{peers, diagnostics}`. Nothing here
22
+ * recomputes, filters, re-sorts or re-words a provider fact, and nothing here re-implements
23
+ * the join.
24
+ *
25
+ * It is NOT the same bytes as an `entwurf_peers` call, and the difference is deliberate, not
26
+ * a drift: that surface is a human listing, so it passes `observationLimit` and returns only
27
+ * rendered TEXT over the wire. This is a machine projection, so its observation is
28
+ * UNBOUNDED — a rationed row would say `unobserved`, which a machine consumer cannot tell
29
+ * apart from "nobody could look". Older rows can therefore read `exists`/`active` here where
30
+ * the human surface says `unobserved`. Same facts, measured for more rows.
31
+ *
32
+ * `placement` STAYS STRUCTURED. The human surface prints `herdr <pane>` so a reader never
33
+ * mistakes a pane id for one of ours; a machine consumer needs the tagged union itself
34
+ * (`{kind:"herdr-pane",paneId}` / `unobserved` / `none` / `ambiguous`), so `renderPlacement`
35
+ * is NOT applied here. A pane is an ephemeral view (Hard Rule 16), never an address.
36
+ *
37
+ * WHAT IS DELIBERATELY ABSENT. No herdr `agent_status`, no `interactive_ready`, no screen
38
+ * text: those are herdr's own display verdicts, they are not delivery evidence
39
+ * (`docs/herdr-launch-rail.md` §9), and keeping them out of every entwurf payload is what
40
+ * makes "observed activity" unable to drift into delivery liveness. A consumer that wants
41
+ * to show them reads herdr itself and labels them as herdr's report.
42
+ *
43
+ * ONE PLACEMENT READ. `readPlacementIndex` is left undefined, so the provider performs its
44
+ * own single read per listing — the anti-watcher shape `entwurf-fact-provider.ts` documents.
45
+ * This verb never loops, retries, or waits for a pane whose session reference has not landed.
46
+ *
47
+ * NO SOCKET COORDINATE LEAVES THIS VERB. `ENTWURF_DIR` selects the socket world that is
48
+ * PROBED, and that is all it does: the control dir is never emitted. #50 C4 retired the
49
+ * legacy `sessions` projection "with the `controlDir` it exposed" because the record is the
50
+ * sole address axis and socket paths are dispatch-internal transport
51
+ * (`entwurf-peers-render.ts` header). Re-publishing that path under a new verb would undo
52
+ * that retirement, and no renderer needs it.
53
+ *
54
+ * stdout (deterministic, 2-space indent, trailing newline):
55
+ * {
56
+ * "schemaVersion": 1, // of THIS projection
57
+ * "storeDir": "/abs", // the meta-record store that was read
58
+ * "peers": [ <PeerFact, verbatim, provider order> ],
59
+ * "diagnostics": [ <EntwurfDiagnostic, verbatim> ]
60
+ * }
61
+ *
62
+ * EXIT CONTRACT — identical in shape to meta-facts, for the same reason:
63
+ * 0 — the store was readable; the JSON above is on stdout (diagnostics in-band).
64
+ * A store that does not exist is a readable EMPTY store (ENOENT only).
65
+ * 2 — usage error (bad argv).
66
+ * 3 — the store could not be READ. No JSON is emitted: an unreadable host must
67
+ * never look like an empty one.
68
+ * A provider wiring invariant (duplicate identity / unprobed in-domain citizen) is left to
69
+ * THROW. It is not an unreadable store and must not be dressed as one (Hard Rule 15).
70
+ */
71
+ import os from "node:os";
72
+ import { defaultControlSocketDir } from "../pi-extensions/lib/control-socket-path.js";
73
+ import { listEntwurfFacts } from "../pi-extensions/lib/entwurf-fact-provider.js";
74
+ import { renderEntwurfPeers } from "../pi-extensions/lib/entwurf-peers-render.js";
75
+ import { defaultMetaSessionsDir, makeStoreRecordReader, readActiveStoreEntries, } from "../pi-extensions/lib/meta-session.js";
76
+ const arg = process.argv[2];
77
+ // A dash argv is a flag this command does not have, not a store directory — treating it as
78
+ // a path would answer `--help` with "empty store, exit 0", a silent wrong fact.
79
+ if (process.argv.length > 3 || (arg !== undefined && arg.startsWith("-"))) {
80
+ console.error("usage: entwurf peer-facts [meta-sessions-dir]");
81
+ process.exit(2);
82
+ }
83
+ const storeDir = arg ?? defaultMetaSessionsDir();
84
+ // The SAME override the bridge honours (`mcp/entwurf-bridge/src/index.ts` ENTWURF_DIR), so
85
+ // this verb and `entwurf_peers` probe one socket world. The pi side has no such override;
86
+ // the path grammar is the shared leaf either way. This value is an INPUT to the probe and
87
+ // never an output — see NO SOCKET COORDINATE LEAVES THIS VERB above.
88
+ const controlDir = process.env.ENTWURF_DIR ?? defaultControlSocketDir(os.homedir());
89
+ let metaEntries;
90
+ try {
91
+ // Entries WITH their kind, never bare names: the listing must be able to refuse a
92
+ // symlinked record without following it (Hard Rule 7). ENOENT is the empty store.
93
+ metaEntries = readActiveStoreEntries(storeDir);
94
+ }
95
+ catch (err) {
96
+ console.error(`FAIL: ${err instanceof Error ? err.message : String(err)}`);
97
+ process.exit(3); // see the EXIT CONTRACT above: 3 = unreadable store, never an empty one
98
+ }
99
+ const result = await listEntwurfFacts({
100
+ metaEntries,
101
+ readRecord: makeStoreRecordReader(storeDir),
102
+ socket: { dir: controlDir },
103
+ // No `observationLimit`: a budget would emit `unobserved` for rows nobody chose to
104
+ // skip, and in a machine payload that is indistinguishable from "no herdr here". The
105
+ // human surface may ration its own rows; a projection may not ration its facts.
106
+ });
107
+ const { payload } = renderEntwurfPeers(result);
108
+ const projection = { schemaVersion: 1, storeDir, ...payload };
109
+ process.stdout.write(`${JSON.stringify(projection, null, 2)}\n`);
@@ -65,7 +65,6 @@ import * as process from "node:process";
65
65
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
66
66
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
67
67
  import { z } from "zod";
68
- import { codexCallerFreshPreflight, codexFreshPreflight } from "../../../pi-extensions/lib/codex-fresh-preflight.ts";
69
68
  import { controlSocketPathIn, defaultControlSocketDir } from "../../../pi-extensions/lib/control-socket-path.js";
70
69
  import { resolveMailboxReceiverFacts } from "../../../pi-extensions/lib/entwurf-deliverability.ts";
71
70
  import { listEntwurfFacts } from "../../../pi-extensions/lib/entwurf-fact-provider.ts";
@@ -80,6 +79,7 @@ import {
80
79
  type VisibleResumeDeps,
81
80
  visibleResume,
82
81
  } from "../../../pi-extensions/lib/entwurf-v2-visible-resume.ts";
82
+ import { dispatchFreshCall, renderDispatchedFreshCall } from "../../../pi-extensions/lib/fresh-call-dispatch.ts";
83
83
  import {
84
84
  type CodexRequestSender,
85
85
  probeNativeSenderAlive,
@@ -99,7 +99,6 @@ import {
99
99
  readMetaSenderMarker,
100
100
  requireBackend,
101
101
  } from "../../../pi-extensions/lib/meta-session.ts";
102
- import { freshCall, renderFreshCall } from "../../../pi-extensions/lib/mux-fresh-call.ts";
103
102
  import { RESUME_CALL_REJECT_HINT, resumeCall } from "../../../pi-extensions/lib/mux-resume-call.ts";
104
103
  import { registerNativeConversation } from "../../../pi-extensions/lib/native-push/register.ts";
105
104
 
@@ -716,30 +715,32 @@ server.tool(
716
715
  // against that answer would call home to a garden id nobody holds.
717
716
  server.tool(
718
717
  "entwurf_fresh_call",
719
- "Open ONE fresh visible sibling in the operator's tmux and hand it a first task. Five fixed " +
720
- "backends only: pi, claude-code, copilot, omp, codex. The sibling's FIRST action is a callback to you carrying a nonce, and the " +
721
- "sender envelope of that callback is its garden id — that is how you learn the address of something that " +
722
- "did not exist a moment ago. This returns a LAUNCH receipt (tmux window/pane plus that nonce) and nothing " +
723
- "else: it does NOT mean the runtime started, the first turn ran, or the task was delivered. Nothing polls " +
724
- "for the callback; if it never arrives the window is visible. For EXISTING " +
725
- "citizens use entwurf_v2 this tool only creates, and entwurf_peers only reports. Model is REQUIRED and " +
726
- "is passed to the chosen runtime CLI (`provider/model` for pi; model id/alias for Claude Code; a model name " +
727
- "or `auto` for copilot; a fuzzy model pattern for omp or codex). Copilot, omp, and codex are refused BEFORE " +
728
- "any window opens when their required birth, MCP, receive/delivery, or visible-identity units are absent; " +
729
- "Codex also requires the operator-owned default app-server socket, which entwurf never starts. An optional " +
730
- "cwd starts the sibling in ONE literal absolute existing directory (cross-repo fresh) never pick resume " +
731
- "for a dormant record's cwd. Omitted/empty cwd means the caller's own directory. " +
732
- "An optional placement.tmuxSession is an expert override naming ONE EXISTING session on this agent's own " +
733
- "tmux server, and it ALWAYS wins. Omitted, the seat follows the CALLER: a CODEX CALLER opens beside its own " +
734
- "TUI pane (matched by thread-id in that pane's title; 0 or 2+ matches REFUSE, never fall back), and every " +
735
- "other caller opens in its own session. " +
736
- "A missing named session is tmux-session-missing and NOTHING is created. " +
737
- "There are no arbitrary command/env knobs. Do not put secrets in the task — model and task argv are visible to " +
738
- "same-user processes on this host. Requires that this agent itself runs inside tmux.",
718
+ "Open ONE fresh visible sibling beside you and hand it a first task. WHERE it opens is decided by where THIS " +
719
+ "agent runs, never by a parameter: inside herdr (HERDR_ENV=1) it opens a NEW UNFOCUSED TAB in your own " +
720
+ "herdr workspace, pi and claude-code " +
721
+ "only; everywhere else it opens in the operator's tmux with all five backends (pi, " +
722
+ "claude-code, copilot, omp, codex). No fallback: an incomplete herdr context is refused by name, " +
723
+ "never by a tmux window you cannot see from herdr. The sibling's FIRST action " +
724
+ "is a callback to you carrying a nonce, whose sender envelope is its garden id — that is how you learn the " +
725
+ "address of a thing that did not exist a moment ago. This returns a LAUNCH receipt (the owner's " +
726
+ "coordinates plus that nonce): it does NOT mean the runtime started, the first turn ran, or " +
727
+ "the task was delivered. Those coordinates are a VIEW, never an address — a herdr tab/pane id can change under " +
728
+ "a running sibling. Nothing polls. " +
729
+ "For EXISTING citizens use entwurf_v2 this tool only creates, and entwurf_peers only reports. Model is REQUIRED and passed to the chosen " +
730
+ "runtime CLI (`provider/model` for pi, an id/alias for Claude Code, a pattern for the rest). On tmux, copilot/omp/codex are refused BEFORE any window " +
731
+ "opens when their birth, MCP, receive or visible-identity units are absent, and codex also needs the " +
732
+ "operator-owned app-server socket entwurf never starts; in herdr they are refused by name first. " +
733
+ "Optional placement.tmuxSession is a TMUX-ONLY seat naming ONE EXISTING session and " +
734
+ "ALWAYS wins; omitted, the seat follows the CALLER, never the backend opened: a CODEX CALLER opens beside " +
735
+ "its own TUI pane, every other caller in its own session; a missing one is tmux-session-missing and " +
736
+ "NOTHING is created. In herdr the field is refused by name. Do not put secrets in the task — model and task argv are visible to same-user " +
737
+ "processes.",
739
738
  {
740
739
  backend: z
741
740
  .enum(["pi", "claude-code", "copilot", "omp", "codex"])
742
- .describe("Which fixed runtime to open. Only these five; there is no arbitrary command."),
741
+ .describe(
742
+ "Which fixed runtime to open. Only these five, and only pi/claude-code when this agent runs inside herdr; there is no arbitrary command.",
743
+ ),
743
744
  model: z
744
745
  .string()
745
746
  .min(1)
@@ -767,7 +768,7 @@ server.tool(
767
768
  .string()
768
769
  .optional()
769
770
  .describe(
770
- "Optional literal ABSOLUTE path of an existing directory to start the sibling in (cross-repo fresh). Taken exactly as given — no trim, no realpath, no project-name resolution; '#' is refused (tmux format expansion). Omit or pass \"\" to start where the CALLER is: its own directory, or for a Codex caller its own record directory, because this bridge is the app-server's child and that process's directory is not the caller's. The receipt echoes the directory that was REQUESTED or the caller record it came from, never an observation of where the pane landed.",
771
+ "Optional literal ABSOLUTE path of an existing directory to start the sibling in (cross-repo fresh). Omit or pass \"\" to start where the CALLER is this agent's own cwd on BOTH rails, or for a Codex caller its own record directory, because this bridge is the app-server's child and that process's directory is not the caller's. Taken exactly as given: no trim, no realpath, no project-name resolution. '#' is refused on the tmux rail only, because tmux format-expands a start directory; inside herdr it is an ordinary path character. The receipt echoes the directory that was REQUESTED or the caller record it came from, never an observation of where the pane landed.",
771
772
  ),
772
773
  placement: z
773
774
  .object({
@@ -779,7 +780,7 @@ server.tool(
779
780
  })
780
781
  .optional()
781
782
  .describe(
782
- "Optional expert seat override: open the sibling in ONE EXISTING tmux session of this agent's own server, and it always wins. When omitted the seat follows the CALLER, never the backend being opened: a Codex CALLER opens beside its own TUI pane, matched by thread-id in that pane's terminal title (0 or 2+ matching panes refuse, never fall back); every other caller opens in its own session. A pane title is a placement input only — never an address, liveness or delivery fact. Nothing is ever created. Independent of cwd; neither is inferred from the other. The receipt reports the selected name (absent for the caller-pane rule, which observed a session rather than requesting a name), its source, and resolved target session id.",
783
+ "Optional expert seat override, TMUX ONLY: open the sibling in ONE EXISTING tmux session of this agent's own server, and it always wins. When omitted the seat follows the CALLER, never the backend being opened: a Codex CALLER opens beside its own TUI pane, matched by thread-id in that pane's terminal title (0 or 2+ matching panes refuse, never fall back); every other caller opens in its own session. A pane title is a placement input only — never an address, liveness or delivery fact. Nothing is ever created. Inside herdr this field is refused by name — placement there belongs to herdr, and a tmux session name would silently place the sibling somewhere else. Independent of cwd; neither is inferred from the other. The receipt reports the selected name (absent for the caller-pane rule, which observed a session rather than requesting a name), its source, and resolved target session id.",
783
784
  ),
784
785
  },
785
786
  async ({ backend, model, task, cwd, placement }, extra) => {
@@ -813,22 +814,21 @@ server.tool(
813
814
  callerGardenId = null;
814
815
  }
815
816
  try {
816
- // TWO capability axes, in this order, both pre-mutation and neither standing in for
817
- // the other. The TARGET axis first "entwurf cannot open a Codex sibling here at
818
- // all" is the more fundamental answer than "and it would not know where to put it".
819
- // The CALLER axis second, and only when the seat anchor will actually be consulted:
820
- // a codex caller that named an explicit placement never reads a pane title, so
821
- // refusing it for a missing `thread-id` would refuse an unused capability.
822
- const targetMissing = backend === "codex" ? await codexFreshPreflight(process.env) : null;
823
- const callerMissing =
824
- targetMissing === null && callerNativeSessionId !== undefined && placement === undefined
825
- ? codexCallerFreshPreflight(process.env)
826
- : null;
827
- const missing = targetMissing ?? callerMissing;
828
- const result = missing
829
- ? ({ ok: false, reason: missing } as const)
830
- : freshCall({ backend, model, task, cwd, placement, callerGardenId, callerNativeSessionId, callerCwd });
831
- const rendered = renderFreshCall(result);
817
+ // Rail choice, Codex preflight ordering and rendering all live in the composition root,
818
+ // so this surface and pi's own cannot drift apart on any of them. The two caller-derived
819
+ // codex inputs ride with the request: the root consults them only on the tmux rail,
820
+ // which is where #95 lane B/C measured them.
821
+ const dispatched = await dispatchFreshCall({
822
+ backend,
823
+ model,
824
+ task,
825
+ cwd,
826
+ placement,
827
+ callerGardenId,
828
+ callerNativeSessionId,
829
+ callerCwd,
830
+ });
831
+ const rendered = renderDispatchedFreshCall(dispatched);
832
832
  return rendered.isError ? textErr(rendered.text) : textOk(rendered.text);
833
833
  } catch (err) {
834
834
  return textErr(`entwurf_fresh_call error: ${err instanceof Error ? err.message : String(err)}`);
@@ -142,6 +142,7 @@
142
142
  "../../scripts/meta-bridge-prune.ts",
143
143
  "../../scripts/meta-bridge-fresh-cut.ts",
144
144
  "../../scripts/meta-facts.ts",
145
+ "../../scripts/peer-facts.ts",
145
146
  "../../scripts/omp-receive-facts.ts",
146
147
  "../../scripts/codex-socket-path.ts",
147
148
  "../../pi-extensions/meta-bridge-hook.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.22.0",
3
+ "version": "0.23.1",
4
4
  "description": "Garden-citizen dispatch substrate and meta-bridge for pi, Claude Code, Copilot CLI, OMP, Antigravity, and Codex CLI; Claude and Snowflake Cortex Code ACP backends.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -73,7 +73,8 @@
73
73
  "CONTRIBUTING.md",
74
74
  "CHANGELOG.md",
75
75
  "!**/__pycache__",
76
- "!**/*.pyc"
76
+ "!**/*.pyc",
77
+ "!pi-extensions/**/*.test.ts"
77
78
  ],
78
79
  "engines": {
79
80
  "node": ">=24.0.0"
@@ -82,7 +83,7 @@
82
83
  "claudeCodeFloor": ">=2.1.217"
83
84
  },
84
85
  "dependencies": {
85
- "@agentclientprotocol/claude-agent-acp": "0.76.0",
86
+ "@agentclientprotocol/claude-agent-acp": "0.79.0",
86
87
  "@agentclientprotocol/sdk": "1.4.0",
87
88
  "@anthropic-ai/sdk": "0.100.1",
88
89
  "@modelcontextprotocol/sdk": "^1.12.1",
@@ -107,7 +108,7 @@
107
108
  "vitest": "4.1.9"
108
109
  },
109
110
  "scripts": {
110
- "prepare": "husky 2>/dev/null || true; npm run --silent build-bridge",
111
+ "prepare": "husky 1>&2 || true; npm run --silent build-bridge 1>&2",
111
112
  "postinstall": "node scripts/postinstall-chmod.cjs",
112
113
  "build-bridge": "bash scripts/with-dist-lock.sh bash scripts/build-bridge.sh",
113
114
  "prepack": "pnpm --silent run build-bridge",
@@ -127,7 +128,6 @@
127
128
  "check-meta-capability-source": "./run.sh check-meta-capability-source",
128
129
  "check-socket-probe": "./run.sh check-socket-probe",
129
130
  "check-project-trust-handler": "./run.sh check-project-trust-handler",
130
- "check-entwurf-v2-lock": "./run.sh check-entwurf-v2-lock",
131
131
  "check-model-lock": "./run.sh check-model-lock",
132
132
  "check-dep-versions": "./run.sh check-dep-versions",
133
133
  "check-node-floor-coherence": "./run.sh check-node-floor-coherence",
@@ -135,6 +135,7 @@
135
135
  "check-copilot-birth-hook": "./run.sh check-copilot-birth-hook",
136
136
  "check-copilot-statusline": "./run.sh check-copilot-statusline",
137
137
  "check-copilot-launch": "./run.sh check-copilot-launch",
138
+ "check-pi-launch": "./run.sh check-pi-launch",
138
139
  "smoke-copilot-statusline-state": "./run.sh smoke-copilot-statusline-state",
139
140
  "smoke-copilot-mcp-state": "./run.sh smoke-copilot-mcp-state",
140
141
  "smoke-omp-bridge-state": "./run.sh smoke-omp-bridge-state",
@@ -173,9 +174,9 @@
173
174
  "check:full": "bash scripts/check-elapsed.sh check:full check:core check:hermetic check:package",
174
175
  "check:core": "pnpm run check:toolchain && pnpm run check:vitest && pnpm run check:contracts",
175
176
  "check:toolchain": "pnpm lint && pnpm typecheck",
176
- "check:vitest": "./run.sh check-mux-fresh-call && ./run.sh check-acp-sdk-surface",
177
- "check:contracts": "./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-v3-record && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-harness-admission-parity && ./run.sh check-capability-bundle-reach && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-capability-source && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-node-floor-coherence && ./run.sh check-claude-floor-coherence && ./run.sh check-copilot-statusline && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-lock && ./run.sh check-entwurf-v2-decider && ./run.sh check-entwurf-v2-matrix && ./run.sh check-entwurf-v2-release && ./run.sh check-entwurf-v2-send && ./run.sh check-compaction-send-guard && ./run.sh check-entwurf-v2-send-fallback && ./run.sh check-entwurf-v2-mailbox && ./run.sh check-entwurf-v2-native-push && ./run.sh check-entwurf-v2-runner && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-entwurf-resume-args && ./run.sh check-resume-launch-identity && ./run.sh check-mux-placement && ./run.sh check-mux-launch && ./run.sh check-mux-resume-call && ./run.sh check-mux-parent-artifact && ./run.sh check-mux-launcher-fence && ./run.sh check-entwurf-v2-visible-resume && ./run.sh check-entwurf-facts && ./run.sh check-control-socket-path && ./run.sh check-socket-discovery && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-native-push-adapter && ./run.sh check-native-push-register && ./run.sh check-auth-boundary && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-carrier-augment && ./run.sh check-codex-sender-identity && ./run.sh check-codex-native-push",
178
- "check:hermetic": "./run.sh check-bridge-delivery && ./run.sh smoke-pi-attach && ./run.sh check-fresh-cut-gate && ./run.sh check-hook-launch-topology && ./run.sh check-meta-hook-session-switch && ./run.sh check-copilot-birth-hook && ./run.sh check-copilot-receive-arm && ./run.sh check-copilot-launch && ./run.sh check-codex-app-server-launch && ./run.sh check-meta-identity-consumers && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh check-meta-doctor-oracle && ./run.sh check-agy-permission-matrix && ./run.sh smoke-agy-statusline-state && ./run.sh smoke-copilot-statusline-state && ./run.sh smoke-copilot-mcp-state && ./run.sh smoke-omp-bridge-state && ./run.sh smoke-omp-mcp-state && ./run.sh smoke-omp-receive-state && ./run.sh check-omp-birth-hook && ./run.sh check-omp-receive-arm && ./run.sh check-omp-fresh-preflight && ./run.sh smoke-agy-hooks-state && ./run.sh smoke-pi-provider-state && ./run.sh smoke-user-scope-citizen && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-bridge-boot && ./run.sh check-probe-bridge-command && ./run.sh check-meta-facts && ./run.sh check-agy-sender-identity && ./run.sh check-acp-provider-surface && ./run.sh check-acp-stop-reason && ./run.sh check-acp-prompt-lifecycle && ./run.sh check-acp-usage-accounting && ./run.sh check-acp-launch-namespace && ./run.sh check-acp-stream-hooks && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-gate-manifests && ./run.sh check-release-gate-outcomes && ./run.sh check-probe-ordering && ./run.sh check-probe-cli-shim && ./run.sh check-acp-cortex && ./run.sh check-codex-bridge-identity && ./run.sh check-codex-birth-hook && ./run.sh smoke-codex-config-state && ./run.sh smoke-codex-birth",
177
+ "check:vitest": "./run.sh check-mux-fresh-call && ./run.sh check-acp-sdk-surface && ./run.sh check-tests-beside-behavior",
178
+ "check:contracts": "./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-v3-record && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-harness-admission-parity && ./run.sh check-capability-bundle-reach && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-capability-source && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-node-floor-coherence && ./run.sh check-claude-floor-coherence && ./run.sh check-copilot-statusline && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-send && ./run.sh check-compaction-send-guard && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-native-push && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-resume-launch-identity && ./run.sh check-mux-placement && ./run.sh check-mux-launch && ./run.sh check-mux-resume-call && ./run.sh check-mux-parent-artifact && ./run.sh check-mux-launcher-fence && ./run.sh check-entwurf-v2-visible-resume && ./run.sh check-control-socket-path && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-herdr-placement && ./run.sh check-herdr-fresh-call && ./run.sh check-herdr-supply && ./run.sh check-fresh-call-dispatch && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-native-push-adapter && ./run.sh check-native-push-register && ./run.sh check-auth-boundary && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-carrier-augment && ./run.sh check-codex-sender-identity && ./run.sh check-codex-native-push",
179
+ "check:hermetic": "./run.sh check-bridge-delivery && ./run.sh smoke-pi-attach && ./run.sh check-fresh-cut-gate && ./run.sh check-hook-launch-topology && ./run.sh check-meta-hook-session-switch && ./run.sh check-copilot-birth-hook && ./run.sh check-copilot-receive-arm && ./run.sh check-copilot-launch && ./run.sh check-pi-launch && ./run.sh check-codex-app-server-launch && ./run.sh check-meta-identity-consumers && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh check-meta-doctor-oracle && ./run.sh check-agy-permission-matrix && ./run.sh smoke-agy-statusline-state && ./run.sh smoke-copilot-statusline-state && ./run.sh smoke-copilot-mcp-state && ./run.sh smoke-omp-bridge-state && ./run.sh smoke-omp-mcp-state && ./run.sh smoke-omp-receive-state && ./run.sh check-omp-birth-hook && ./run.sh check-omp-receive-arm && ./run.sh check-omp-fresh-preflight && ./run.sh smoke-agy-hooks-state && ./run.sh smoke-pi-provider-state && ./run.sh smoke-user-scope-citizen && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-bridge-boot && ./run.sh check-probe-bridge-command && ./run.sh check-meta-facts && ./run.sh check-peer-facts && ./run.sh check-herdr-plugin && ./run.sh check-herdr-plugin-profile && ./run.sh check-herdr-plugin-build && ./run.sh check-herdr-runtime-bootstrap && ./run.sh check-herdr-activation && ./run.sh check-agy-sender-identity && ./run.sh check-acp-provider-surface && ./run.sh check-acp-stop-reason && ./run.sh check-acp-prompt-lifecycle && ./run.sh check-acp-usage-accounting && ./run.sh check-acp-launch-namespace && ./run.sh check-acp-stream-hooks && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-typing-call-fence && ./run.sh check-gate-manifests && ./run.sh check-release-gate-outcomes && ./run.sh check-probe-ordering && ./run.sh check-probe-cli-shim && ./run.sh check-acp-cortex && ./run.sh check-codex-bridge-identity && ./run.sh check-codex-birth-hook && ./run.sh smoke-codex-config-state && ./run.sh smoke-codex-birth && ./run.sh check-herdr-sandbox",
179
180
  "check:package": "./run.sh check-install-surface && ./run.sh smoke-meta-install-state && ./run.sh smoke-agy-install-state && ./run.sh smoke-setup-verdict && ./run.sh check-pack-pin-matcher && ./run.sh check-package-source-routing && ./run.sh check-install-preflight && ./run.sh check-pack"
180
181
  },
181
182
  "pi": {
@@ -90,6 +90,7 @@ import {
90
90
  } from "./lib/compaction-send-guard.js";
91
91
  import { CONTROL_SOCKET_SUFFIX, controlSocketPathIn, defaultControlSocketDir } from "./lib/control-socket-path.js";
92
92
  import {
93
+ attachAcceptedSocketDisconnectPolicy,
93
94
  formatSenderInfoBlock,
94
95
  type RpcCommand,
95
96
  type RpcResponse,
@@ -220,7 +221,11 @@ function writeResponse(socket: net.Socket, response: RpcResponse): void {
220
221
  try {
221
222
  socket.write(`${JSON.stringify(response)}\n`);
222
223
  } catch {
223
- // Socket may be closed
224
+ // SYNCHRONOUS throws only — a destroyed stream lands here. This catch CANNOT see an
225
+ // asynchronous stream error: an EPIPE on a peer that hung up arrives as an `error` event,
226
+ // and `attachAcceptedSocketDisconnectPolicy`, installed on every accepted connection in
227
+ // `createServer`, is what carries that half of the contract. Reading this catch as full
228
+ // coverage is what let a late response kill a resident session.
224
229
  }
225
230
  }
226
231
 
@@ -841,6 +846,10 @@ async function handleCommand(
841
846
 
842
847
  async function createServer(pi: ExtensionAPI, state: SocketState, socketPath: string): Promise<net.Server> {
843
848
  const server = net.createServer((socket) => {
849
+ // FIRST — before setEncoding, before the data handler, and before any response can be
850
+ // written back. The policy itself (and why this process died without it) lives with the
851
+ // rest of the wire protocol in lib/entwurf-control-rpc.ts.
852
+ attachAcceptedSocketDisconnectPolicy(socket);
844
853
  socket.setEncoding("utf8");
845
854
  let buffer = "";
846
855
  socket.on("data", (chunk) => {
@@ -1032,6 +1041,43 @@ function applyEmacsAgentSocketEnv(pi: ExtensionAPI): void {
1032
1041
  }
1033
1042
  }
1034
1043
 
1044
+ /**
1045
+ * The one-line answer to "I installed entwurf, why is nothing here?" (#116 M3-b3 F).
1046
+ *
1047
+ * `[관측: GLG, 날것 PC, 2026-09-17]` a herdr plugin install wires this extension into pi at USER
1048
+ * scope, so it loads in EVERY pi session on the host — but citizenship is argv-gated on purpose, so
1049
+ * a plain `pi` has no garden id, no socket and no tools. From the outside those two facts are
1050
+ * indistinguishable from "the install did nothing", and the operator has no reason to guess at a
1051
+ * flag nobody showed them. This says it once, at exactly the moment they are looking at the wrong
1052
+ * thing.
1053
+ *
1054
+ * UI ONLY, AND THAT IS THE CONTRACT. The refusal path below writes stderr ALWAYS because a control
1055
+ * surface that failed to come up is a durable fault. This is not that: it is an ordinary,
1056
+ * deliberate state. Writing it to stderr would put a sentence into every `pi -p …` pipeline and
1057
+ * every script on the host to tell a human something no human is reading there.
1058
+ *
1059
+ * WHETHER TO SPEAK IS NOT DECIDED HERE. It is `decideUncitizenedNotice` in the self-address fence,
1060
+ * reached by the same non-literal dynamic import as its sibling — and that placement is a repair,
1061
+ * not taste. `[측정 2026-09-17, 독립 검수 + 재현]` while the three conditions were `if`s at this
1062
+ * call site and the gate pinned them with a source regex, two mutants walked through green: moving
1063
+ * the call into the CITIZEN branch, and deleting the once-latch. A regex sees that a call exists;
1064
+ * it cannot see which branch it sits in or how often it fires. So the call is now UNCONDITIONAL and
1065
+ * carries `controlEnabled` as a fact — the branch is gone, and what is left is a truth table.
1066
+ */
1067
+ let uncitizenedNoticeShown = false;
1068
+ async function noticeUncitizenedSession(ctx: ExtensionContext, controlEnabled: boolean): Promise<void> {
1069
+ const self = (await import(ENTWURF_SELF_ADDRESS_MODULE)) as unknown as EntwurfSelfAddressModule;
1070
+ if (!self.decideUncitizenedNotice({ controlEnabled, hasUI: ctx.hasUI, alreadyShown: uncitizenedNoticeShown })) {
1071
+ return;
1072
+ }
1073
+ uncitizenedNoticeShown = true;
1074
+ ctx.ui.notify(
1075
+ "🪛 entwurf is installed here, but this session is not a garden citizen — start pi with " +
1076
+ "--entwurf-control for a garden id, a control socket and the entwurf tools.",
1077
+ "info",
1078
+ );
1079
+ }
1080
+
1035
1081
  function shouldRegisterControlTools(pi: ExtensionAPI): boolean {
1036
1082
  return pi.getFlag(ENTWURF_FLAG) === true || wasBooleanFlagPassed(ENTWURF_FLAG);
1037
1083
  }
@@ -1106,6 +1152,7 @@ export default function (pi: ExtensionAPI) {
1106
1152
  // non-control session.
1107
1153
  applyEmacsAgentSocketEnv(pi);
1108
1154
  const enabled = pi.getFlag(ENTWURF_FLAG) === true;
1155
+ await noticeUncitizenedSession(ctx, enabled);
1109
1156
  if (!enabled) {
1110
1157
  await stopControlServer(state);
1111
1158
  residentGardenId = null;
@@ -1279,6 +1326,7 @@ type SelfAddressabilityFn = (facts: {
1279
1326
 
1280
1327
  interface EntwurfSelfAddressModule {
1281
1328
  computeSelfAddressability: SelfAddressabilityFn;
1329
+ decideUncitizenedNotice: (facts: { controlEnabled: boolean; hasUI: boolean; alreadyShown: boolean }) => boolean;
1282
1330
  }
1283
1331
 
1284
1332
  /**
@@ -1371,8 +1419,8 @@ A peer entwurf_peers shows as liveness=alive → fire-and-forget. A citizen with
1371
1419
  (liveness=unsupported) is ALSO fire-and-forget — unsupported means only "no control-socket probe" — and the
1372
1420
  decider picks its own rail: a self-fetch backend (e.g. Claude Code) gets the mailbox, a native-push backend
1373
1421
  (e.g. Antigravity) gets direct injection and has NO mailbox at all. THERE IS A THIRD RESULT: the mailbox
1374
- delivers only to a DELIVERABLE citizen, so a terminated session, or a backend with no adapter here (e.g.
1375
- codex), is mailbox-undeliverable, not queued for an inbox nobody drains. The native-push probe is 3-valued:
1422
+ delivers only to a DELIVERABLE citizen, so a terminated self-fetch session is mailbox-undeliverable
1423
+ rather than queued for an inbox nobody drains. The native-push probe is 3-valued:
1376
1424
  alive → injected; dead → native-push-target-dead; indeterminate → native-push-probe-indeterminate
1377
1425
  (unestablished ≠ gone). DORMANT IS UNREACHABLE: a socket-domain citizen that is not running gets
1378
1426
  dormant-fire-forget-unsupported — same receiver rule as the mailbox, no active drainer means no
@@ -1530,16 +1578,15 @@ function registerListSessionsTool(pi: ExtensionAPI): void {
1530
1578
  // Tool: entwurf_fresh_call
1531
1579
  // ============================================================================
1532
1580
 
1533
- const MUX_FRESH_CALL_MODULE = "./lib/mux-fresh-call.ts";
1534
- const CODEX_FRESH_PREFLIGHT_MODULE = "./lib/codex-fresh-preflight.ts";
1535
-
1536
- interface CodexFreshPreflightModule {
1537
- codexFreshPreflight(env: NodeJS.ProcessEnv): Promise<string | null>;
1538
- }
1581
+ // ONE dynamic import, and it resolves the composition root rather than a rail: which rail opens
1582
+ // the sibling, when the Codex preflight runs, and how the receipt renders are decisions this
1583
+ // surface must not own a second copy of (#116 C3). The import stays non-literal-free and lazy for
1584
+ // the same startup-fence reason it always was.
1585
+ const FRESH_CALL_DISPATCH_MODULE = "./lib/fresh-call-dispatch.ts";
1539
1586
 
1540
- interface MuxFreshCallModule {
1541
- freshCall(
1542
- params: {
1587
+ interface FreshCallDispatchModule {
1588
+ dispatchFreshCall(
1589
+ request: {
1543
1590
  backend: "pi" | "claude-code" | "copilot" | "omp" | "codex";
1544
1591
  model: string;
1545
1592
  task: string;
@@ -1548,8 +1595,11 @@ interface MuxFreshCallModule {
1548
1595
  callerGardenId: string | null;
1549
1596
  },
1550
1597
  env?: NodeJS.ProcessEnv,
1551
- ): { ok: boolean };
1552
- renderFreshCall(result: { ok: boolean }): { text: string; isError: boolean };
1598
+ ): Promise<{ rail: "herdr" | "tmux"; result: { ok: boolean } }>;
1599
+ renderDispatchedFreshCall(dispatched: { rail: "herdr" | "tmux"; result: { ok: boolean } }): {
1600
+ text: string;
1601
+ isError: boolean;
1602
+ };
1553
1603
  }
1554
1604
 
1555
1605
  /**
@@ -1570,27 +1620,25 @@ function registerFreshCallTool(pi: ExtensionAPI): void {
1570
1620
  registerTool({
1571
1621
  name: "entwurf_fresh_call",
1572
1622
  label: "Open Fresh Sibling",
1573
- description: `Open ONE fresh visible sibling in the operator's tmux and hand it a first task. Five fixed
1574
- backends only: pi, claude-code, copilot, omp, codex. The sibling's FIRST action is a callback to you carrying a nonce, and the
1575
- sender envelope of that callback is its garden id that is how you learn the address of something that did
1576
- not exist a moment ago. This returns a LAUNCH receipt (tmux window/pane plus that nonce) and nothing else:
1577
- it does NOT mean the runtime started, the first turn ran, or the task was delivered. Nothing polls for the
1578
- callback; if it never arrives the window is visible and can be read directly. For EXISTING citizens use
1579
- entwurf_v2 this tool only creates, and entwurf_peers only reports. Model is REQUIRED and passed to the
1580
- chosen runtime CLI (provider/model for pi; model id/alias for Claude Code; a Copilot, OMP, or Codex model
1581
- name). Copilot, omp, and codex are refused BEFORE any window opens when their required birth, MCP,
1582
- receive/delivery, or visible-identity units are absent. Codex additionally requires the operator-owned
1583
- default app-server socket; entwurf never starts or supervises it. An optional cwd starts the
1584
- sibling in ONE literal absolute existing directory (cross-repo fresh) — never pick resume for a dormant
1585
- record's cwd; resume is continuity-only. Omitted/empty cwd means the caller's own directory. An optional
1586
- placement.tmuxSession is an expert override naming ONE EXISTING session on this agent's own tmux server and
1587
- ALWAYS wins. Omitted, the seat follows the CALLER, never the backend being opened: every caller on this
1588
- surface opens in its own session. A missing named session is tmux-session-missing and NOTHING is created. There are no
1589
- arbitrary command/env knobs. Do not put secrets in the task — model and task argv are visible to same-user
1590
- processes on this host.`,
1623
+ description: `Open ONE fresh visible sibling beside you and hand it a first task. WHERE it opens is decided by
1624
+ where THIS agent runs, never by a parameter: inside herdr (HERDR_ENV=1) it opens a NEW UNFOCUSED TAB in your own
1625
+ herdr workspace and only pi and claude-code may be opened; everywhere else it opens in the operator's tmux with all five backends (pi, claude-code,
1626
+ copilot, omp, codex). There is no fallback an incomplete herdr context is refused by name rather than opening a tmux
1627
+ window you cannot see from in herdr. The sibling's FIRST action is a callback to you carrying a nonce, whose sender
1628
+ envelope is its garden id that is how you learn the address of a thing that did not exist a moment ago. This returns
1629
+ a LAUNCH receipt (the owner's coordinates plus that nonce) and nothing else: it does NOT mean the runtime started, the
1630
+ first turn ran, or the task was delivered. Those coordinates are a VIEW, never an address — a herdr tab/pane id can
1631
+ change under a running sibling. Nothing polls; if the callback never arrives the tab is visible. For EXISTING citizens use entwurf_v2 — this tool only creates, and entwurf_peers only reports. Model is REQUIRED and passed to the chosen runtime CLI (provider/model for pi, an id/alias for Claude Code, a name or pattern
1632
+ for the rest). On tmux, copilot/omp/codex are refused BEFORE any window opens when their birth, MCP, receive or
1633
+ visible-identity units are absent, and codex also needs the operator-owned app-server socket entwurf never starts; in
1634
+ herdr those three are refused by name first. Optional
1635
+ placement.tmuxSession is a TMUX-ONLY seat naming ONE EXISTING session and ALWAYS wins; omitted, the seat
1636
+ follows the CALLER, and on this surface that is always your own session a missing one is
1637
+ tmux-session-missing and NOTHING is created. In herdr the field is refused by name. Do not put secrets in the task model and task argv are visible to same-user processes.`,
1591
1638
  parameters: Type.Object({
1592
1639
  backend: StringEnum(["pi", "claude-code", "copilot", "omp", "codex"], {
1593
- description: "Which fixed runtime to open. Only these five; there is no arbitrary command.",
1640
+ description:
1641
+ "Which fixed runtime to open. Only these five, and only pi/claude-code when this agent runs inside herdr; there is no arbitrary command.",
1594
1642
  }),
1595
1643
  model: Type.String({
1596
1644
  minLength: 1,
@@ -1608,7 +1656,7 @@ processes on this host.`,
1608
1656
  cwd: Type.Optional(
1609
1657
  Type.String({
1610
1658
  description:
1611
- "Optional literal ABSOLUTE path of an existing directory to start the sibling in (cross-repo fresh). Omit or pass \"\" to start in this agent's own cwd. Taken exactly as given no trim, no realpath, no project-name resolution; '#' is refused (tmux format expansion). The receipt echoes what was REQUESTED, never an observation.",
1659
+ "Optional literal ABSOLUTE path of an existing directory to start the sibling in (cross-repo fresh). Omit or pass \"\" to start in this agent's own cwd — on BOTH rails. Taken exactly as given: no trim, no realpath, no project-name resolution. '#' is refused on the tmux rail only, because tmux format-expands a start directory; inside herdr it is an ordinary path character. The receipt echoes what was REQUESTED, never an observation.",
1612
1660
  }),
1613
1661
  ),
1614
1662
  placement: Type.Optional(
@@ -1621,7 +1669,7 @@ processes on this host.`,
1621
1669
  },
1622
1670
  {
1623
1671
  description:
1624
- "Optional expert seat override: open the sibling in ONE EXISTING tmux session of this agent's own server, and it always wins. When omitted the seat follows the CALLER, never the backend being opened: every caller on THIS surface opens in its own session (a pi session is never a Codex citizen, so the Codex caller-pane rule the MCP bridge carries cannot arise here). Nothing is ever created. Independent of cwd; neither is inferred from the other. The receipt reports the selected name, its source, and resolved target session id.",
1672
+ "Optional expert seat override, TMUX ONLY: open the sibling in ONE EXISTING tmux session of this agent's own server, and it always wins. When omitted the seat follows the CALLER, never the backend being opened: every caller on THIS surface opens in its own session (a pi session is never a Codex citizen, so the Codex caller-pane rule the MCP bridge carries cannot arise here). Nothing is ever created. Inside herdr this field is refused by name — placement there belongs to herdr, and a tmux session name would silently place the sibling somewhere else. Independent of cwd; neither is inferred from the other. The receipt reports the selected name, its source, and resolved target session id.",
1625
1673
  },
1626
1674
  ),
1627
1675
  ),
@@ -1640,29 +1688,20 @@ processes on this host.`,
1640
1688
  _ctx: ExtensionContext,
1641
1689
  ) {
1642
1690
  try {
1643
- const mux = (await import(MUX_FRESH_CALL_MODULE)) as unknown as MuxFreshCallModule;
1644
- // ONE input object for ONE composition call. The codex branch differs only by the
1645
- // capability preflight that must answer BEFORE any mutation; duplicating the call
1646
- // would put the caller-identity contract in two places, which is how a mutant that
1647
- // plants a defect in one of them survives on the other.
1648
- const call = {
1691
+ const dispatch = (await import(FRESH_CALL_DISPATCH_MODULE)) as unknown as FreshCallDispatchModule;
1692
+ // ONE input object for ONE dispatch call. Rail selection and the Codex preflight
1693
+ // ordering live behind it: duplicating either here would put the same decision in
1694
+ // two places, which is how a mutant that plants a defect in one of them survives
1695
+ // on the other.
1696
+ const dispatched = await dispatch.dispatchFreshCall({
1649
1697
  backend: params.backend,
1650
1698
  model: params.model,
1651
1699
  task: params.task,
1652
1700
  cwd: params.cwd,
1653
1701
  placement: params.placement,
1654
1702
  callerGardenId: residentGardenId,
1655
- };
1656
- const result =
1657
- params.backend === "codex"
1658
- ? await (async () => {
1659
- const preflight = (await import(CODEX_FRESH_PREFLIGHT_MODULE)) as unknown as CodexFreshPreflightModule;
1660
- const missing = await preflight.codexFreshPreflight(process.env);
1661
- if (missing) return { ok: false as const, reason: missing };
1662
- return mux.freshCall(call);
1663
- })()
1664
- : mux.freshCall(call);
1665
- const rendered = mux.renderFreshCall(result);
1703
+ });
1704
+ const rendered = dispatch.renderDispatchedFreshCall(dispatched);
1666
1705
  return {
1667
1706
  content: [{ type: "text", text: rendered.text }],
1668
1707
  isError: rendered.isError,