@junghanacs/entwurf 0.23.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/BASELINE.md +1 -0
  2. package/CHANGELOG.md +628 -0
  3. package/DELIVERY.md +24 -1
  4. package/FAQ.md +47 -0
  5. package/README.md +5 -3
  6. package/VERIFY.md +20 -2
  7. package/demo/README.md +5 -2
  8. package/docs/acp-backend-rail.md +49 -2
  9. package/docs/setup-clean-host.md +8 -7
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +38 -5
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +19 -5
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/callback-env.js +68 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-send-receipt.js +101 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +17 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +4 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +53 -6
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +38 -3
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +54 -22
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +20 -6
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +24 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +19 -10
  23. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +35 -42
  24. package/mcp/entwurf-bridge/dist/protocol.js +0 -7
  25. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +40 -3
  26. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +72 -0
  27. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  28. package/mcp/entwurf-bridge/src/index.ts +46 -5
  29. package/mcp/entwurf-bridge/test.sh +2 -2
  30. package/package.json +11 -9
  31. package/pi-extensions/entwurf-control.ts +83 -154
  32. package/pi-extensions/lib/acp/backend.ts +16 -6
  33. package/pi-extensions/lib/acp/context.ts +30 -10
  34. package/pi-extensions/lib/acp/session-store.ts +2 -2
  35. package/pi-extensions/lib/callback-env.ts +78 -0
  36. package/pi-extensions/lib/compaction-send-guard.ts +6 -1
  37. package/pi-extensions/lib/control-send-receipt.ts +108 -0
  38. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  39. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -2
  40. package/pi-extensions/lib/entwurf-v2-production.ts +19 -1
  41. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -1
  42. package/pi-extensions/lib/entwurf-v2-send.ts +82 -6
  43. package/pi-extensions/lib/entwurf-v2-surface.ts +42 -3
  44. package/pi-extensions/lib/fresh-call-composition.ts +56 -22
  45. package/pi-extensions/lib/herdr-fresh-call.ts +25 -7
  46. package/pi-extensions/lib/herdr-placement.ts +24 -4
  47. package/pi-extensions/lib/mux-fresh-call.ts +23 -8
  48. package/pi-extensions/meta-bridge-omp.ts +35 -48
  49. package/protocol.js +0 -8
  50. package/run.sh +306 -53
  51. package/scripts/check-acp-backend-preflight.ts +117 -9
  52. package/scripts/check-acp-carrier-augment.ts +6 -4
  53. package/scripts/check-acp-event-mapper.ts +17 -6
  54. package/scripts/check-acp-prompt-builder.ts +17 -9
  55. package/scripts/check-acp-prompt-lifecycle.ts +23 -6
  56. package/scripts/check-acp-session-reuse.ts +64 -51
  57. package/scripts/check-acp-session-store.ts +31 -8
  58. package/scripts/check-acp-stop-reason.ts +20 -2
  59. package/scripts/check-acp-stream-hooks.ts +22 -5
  60. package/scripts/check-acp-usage-accounting.ts +33 -9
  61. package/scripts/check-entwurf-bridge-boot.ts +16 -1
  62. package/scripts/check-entwurf-v2-surface.ts +289 -10
  63. package/scripts/check-fresh-call-dispatch.ts +4 -5
  64. package/scripts/check-gate-qualification.ts +51 -9
  65. package/scripts/check-herdr-activation.ts +73 -0
  66. package/scripts/check-herdr-fresh-call.ts +30 -6
  67. package/scripts/check-herdr-placement.ts +51 -0
  68. package/scripts/check-herdr-plugin-build.ts +11 -4
  69. package/scripts/check-herdr-plugin.ts +745 -38
  70. package/scripts/check-herdr-sandbox.ts +14 -2
  71. package/scripts/check-install-container.sh +1 -1
  72. package/scripts/check-mux-launcher-fence.ts +37 -0
  73. package/scripts/check-pi-launch.ts +8 -2
  74. package/scripts/check-release-gate-outcomes.ts +165 -0
  75. package/scripts/doctor-pi-provider.ts +44 -4
  76. package/scripts/herdr-rails.mjs +75 -0
  77. package/scripts/lib/mutation-qualify.ts +1 -1
  78. package/scripts/meta-bridge-state.py +18 -1
  79. package/scripts/mutants/acp-backend-preflight.json +20 -0
  80. package/scripts/mutants/bridge-boot-resume.json +26 -0
  81. package/scripts/mutants/bridge-command-boot.json +38 -0
  82. package/scripts/mutants/control-send-receipt.json +235 -0
  83. package/scripts/mutants/herdr-activation.json +16 -0
  84. package/scripts/mutants/herdr-fresh-call.json +15 -4
  85. package/scripts/mutants/herdr-plugin-build.json +7 -11
  86. package/scripts/mutants/herdr-plugin.json +177 -9
  87. package/scripts/mutants/meta-retire.json +22 -0
  88. package/scripts/mutants/mux-fresh-call.json +21 -8
  89. package/scripts/mutants/omp-fresh.json +5 -9
  90. package/scripts/mutants/pack-install.json +4 -4
  91. package/scripts/mutants/pi-floor.json +17 -0
  92. package/scripts/mutants/release-gate.json +48 -0
  93. package/scripts/mutants/retired-sent-surface.json +32 -0
  94. package/scripts/mutants/v2-surface.json +7 -7
  95. package/scripts/probe-bridge-command.ts +1 -0
  96. package/scripts/smoke-acp-session-reuse-live.ts +23 -5
  97. package/scripts/smoke-agy-install-state.sh +2 -2
  98. package/scripts/smoke-codex-fresh-live.ts +94 -13
  99. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  100. package/scripts/smoke-herdr-fresh-call-live.ts +71 -36
  101. package/scripts/smoke-herdr-raw-install-live.sh +90 -5
  102. package/scripts/smoke-meta-install-state.sh +107 -1
  103. package/scripts/smoke-mux-lifecycle-live.ts +9 -2
  104. package/scripts/smoke-omp-fresh-live.ts +4 -2
  105. package/scripts/smoke-omp-mcp-state.sh +1 -1
  106. package/scripts/smoke-pi-provider-state.sh +108 -2
  107. package/scripts/smoke-setup-verdict.sh +18 -2
package/DELIVERY.md CHANGED
@@ -27,7 +27,7 @@ API. The levels compare capabilities, not implementation shapes.
27
27
 
28
28
  | State | Meaning |
29
29
  |---|---|
30
- | `queued` | A message is durable; the backend may not have seen it. |
30
+ | `queued` | A receiver queue accepted the message; the backend may not have seen it. Durability is a property of the RAIL, not of this word — see the acceptance table below. |
31
31
  | `triggered` | A supported event/API accepted the signal. |
32
32
  | `woke` | An idle interactive session began a turn without user typing. |
33
33
  | `injected` | The message reached model-visible context. |
@@ -36,6 +36,29 @@ API. The levels compare capabilities, not implementation shapes.
36
36
 
37
37
  Avoid bare `delivered`; name the observed boundary.
38
38
 
39
+ ### Control-socket acceptance boundaries (#120)
40
+
41
+ A control-socket send answers with ONE of these, and the sender renders that word rather than the
42
+ internal route it took. They are not four spellings of success: they differ on every axis an
43
+ operator has to reason about.
44
+
45
+ | Boundary | Order | Durability | Visibility | Rejection |
46
+ |---|---|---|---|---|
47
+ | `sent` | n/a — the receiver was idle and the turn was TRIGGERED directly | none needed; the turn started | the turn is running, but the model has NOT necessarily read the text yet, and nothing here says the turn completed | an in-band refusal is `rejected`, never this |
48
+ | `queued-steer` | drained after each turn; overtakes every `queued-follow-up` already waiting | **none** — volatile receiver process memory | nothing shows it: it is not in the TUI's pending-message count | an abort clears the queue and the message is gone, silently |
49
+ | `queued-follow-up` | drained only when the receiver's inner loop ends; a steer arriving later still goes first | **none** — volatile receiver process memory | same: invisible in the receiver's own queue display | same: an abort drops it |
50
+ | `accepted-unknown-boundary` | unknown | unknown | unknown | the receiver ACCEPTED; this names our inability to classify its answer (version skew), and it must never be retried or rendered as `sent` |
51
+
52
+ `[측정 2026-09-22, pi-agent-core 0.87.0; 재측정 2026-09-23, 0.87.1 — 좌표 동일]` `dist/agent.js:60-71,96-97,137-138`
53
+ (two independent `one-at-a-time` queues, no FIFO between them)
54
+ and `dist/agent-loop.js:85,186,191-197`
55
+ (steering drained every turn, follow-ups only once the inner loop ends).
56
+
57
+ The **meta-mailbox** rail is the contrast, not a fifth boundary: it writes a durable `*.msg` before
58
+ ringing any doorbell, its filenames are ISO stamps so a drain is a global FIFO, an undrained inbox
59
+ is an honest `mailbox-undeliverable` refusal, and its receipt is that FILE. A mailbox enqueue is a
60
+ durable file receipt; a control-socket queue is process memory.
61
+
39
62
  ## Levels (D0–D8)
40
63
 
41
64
  These are independent of VERIFY's `L0–L5` evidence levels and BASELINE's question
package/FAQ.md ADDED
@@ -0,0 +1,47 @@
1
+ # Entwurf FAQ
2
+
3
+ Short answers for people meeting Entwurf from a different agent-harness tradition.
4
+
5
+ ## Is Entwurf an agent factory or a subagent framework?
6
+
7
+ No. Entwurf is closer to a workshop than a factory: it connects independently owned, visible sibling sessions without turning them into workers of a new central runtime. A harness may run its own subagents or teams, but that remains the harness's responsibility.
8
+
9
+ ## What does Entwurf do?
10
+
11
+ It gives an existing session a stable **garden id** and routes a message, reply, or visible sibling launch through the rail that session actually supports. It does not reconstruct prompts, hydrate transcripts, manage credentials, or emulate another harness.
12
+
13
+ ## What is a garden id?
14
+
15
+ A garden id is the stable address of one visible, record-backed session. Think of it as an invitation to a shared workshop table, not a worker name, a pane id, a transcript id, or a worktree lease.
16
+
17
+ ## How are session state and project state separated?
18
+
19
+ Entwurf does not merge or isolate either one. Each harness keeps its own authentication, tools, transcript, and workspace policy. Git branches, worktrees, file locks, task allocation, and any coordination around a shared checkout belong to the operator and the harnesses involved; a garden id is never a file-isolation claim.
20
+
21
+ ## What happens to a harness's internal subagents?
22
+
23
+ The visible top-level session is the garden principal. Internal children remain inside that harness and do not receive a second garden id, separate delivery authority, or an Entwurf-managed access-control layer.
24
+
25
+ ## Does sending a message start a process?
26
+
27
+ No. [`entwurf_v2`](./README.md#entwurf_v2--canonical-dispatch-verb) only addresses an existing garden citizen and selects its supported delivery rail. `entwurf_fresh_call` opens a new visible sibling; `entwurf_resume_call` reopens a dormant pi citizen under the same id.
28
+
29
+ ## Where do visible siblings open?
30
+
31
+ Inside Herdr, Entwurf opens a new unfocused tab in the caller's workspace and currently admits pi and Claude Code. Outside Herdr, it opens one of the fixed pi, Claude Code, Copilot, OMP, or Codex runtimes in a visible tmux window. The runtime keeps its own model, tools, authentication, and transcript.
32
+
33
+ ## Does Entwurf replace Herdr or tmux?
34
+
35
+ No. Herdr and tmux provide visible seats. Entwurf provides identity, delivery, and receipts across independent harness sessions. A pane or tmux session is a location for a sibling, never its address or liveness proof.
36
+
37
+ ## How do I install it?
38
+
39
+ Use the [direct installation route](./README.md#install) for the broader harness surface. In an existing Herdr workbench, use the [Herdr integration guide](./plugins/herdr/README.md); it activates only Herdr-integrated pi and Claude Code and leaves the rest to the direct route.
40
+
41
+ ## How do I start pi as a citizen?
42
+
43
+ After the direct install, run `entwurf pi`; it starts pi with Entwurf's control surface. The Herdr integration does not put `entwurf` on `PATH`, so its documented command remains `pi --entwurf-control`.
44
+
45
+ ## Where is the precise contract?
46
+
47
+ [AGENTS.md](./AGENTS.md) owns the project invariants. [DELIVERY.md](./DELIVERY.md), [VERIFY.md](./VERIFY.md), and [docs/adding-a-harness.md](./docs/adding-a-harness.md) own capability, evidence, and admission details.
package/README.md CHANGED
@@ -8,6 +8,8 @@
8
8
 
9
9
  npm package: <https://www.npmjs.com/package/@junghanacs/entwurf>
10
10
 
11
+ **Short answers for common questions:** [FAQ.md](./FAQ.md).
12
+
11
13
  Legacy package: [`@junghanacs/pi-shell-acp`](https://www.npmjs.com/package/@junghanacs/pi-shell-acp). `entwurf` is its 0.12+ successor line: the same work renamed around the garden-citizen dispatch substrate rather than the pi adapter.
12
14
 
13
15
  > **Repository shape.** This repo is **entwurf-core (v2 dispatch) + native-harness bridges + a pi adapter + an ACP plugin**. Pi is one adapter, not the project subject. Claude Code, GitHub Copilot CLI, and OMP (`omp`) are shipped self-fetch citizens; Antigravity (`agy`) is shipped native-push. Codex CLI is a **native-push citizen, supported in 0.21.0 on Linux** over the operator-owned app-server: vendor-trusted user-scope birth, strict request-scoped identity, loaded-thread probe, one-shot queue delivery, and visible fresh beside the CALLER's own pane. Unrestricted attached-TUI seat inference is not supported or claimed; #95 D1 retired the fixed `codex` tmux home on 2026-09-16, so the operator seats the app-server wherever they like and a Codex caller opens its sibling beside its own TUI pane. Codex remains native so it keeps its vendor tools, delegation, and work context; this is not another way to buy or expose GPT through ACP.
@@ -252,7 +254,7 @@ cd ~/repos/gh/entwurf
252
254
 
253
255
  The full source setup requires Node 24, pnpm, and Python 3 on PATH. Harnesses are
254
256
  optional-by-presence: `setup` runs the frozen dependency install, then composes what the operator
255
- already installed — a compatible `pi` (`>=0.85.1 <0.86`), Claude Code, agy, and the Copilot CLI
257
+ already installed — a compatible `pi` (`>=0.87.1 <0.88`), Claude Code, agy, and the Copilot CLI
256
258
  each get their wiring completed when detected, an absent harness is an explicit zero-state SKIP,
257
259
  and a detected harness that cannot be completed (including a below-floor `pi`) is a named FAIL
258
260
  that makes setup exit nonzero. `setup` never installs a harness binary or touches a credential
@@ -286,7 +288,7 @@ This rail is CERTIFIED on Linux; on macOS it is NOT CERTIFIED — pending
286
288
  physical host (control sockets, mux, ACP plugin hosting).
287
289
 
288
290
  To use the `entwurf` provider inside pi, install a compatible pi binary
289
- separately (`@earendil-works/pi-coding-agent >=0.85.1 <0.86`). Then point pi at
291
+ separately (`@earendil-works/pi-coding-agent >=0.87.1 <0.88`). Then point pi at
290
292
  the npm-installed package or development clone:
291
293
 
292
294
  ```bash
@@ -636,7 +638,7 @@ A two-pane recording covers the pre-0.12 v1 surface end-to-end — sibling resum
636
638
 
637
639
  Live peer messaging carries a sender envelope `{ sessionId, agentId, cwd, timestamp, origin?, replyable? }`; `entwurf_self` returns that authoritative envelope for the current pi session or trusted meta-session. Plain external MCP hosts are non-replyable. A garden-native meta-session carries a trusted `meta-session` envelope, but **`replyable` is a fact its own rail decides, not a consequence of being trusted** — a self-fetch citizen needs a live armed receiver, a native-push citizen needs an alive adapter probe, and a pi session needs its control socket. `entwurf_self` also reports which rail a meta-session reply would ride, because a native-push citizen has no mailbox to name. `wants_reply` is an etiquette marker rendered as a `(wants reply)` badge — not a transport contract, no wait, no polling. **v2 never gates on it:** a `wants_reply` from an external/non-replyable caller is passed through and surfaced honestly beside that sender's `replyable: false`, not rejected — the decider routes on target + intent, never on sender replyability. (The retired v1 `entwurf_send` did reject it; that behaviour went with the verb.)
638
640
 
639
- In ACP-backed and external native-harness sessions, `entwurf-bridge` exposes seven tools: `entwurf_v2`, `entwurf_peers`, `entwurf_fresh_call`, `entwurf_resume_call`, `entwurf_self`, `entwurf_inbox_read`, and the explicit/manual `entwurf_register_native` fallback. Native pi exposes the shared capability directly through the extension surface (`entwurf_v2`, `entwurf_peers`, `entwurf_fresh_call`, `entwurf_resume_call` tools; the socket-scan `/entwurf-sessions` command is gone — #50 C4). **For garden-id delivery/reply use `entwurf_v2`** — the canonical surface that classifies the target and routes to live-pi / self-fetch meta-mailbox (Claude Code, Copilot, OMP) / native-push (Antigravity, Codex), and refuses a dormant target instead of waking it. **To open a sibling that does not exist yet use `entwurf_fresh_call`** — WHERE it opens is a fact about the calling process, not a parameter: inside herdr (`HERDR_ENV=1`) it opens a NEW TAB in the caller's own workspace, without taking focus, and admits only `pi` and `claude-code`, refusing `placement` by name; everywhere else it launches one of five fixed backends (`pi`, `claude-code`, `copilot`, `omp`, `codex`) as a visible window on the operator's own tmux server. There is no fallback between the two — an incomplete herdr context is refused by its herdr reason rather than quietly opening a tmux window nobody inside herdr can see. Everything that follows in this paragraph describes the tmux rail; the herdr rail's coordinates, refusals and reclaim are owned by [docs/herdr-launch-rail.md](./docs/herdr-launch-rail.md). On tmux it requires an explicit model, accepts one optional literal absolute `cwd` (omit it or pass `""` to use the caller's cwd) and one optional `placement.tmuxSession` naming an EXISTING session on that server to open the sibling in instead of the caller's own (the operator's project seat — nothing is ever created: an absent session is `tmux-session-missing` and a name outside the addressed grammar `[A-Za-z0-9][A-Za-z0-9_-]*` is `tmux-session-name-invalid`, with no window and no session left behind. The two inputs never infer each other, and the positive form of that matters more than the prohibition: **with `cwd` omitted, a seated sibling starts in THIS agent's own working directory**, measured — it does not inherit the target session's path or its active pane, so naming the `org` seat does not put the sibling in the `org` project. Pass `cwd` when you want a different directory. entwurf never resolves that path: it hands the literal string to tmux, so with a SYMLINKED directory the receipt echoes what you asked for while the record, the transcript path, the callback envelope and `pane_current_path` all show the path the OS resolved it to — two spellings of one place, and the resolved one is what `entwurf_resume_call` later reopens), passes the model and selected directory through the runtime's visible launch path, and hands it a first task; a `copilot` launch goes through entwurf's own managed invocation and is refused before any window opens if this host lacks the Copilot birth, MCP, receiver or visible-footer units, and an `omp` launch carries its task in the `--entwurf-bootstrap` payload instead of an argv prompt because that vendor connects its MCP tools after the session has already started. Omitted placement follows the CALLER rather than the backend being opened: a Codex CALLER opens its sibling beside its own TUI pane, located by the `thread-id` that pane's terminal title carries (0 or 2+ matching panes reject with no fallback); every other caller opens in its own seat. (0.21.0 shipped a backend-selected default instead — an omitted-placement `codex` TARGET resolved a fixed operator-owned session named `codex` — and #95 D1 retired it on 2026-09-16.) Any of the five can still be sent to an operator-owned existing seat with an explicit `placement.tmuxSession`, and named `claude`/`pi` seats remain available that way, but they are not omitted-placement defaults. Read tmux here as the VISIBLE LAUNCH SEAT, never as an address: the garden id is the address, and it arrives in the callback envelope. Sibling symmetry means each backend keeps its own transcript, auth, native tools and visibility — not that the transports are made identical. The sibling's first action is a nonce callback whose sender envelope carries its garden id. The launch receipt records the requested model/cwd/seat plus tmux coordinates — including the RESOLVED target session id, which is where the window actually is — and never claims that the runtime accepted them or completed delivery. (The v1 verbs `entwurf` / `entwurf_resume` / `entwurf_send` are gone.) Garden-native operator commands require `--entwurf-control`. There is no spawn target allowlist — the target registry is gone (#50 C3): `entwurf_v2` and `entwurf_resume_call` address an existing record-backed citizen, while `entwurf_fresh_call` takes its explicit backend/model/task and optional cwd directly rather than resolving a model tuple from a registry file. **To reopen a DORMANT pi citizen use `entwurf_resume_call {target}`** — the record supplies transcript, model, provider and cwd, so it takes no prompt, no task and no model override; it runs no turn, returns a LAUNCH receipt and a separate OBSERVATION receipt (only the second says the citizen is back), refuses a non-pi target as `target-not-pi`, and on an unobserved socket leaves the visible window open and releases its lock rather than retrying.
641
+ In ACP-backed and external native-harness sessions, `entwurf-bridge` exposes eight tools: `entwurf_v2`, `entwurf_peers`, `entwurf_fresh_call`, `entwurf_resume_call`, `entwurf_callback`, `entwurf_self`, `entwurf_inbox_read`, and the explicit/manual `entwurf_register_native` fallback. Native pi exposes the shared capability directly through the extension surface (`entwurf_v2`, `entwurf_peers`, `entwurf_fresh_call`, `entwurf_resume_call`, `entwurf_callback` tools; the socket-scan `/entwurf-sessions` command is gone — #50 C4). **For garden-id delivery/reply use `entwurf_v2`** — the canonical surface that classifies the target and routes to live-pi / self-fetch meta-mailbox (Claude Code, Copilot, OMP) / native-push (Antigravity, Codex), and refuses a dormant target instead of waking it. **To open a sibling that does not exist yet use `entwurf_fresh_call`** — WHERE it opens is a fact about the calling process, not a parameter: inside herdr (`HERDR_ENV=1`) it opens a NEW TAB in the caller's own workspace, without taking focus, and admits only `pi` and `claude-code`, refusing `placement` by name; everywhere else it launches one of five fixed backends (`pi`, `claude-code`, `copilot`, `omp`, `codex`) as a visible window on the operator's own tmux server. There is no fallback between the two — an incomplete herdr context is refused by its herdr reason rather than quietly opening a tmux window nobody inside herdr can see. Everything that follows in this paragraph describes the tmux rail; the herdr rail's coordinates, refusals and reclaim are owned by [docs/herdr-launch-rail.md](./docs/herdr-launch-rail.md). On tmux it requires an explicit model, accepts one optional literal absolute `cwd` (omit it or pass `""` to use the caller's cwd) and one optional `placement.tmuxSession` naming an EXISTING session on that server to open the sibling in instead of the caller's own (the operator's project seat — nothing is ever created: an absent session is `tmux-session-missing` and a name outside the addressed grammar `[A-Za-z0-9][A-Za-z0-9_-]*` is `tmux-session-name-invalid`, with no window and no session left behind. The two inputs never infer each other, and the positive form of that matters more than the prohibition: **with `cwd` omitted, a seated sibling starts in THIS agent's own working directory**, measured — it does not inherit the target session's path or its active pane, so naming the `org` seat does not put the sibling in the `org` project. Pass `cwd` when you want a different directory. entwurf never resolves that path: it hands the literal string to tmux, so with a SYMLINKED directory the receipt echoes what you asked for while the record, the transcript path, the callback envelope and `pane_current_path` all show the path the OS resolved it to — two spellings of one place, and the resolved one is what `entwurf_resume_call` later reopens), passes the model and selected directory through the runtime's visible launch path, and hands it a first task; a `copilot` launch goes through entwurf's own managed invocation and is refused before any window opens if this host lacks the Copilot birth, MCP, receiver or visible-footer units, and an `omp` launch carries its task in the `--entwurf-bootstrap` payload instead of an argv prompt because that vendor connects its MCP tools after the session has already started. Omitted placement follows the CALLER rather than the backend being opened: a Codex CALLER opens its sibling beside its own TUI pane, located by the `thread-id` that pane's terminal title carries (0 or 2+ matching panes reject with no fallback); every other caller opens in its own seat. (0.21.0 shipped a backend-selected default instead — an omitted-placement `codex` TARGET resolved a fixed operator-owned session named `codex` — and #95 D1 retired it on 2026-09-16.) Any of the five can still be sent to an operator-owned existing seat with an explicit `placement.tmuxSession`, and named `claude`/`pi` seats remain available that way, but they are not omitted-placement defaults. Read tmux here as the VISIBLE LAUNCH SEAT, never as an address: the garden id is the address, and it arrives in the callback envelope. Sibling symmetry means each backend keeps its own transcript, auth, native tools and visibility — not that the transports are made identical. The sibling's first action is a nonce callback whose sender envelope carries its garden id. The launch receipt records the requested model/cwd/seat plus tmux coordinates — including the RESOLVED target session id, which is where the window actually is — and never claims that the runtime accepted them or completed delivery. (The v1 verbs `entwurf` / `entwurf_resume` / `entwurf_send` are gone.) Garden-native operator commands require `--entwurf-control`. There is no spawn target allowlist — the target registry is gone (#50 C3): `entwurf_v2` and `entwurf_resume_call` address an existing record-backed citizen, while `entwurf_fresh_call` takes its explicit backend/model/task and optional cwd directly rather than resolving a model tuple from a registry file. **To reopen a DORMANT pi citizen use `entwurf_resume_call {target}`** — the record supplies transcript, model, provider and cwd, so it takes no prompt, no task and no model override; it runs no turn, returns a LAUNCH receipt and a separate OBSERVATION receipt (only the second says the citizen is back), refuses a non-pi target as `target-not-pi`, and on an unobserved socket leaves the visible window open and releases its lock rather than retrying.
640
642
 
641
643
  ### `entwurf_v2` — canonical dispatch verb
642
644
 
package/VERIFY.md CHANGED
@@ -5,7 +5,7 @@ invariants live in gates; this file defines evidence strength, release acceptanc
5
5
  and the manual judgements a gate cannot make.
6
6
 
7
7
  > **Current surface.** `entwurf-bridge` exposes `entwurf_v2`, `entwurf_peers`,
8
- > `entwurf_fresh_call`, `entwurf_resume_call`, `entwurf_self`, `entwurf_inbox_read`, and `entwurf_register_native`. The ACP
8
+ > `entwurf_fresh_call`, `entwurf_resume_call`, `entwurf_callback`, `entwurf_self`, `entwurf_inbox_read`, and `entwurf_register_native`. The ACP
9
9
  > backends are Claude and Snowflake Cortex Code. Antigravity is a separate shipped
10
10
  > native-push citizen lane. Codex is a native-push citizen supported in 0.21.0, whose birth hook is
11
11
  > operator-owned and gated on one vendor trust receipt, with strict request-scoped identity and
@@ -132,6 +132,24 @@ that moved out of `scripts/` and into a file beside its subject would otherwise
132
132
  > `LIVE=1 ENTWURF_CODEX_APP_SERVER_PID=<existing-app-server-pid>
133
133
  > ENTWURF_CODEX_FRESH_MODEL=<codex-model>
134
134
  > ENTWURF_CODEX_FRESH_PI_MODEL=<pi-model> ./run.sh smoke-codex-fresh-live`.
135
+ >
136
+ > Those three values are the step's operator-supplied prerequisite, and
137
+ > **`release-gate --cut` inherits that triple** — it is exported onto the aggregate invocation (the
138
+ > release skill's P5 carries it), never inferred by the gate, so the no-inference rule above holds
139
+ > unchanged inside the aggregate. **An ABSENT value is a protocol SKIP naming what was missing; a PRESENT but wrong one
140
+ > stays a FAIL.** That split is the honest reading of the step-outcome protocol — a step that never
141
+ > ran and a step that ran and broke are different facts — and it is what makes this MUST reproducible
142
+ > at every cut instead of inviting a standing exception: before 2026-09-23 all three were assertions, so their
143
+ > absence threw, and because the aggregate supplied none of them this step went red inside every
144
+ > cut on every host (0.25.0's candidate receipt, `MUST PASS=23 FAIL=1`, is that shape). A SKIP is
145
+ > **not** a softening: `--cut` refuses it as `cut: BLOCKED (MUST SKIP)`, so a cut without this
146
+ > receipt is still impossible — the operator supplies the triple and re-runs. A host with no Codex
147
+ > app-server therefore cannot take a cut, which is the intended cost of #87's release stop
148
+ > (「A visible-fresh receipt outside the MUST tier is no longer the general rule」above) rather than
149
+ > an accident. The installed-config preflights are deliberately NOT in that split and remain FAIL
150
+ > on absence — a missing Codex birth, user MCP/status/terminal-title config, runtime, bridge, tmux
151
+ > seat, or an unanswered launch directory is host state `setup` and the Codex doctors own, not a
152
+ > per-invocation input.
135
153
  > **Both of THIS cell's live-spend defaults are `gpt-5.6-luna` from 2026-09-17**
136
154
  > (`openai-codex/gpt-5.6-luna` on the pi side) — the Codex leg came down off `sol`. It is a tier
137
155
  > choice and not a gate change; the receipt this cell is accepted on must be the one that actually
@@ -269,7 +287,7 @@ The goal is not merely "invoke Claude Code." We want:
269
287
  **One install command to remember: `./run.sh setup <project>`.** It is idempotent — re-run the exact same command whenever anything looks wrong. There is no second install surface to juggle: from a clone `setup` runs the whole floor in order.
270
288
 
271
289
  1. `pnpm install` — installs the pinned development dependencies and builds the bridge (source-checkout bootstrap only; an installed package never runs npm/pnpm inside `node_modules`)
272
- 2. pi wiring → `<project>/.pi/settings.json` + user-scope registration — only when a `pi` inside the supported range (`>=0.85.1 <0.86`) is on PATH; absent pi is an explicit zero-state SKIP, a below-floor pi is a detected FAIL. The user-scope entry is owner-recorded (#86 C2): another root's live-or-missing ownership makes this step a zero-write refusal (setup: pi FAIL) that names `takeover-user-scope`; the install-states bind the exact managed settings path (a drifted/symlinked/corrupt target is a zero-write refusal) and the inverse removes only the recorded owner's exact entry; `doctor-pi-package` reports the verdict
290
+ 2. pi wiring → `<project>/.pi/settings.json` + user-scope registration — only when a `pi` inside the supported range (`>=0.87.1 <0.88`) is on PATH; absent pi is an explicit zero-state SKIP, a below-floor pi is a detected FAIL. The user-scope entry is owner-recorded (#86 C2): another root's live-or-missing ownership makes this step a zero-write refusal (setup: pi FAIL) that names `takeover-user-scope`; the install-states bind the exact managed settings path (a drifted/symlinked/corrupt target is a zero-write refusal) and the inverse removes only the recorded owner's exact entry; `doctor-pi-package` reports the verdict
273
291
  3. Claude meta-bridge global plugin — only when `claude` is on PATH; otherwise skipped cleanly
274
292
  4. source stable-bin exposure — including certified `entwurf` → this checkout's `run.sh`, the managed runtime Copilot fresh resolves; helper units are attempted independently and a foreign helper is a named FAIL
275
293
  5. agy bridge + exact permission + statusline + `PreInvocation` hook — only when `agy` is on PATH; each adapter is idempotent and independently doctorable
package/demo/README.md CHANGED
@@ -46,7 +46,10 @@ a background driver types the scene prompts into the sender pane.
46
46
  1. **Spawn** a sonnet sibling (memory write — "tempered indigo").
47
47
  2. **Resume** that same sibling (memory recall — identity preservation,
48
48
  the #9 regression guard).
49
- 3. **Cross-session greeting** via `entwurf_send` (sent box ↔ received box).
49
+ 3. **Cross-session greeting** via `entwurf_send`. *(Historical: `entwurf_send` was retired in the
50
+ 0.12 cutover, and the "sent box" half of that pairing was never actually reachable — the
51
+ sender-side renderer had zero producers and was removed in #120 P3. What the recording shows on
52
+ the receive side is real; the send side is a box the operator could not have seen.)*
50
53
 
51
54
  Run:
52
55
 
@@ -139,7 +142,7 @@ SCENE_DELAY=30 FINAL_PAUSE=10 bash demo.sh
139
142
 
140
143
  ## Prerequisites
141
144
 
142
- - `pi` on PATH (current floor 0.85.1)
145
+ - `pi` on PATH (current floor 0.87.1)
143
146
  - `entwurf` provider configured + auth ready for the selected sender/peer models
144
147
  - `asciinema` installed
145
148
  - `agg` installed (optional — only for GIF conversion)
@@ -92,8 +92,8 @@ undifferentiated "supported" column is what let a Claude PASS read as if it also
92
92
 
93
93
  | Surface | Declaration | Class | What a green actually says |
94
94
  |---|---|---|---|
95
- | Entwurf package | `0.23.0` | shipped baseline | the package contract these rows belong to |
96
- | pi runtime | devDep exact `0.85.1`, peer `>=0.85.1 <0.86` | **exact** oracle + **closed range** | built and certified against 0.85.1; hosts inside the range are accepted, and the ceiling moves only on measurement |
95
+ | Entwurf package | `0.25.0` | shipped baseline | the package contract these rows belong to |
96
+ | pi runtime | devDep exact `0.87.1`, peer `>=0.87.1 <0.88` | **exact** oracle + **closed range** | built and certified against 0.87.1; hosts inside the range are accepted, and the ceiling moves only on measurement |
97
97
  | ACP wire SDK | `@agentclientprotocol/sdk 1.4.0` | **exact** | the shared wire oracle both adapters speak |
98
98
  | Claude ACP adapter | `@agentclientprotocol/claude-agent-acp 0.79.0` | **exact**, bundled | the adapter we ship and certify; resolved before any PATH fallback |
99
99
  | Claude Agent SDK | `0.3.274` (transitive) | **exact** oracle | the runtime risk surface behind the adapter |
@@ -476,6 +476,53 @@ already emitted a `usage_update` at `compact_boundary` and 0.75.1 still does
476
476
 
477
477
  Receipt, limits and the `completed`-branch gap: `scripts/raw-acp-compaction-measure/README.md`.
478
478
 
479
+ ## 11-9. A steer reaches an ACP receiver one child turn late (#120 P5, measurement only)
480
+
481
+ `entwurf_v2 mode:"steer"` injects into the receiver's pi steering queue; it is not an ACP concept.
482
+ On a NATIVE pi receiver that queue is drained seconds later, and on an ACP receiver it is drained
483
+ one whole child turn later. The difference is not a different drain point — it is the same one.
484
+
485
+ `[측정 2026-09-22, pi-agent-core 0.87.0; 재측정 2026-09-23, 0.87.1 — 좌표 동일]` `dist/agent-loop.js:141` awaits `streamAssistantResponse`
486
+ once per turn, and the steering drain is `:186`, after the `turn_end` emit at `:185`. For this rail
487
+ that one await is `pi-extensions/lib/acp/backend.ts:815` —
488
+ `await Promise.race([session.connection.prompt(promptArgs), lifecycle])` — so it spans the entire
489
+ `session/prompt` round trip. The child's own tool calls and subagent turns are invisible to pi, so
490
+ pi cannot reach `:186` until the child turn ends. Same code path as native, one long await.
491
+
492
+ That is a property, not a silent failure: the sender's receipt already says `queued-steer` and
493
+ `DELIVERY.md` already states that both queues are volatile and unordered against each other, so
494
+ nothing here is promised and then quietly not delivered. `[QK:PI-QUEUE-NO-MIDFLIGHT-MERGE]`
495
+ (`test/pi-queue.oracle.test.ts`) proves the mechanism on the installed vendor: a message queued
496
+ while a turn is in flight never joins that turn and arrives as a later one. The delay's SIZE is
497
+ that turn's duration, which the same cell fixes by construction.
498
+
499
+ **Forwarding is not adopted here.** `[측정 2026-09-22]` standard ACP has no steer method at all —
500
+ enumerating the installed `@agentclientprotocol/sdk`'s `AGENT_METHODS` and `CLIENT_METHODS` yields
501
+ zero entries containing `steer`. What exists is a PRIVATE vendor extension,
502
+ `_session/steering` in `@agentclientprotocol/claude-agent-acp@0.79.0`
503
+ (`dist/acp-agent.js:163`, handler at `:7792`), client→agent, advertised through
504
+ `InitializeResponse._meta.steering.supported`. Our client sends `initialize`, `session/new`,
505
+ `session/prompt`, `session/set_config_option` and `session/cancel` (`acp-client.ts:138-149`) and
506
+ has no steering path at all.
507
+
508
+ Adopting it would mean owning five things this driver does not own today, each a new contract
509
+ rather than a wiring change:
510
+
511
+ 1. reading the `_meta.steering.supported` capability, and deciding what a backend without it is;
512
+ 2. a typed schema and version fence for a request that is outside the SDK's method set, so a
513
+ private extension changing shape is a named refusal rather than a silent no-op;
514
+ 3. a backend-specific concurrent-request seam from the control extension to a live ACP connection —
515
+ today a turn is one in-flight `prompt`, and this adds a second request during it;
516
+ 4. an exactly-once / ordering policy that picks the outer pi queue or the vendor injection but never
517
+ both, since those are two independent orderings that currently do not meet;
518
+ 5. a receipt mapping for the extension's outcomes — `injected`, `promptRequired`, `startedNewTurn` —
519
+ plus the idle race, cancel and release meanings behind them. (`idleBehavior:"promptRequired"`
520
+ lets a host refuse the detached `startedNewTurn` path, but that opt-in is itself a new contract.)
521
+
522
+ The adapter keeps its own turn-settlement bookkeeping internally, and our event mapper ignores
523
+ update kinds it does not handle (`event-mapper.ts:377-378`), so nothing above is a claim that
524
+ Entwurf must replicate vendor internals. The reason to stay put is the five contracts, not a ledger.
525
+
479
526
  ## Open work
480
527
 
481
528
  - causal MCP-readiness diagnosis and, only with proof, a backend-invariant fence;
@@ -22,7 +22,7 @@ evidence split, and its three states must not collapse into "macOS is supported"
22
22
  | npm/pnpm | npm is bundled with Node; pnpm is required for source setup | package or source installation |
23
23
  | Python 3 | required by `setup`/`install` (project-path normalization + settings writers); `--help`/`check-bridge` stay Python-free | pi/Claude/agy/Copilot/Codex wiring writers |
24
24
  | entwurf | global/project-local `@junghanacs/entwurf`, or a source checkout | operator command and garden capability |
25
- | pi | optional-by-presence, `>=0.85.1 <0.86` — absent is an explicit setup SKIP, below-floor is a named FAIL | ACP provider, control sockets |
25
+ | pi | optional-by-presence, `>=0.87.1 <0.88` — absent is an explicit setup SKIP, below-floor is a named FAIL | ACP provider, control sockets |
26
26
  | Claude Code | optional, **`>=2.1.217`** — the exec-form hook floor | Claude ACP auth/runtime and mailbox-backed native citizen |
27
27
  | GitHub Copilot CLI | optional-by-presence, operator-installed and authenticated — absent is an explicit setup SKIP; detected composes all four units (birth/MCP/receiver/footer) | self-fetch citizen and visible fresh |
28
28
  | OMP (`omp`) | optional-by-presence, operator-installed — absent is an explicit setup SKIP; detected composes all four units (birth/MCP/`tools.xdev` setting/receiver) | self-fetch citizen and visible fresh (accepted on one host — see §4b) |
@@ -156,7 +156,7 @@ on Darwin; that is wiring, not a rail receipt. If `pi` is on PATH,
156
156
  Install the exact release floor, then wire the project:
157
157
 
158
158
  ```bash
159
- npm install -g @earendil-works/pi-coding-agent@0.85.1
159
+ npm install -g @earendil-works/pi-coding-agent@0.87.1
160
160
  pi --version
161
161
 
162
162
  cd ~/entwurf-smoke
@@ -164,7 +164,7 @@ entwurf install .
164
164
  pi -e "$(npm root -g)/@junghanacs/entwurf" --list-models entwurf
165
165
  ```
166
166
 
167
- The supported range is `>=0.85.1 <0.86`. It is a hard minimum: installing this
167
+ The supported range is `>=0.87.1 <0.88`. It is a hard minimum: installing this
168
168
  release onto a 0.83.x pi host upgrades the runtime rather than keeping the older
169
169
  minor. A host using only the external MCP bridge can skip pi until it needs a
170
170
  control socket; no delivery rail launches a pi process.
@@ -264,9 +264,10 @@ process. Open omp yourself and it is a two-way citizen either way — it sends u
264
264
  garden id and a reply lands on it.
265
265
 
266
266
  The first turn of a fresh omp sibling is a TWO-STAGE BOOTSTRAP, not a positional prompt: the
267
- launcher carries `{v,target,nonce,task}` on the fixed registered flag `--entwurf-bootstrap`,
268
- and the installed birth extension waits until the callback tool is actually callable, sends a
269
- callback-only prompt, and delivers the operator's task only after that exact call succeeds.
267
+ launcher carries `{v,task}` on the fixed registered flag `--entwurf-bootstrap` while the
268
+ caller's address rides the sibling's own process env, and the installed birth extension waits
269
+ until the zero-argument callback tool is actually callable, sends a callback-only prompt, and
270
+ delivers the operator's task only after that exact call succeeds.
270
271
  That is a measured correction — the interactive host defers MCP discovery, so a positional
271
272
  first turn began before the tool it named existed.
272
273
 
@@ -306,7 +307,7 @@ as a note rather than a fault.
306
307
 
307
308
  **entwurf sets no OMP version floor in code, and will not grow one on schedule.** Detection is
308
309
  presence-only (`command -v "${OMP_BIN:-omp}"`); there is no `entwurf.ompFloor`, no coherence
309
- gate, and no exact pin — unlike Node (`engines.node`), pi (`>=0.85.1 <0.86`) and Claude Code
310
+ gate, and no exact pin — unlike Node (`engines.node`), pi (`>=0.87.1 <0.88`) and Claude Code
310
311
  (`entwurf.claudeCodeFloor`), each of which has an enforcement point. That asymmetry is a
311
312
  decision, not an omission. A floor is the answer to a vendor that fails SILENTLY — Claude Code
312
313
  earned one because an older binary validates the exec manifest, drops `args` at runtime, and
@@ -34,8 +34,13 @@
34
34
  * - entwurf_resume_call — reopen ONE DORMANT pi citizen under its OWN garden id in a visible
35
35
  * window; target-only, runs no turn, LAUNCH and OBSERVATION receipts stay
36
36
  * apart.
37
+ * - entwurf_callback — ZERO-ARGUMENT fresh-sibling callback. Reads ENTWURF_CALLBACK_TARGET +
38
+ * ENTWURF_CALLBACK_NONCE from this process env (launcher-injected), validates
39
+ * grammar, and dispatches through the existing v2 runner. Refuses by name
40
+ * when env is absent/malformed or this process is a Codex-provenance bridge.
41
+ * No model-supplied target fallback.
37
42
  *
38
- * That list is the WHOLE public surface — seven verbs — and `check-entwurf-bridge-boot`
43
+ * That list is the WHOLE public surface — eight verbs — and `check-entwurf-bridge-boot`
39
44
  * (G1f) holds it as an exact set on the runtime tools/list, so a verb added or dropped
40
45
  * here without a decision is red rather than merely undocumented.
41
46
  *
@@ -64,6 +69,7 @@ import * as process from "node:process";
64
69
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
65
70
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
66
71
  import { z } from "zod";
72
+ import { readCallbackEnv } from "../../../pi-extensions/lib/callback-env.js";
67
73
  import { controlSocketPathIn, defaultControlSocketDir } from "../../../pi-extensions/lib/control-socket-path.js";
68
74
  import { resolveMailboxReceiverFacts } from "../../../pi-extensions/lib/entwurf-deliverability.js";
69
75
  import { listEntwurfFacts } from "../../../pi-extensions/lib/entwurf-fact-provider.js";
@@ -348,7 +354,9 @@ server.tool("entwurf_v2", "CANONICAL DELIVERY SURFACE for garden ids: message, r
348
354
  "alone does not say which rail that citizen answers on. Give target + intent; the decider picks transport " +
349
355
  "from liveness (live socket citizen → control-socket send; deliverable self-fetch citizen → meta-bridge " +
350
356
  "mailbox; probe-alive native-push citizen → direct injection into its conversation) and reports ONE " +
351
- "outcome (delivered / rejected / delivered-but-lock-dirty). EXISTING targets only; discover with " +
357
+ "outcome, named per rail: a control receiver's acceptance boundary (`sent` / `queued-steer` / " +
358
+ "`queued-follow-up` / `accepted-unknown-boundary` — those four only), a mailbox ENQUEUE receipt, a " +
359
+ "native-push INJECTION, a rejection, or lock-dirty. EXISTING targets only; discover with " +
352
360
  "entwurf_peers. A peer entwurf_peers shows as liveness=alive → fire-and-forget. A " +
353
361
  "citizen with NO socket liveness (liveness=unsupported) is ALSO fire-and-forget — unsupported means only " +
354
362
  '"no control-socket probe." The decider resolves the actual rail at dispatch time: a self-fetch ' +
@@ -383,9 +391,8 @@ server.tool("entwurf_v2", "CANONICAL DELIVERY SURFACE for garden ids: message, r
383
391
  mode: z
384
392
  .enum(["steer", "follow_up"])
385
393
  .optional()
386
- .describe("Injection style for a CONTROL-SOCKET send only: steer (interrupt the current turn) or " +
387
- "follow_up (queue after it). The mailbox and native-push plans carry no mode, so it has no " +
388
- "effect on those rails."),
394
+ .describe("Injection style for a CONTROL-SOCKET send only: steer (ask the receiver's steering queue) or follow_up (ask its follow-up queue). NEITHER is an interrupt: a busy receiver drains steering after each turn and follow-ups only when its inner loop ends, so a later steer overtakes every earlier follow_up and there is no order between the two. Both queues are volatile process memory — an abort drops what is in them. The receipt names which queue accepted the message (`queued-steer` / `queued-follow-up`); an idle receiver answers `sent`, meaning the turn was TRIGGERED, not that the model has seen the text. " +
395
+ "The mailbox and native-push plans carry no mode, so it has no effect on those rails."),
389
396
  wants_reply: z.boolean().optional().describe("Human-conversation reply hint (default false)"),
390
397
  }, async ({ target, intent, message, mode, wants_reply }, extra) => {
391
398
  try {
@@ -740,6 +747,32 @@ server.tool("entwurf_resume_call", "Reopen ONE DORMANT pi citizen under its OWN
740
747
  return textErr(`entwurf_resume_call error: ${err instanceof Error ? err.message : String(err)}`);
741
748
  }
742
749
  });
750
+ server.tool("entwurf_callback", "ZERO-ARGUMENT callback for a fresh sibling this process was launched as. Reads " +
751
+ "ENTWURF_CALLBACK_TARGET and ENTWURF_CALLBACK_NONCE from this process environment " +
752
+ "(injected by the launcher next to the identity scrub), validates garden-id and nonce " +
753
+ "grammar, and dispatches through the existing v2 runner with intent fire-and-forget, " +
754
+ "message=nonce, wants_reply=false. The target is re-resolved by record/decider — env " +
755
+ "names the id, it does not skip dispatch. REFUSES BY NAME when the pair is absent, " +
756
+ "malformed, or this process is a Codex-provenance bridge (window env never reaches that " +
757
+ "MCP child). No arguments, no fallback to a model-supplied target.", {}, async (_args, extra) => {
758
+ const read = readCallbackEnv(process.env);
759
+ if (!read.ok) {
760
+ return textErr(`entwurf_callback: ${read.reason}`);
761
+ }
762
+ try {
763
+ const sender = await buildSendSenderEnvelope({ requestMeta: extra._meta });
764
+ const rendered = await runAndRenderEntwurfV2FromSurface({
765
+ target: read.target,
766
+ intent: "fire-and-forget",
767
+ message: read.nonce,
768
+ wants_reply: false,
769
+ }, { senderProvider: () => sender });
770
+ return rendered.isError ? textErr(rendered.text) : textOk(rendered.text);
771
+ }
772
+ catch (err) {
773
+ return textErr(`entwurf_callback error: ${err instanceof Error ? err.message : String(err)}`);
774
+ }
775
+ });
743
776
  // ============================================================================
744
777
  // Main
745
778
  // ============================================================================
@@ -8,12 +8,16 @@
8
8
  //
9
9
  // S2c/S2d boundary (GPT S2c Q2): this is CONVERSATION TRANSCRIPT PASSTHROUGH, not
10
10
  // rich-carrier identity injection. Deliberately EXCLUDED here (all S2d):
11
- // - `context.systemPrompt` — never read into the prompt or `_meta.systemPrompt`
11
+ // - the system prompt — never read into the prompt or `_meta.systemPrompt`
12
12
  // (the billing carrier stays absent — NEXT §S2-scout 핀1);
13
13
  // - `~/AGENTS.md` / cwd AGENTS / bridge identity narrative;
14
14
  // - first-user-message augment + project-context de-dup;
15
- // - `context.tools` — the ACP child tool surface is the S2b
15
+ // - the declared tool surface — the ACP child tool surface is the S2b
16
16
  // `_meta.claudeCode.options` SSOT, never re-sent here.
17
+ // Since pi 0.86 both of those ride a leading `role:"system"` message inside the
18
+ // `TranscriptContext` (pi-ai `normalizeContext`) rather than their own `Context`
19
+ // fields, so the exclusion is enforced by `renderMessage`'s explicit
20
+ // `case "system"` below, not by simply not reading two fields.
17
21
  // Structured tool replay is also excluded: tool calls/results render as plain
18
22
  // transcript text, never as ACP tool invocations (the child runs its own tools).
19
23
  // MUST equal event-mapper.ts `LIFECYCLE_NOTICE_SIGNATURE` (the SSOT/producer).
@@ -69,13 +73,23 @@ function renderMessage(message) {
69
73
  const tag = message.isError ? "Tool error" : "Tool result";
70
74
  return text ? `${tag} (${message.toolName}): ${text}` : undefined;
71
75
  }
76
+ case "system":
77
+ // The never-forward-systemPrompt invariant, stated rather than inherited.
78
+ // Since pi 0.86 the system prompt and the declared tool surface arrive AS
79
+ // transcript messages (`normalizeContext`), so what used to be "we simply
80
+ // do not read `context.systemPrompt`" is now an active decision on every
81
+ // turn. `default` would already skip it; the explicit case exists so
82
+ // deleting it is a visible change and a new pi role cannot quietly start
83
+ // leaking the prompt into the ACP child's transcript.
84
+ return undefined;
72
85
  default:
73
86
  return undefined;
74
87
  }
75
88
  }
76
89
  /**
77
- * Flatten a pi Context into a single transcript string. Excludes
78
- * `context.systemPrompt` and `context.tools` by construction.
90
+ * Flatten a pi transcript into a single transcript string. Excludes the system
91
+ * prompt and the declared tool surface by construction — since 0.86 both ride
92
+ * the leading `role:"system"` message, which `renderMessage` drops explicitly.
79
93
  */
80
94
  export function contextTranscript(context) {
81
95
  const lines = [];
@@ -87,7 +101,7 @@ export function contextTranscript(context) {
87
101
  return lines.join("\n\n");
88
102
  }
89
103
  /**
90
- * Convert a pi Context into the ACP `prompt` array (a single text block holding
104
+ * Convert a pi transcript into the ACP `prompt` array (a single text block holding
91
105
  * the flattened transcript). Empty history yields an empty array — the caller
92
106
  * decides whether that is a hard error.
93
107
  */
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Callback-env — the launch-computed pair that lets a fresh sibling call home
3
+ * without copying a garden id or nonce out of prose.
4
+ *
5
+ * WHY THIS FILE EXISTS. Until this seam, `composeFreshCallFraming` put
6
+ * `target=<caller gid>` and `message=<nonce>` into the first-turn prompt, and
7
+ * the sibling had to retype them into `entwurf_v2`. That is the one place in
8
+ * the garden Hard Rule 2 (record is the sole address axis) was violated: the
9
+ * transcript became the address carrier, and a model that dropped a `$` or a
10
+ * hex nibble produced a refused dispatch that looked like a flaky backend.
11
+ *
12
+ * WHAT TRAVELS. Two process-env names, injected by the launcher next to the
13
+ * identity scrub (`PI_SESSION_ID=` / `PI_AGENT_ID=`), never as a general env
14
+ * carrier. The no-arg `entwurf_callback` verb reads BOTH from its own process
15
+ * and refuses by name when they are absent, malformed, or this process is a
16
+ * Codex-provenance bridge (window env never reaches that MCP child — measured).
17
+ *
18
+ * This module does not dispatch. It parses, validates, and formats. Launchers
19
+ * and the verb share it so a drifted regex cannot inject what the verb rejects.
20
+ */
21
+ import { SESSION_ID_RE } from "./session-id.js";
22
+ export const CALLBACK_TARGET_ENV = "ENTWURF_CALLBACK_TARGET";
23
+ export const CALLBACK_NONCE_ENV = "ENTWURF_CALLBACK_NONCE";
24
+ /** Exactly what `mintNonce` emits (`fresh-call-composition.ts`): `mux-fresh-call-` + 12 random
25
+ * bytes as hex. ONE minter, ONE grammar — both rails call the same `mintNonce`, so a second
26
+ * accepted prefix would be a spelling no launcher produces and this leaf exists to refuse
27
+ * exactly that. A gate fixture is an oracle and never a reason to widen the subject. */
28
+ export const CALLBACK_NONCE_RE = /^mux-fresh-call-[0-9a-f]{24}$/;
29
+ function parseCallbackPair(target, nonce) {
30
+ if (typeof target !== "string" || typeof nonce !== "string") {
31
+ return { ok: false, reason: "callback-env-malformed" };
32
+ }
33
+ if (!SESSION_ID_RE.test(target) || !CALLBACK_NONCE_RE.test(nonce)) {
34
+ return { ok: false, reason: "callback-env-malformed" };
35
+ }
36
+ return { ok: true, target, nonce };
37
+ }
38
+ /**
39
+ * Format the two `KEY=value` assignments a launcher injects. Throws rather than
40
+ * emitting a pair the verb would refuse — a launch that cannot name its caller
41
+ * must not open a window whose first action is a guaranteed reject.
42
+ */
43
+ export function callbackEnvAssignments(params) {
44
+ const parsed = parseCallbackPair(params.target, params.nonce);
45
+ if (!parsed.ok) {
46
+ throw new Error(`callback-env: refusing to inject ${parsed.reason}`);
47
+ }
48
+ return [`${CALLBACK_TARGET_ENV}=${parsed.target}`, `${CALLBACK_NONCE_ENV}=${parsed.nonce}`];
49
+ }
50
+ /**
51
+ * Read the pair from a process environment. Codex provenance is a named refuse
52
+ * even when the pair is well-formed: that bridge is an app-server child, and a
53
+ * pane-injected value would be the wrong citizen's (or last-launch-wins).
54
+ *
55
+ * Both names absent → `callback-env-absent`. One present, or either failing
56
+ * grammar → `callback-env-malformed`. No fallback to a model-supplied target.
57
+ */
58
+ export function readCallbackEnv(env = process.env) {
59
+ if (env.ENTWURF_BRIDGE_NATIVE_HOST?.trim() === "codex") {
60
+ return { ok: false, reason: "codex-callback-env-unsupported" };
61
+ }
62
+ const target = env[CALLBACK_TARGET_ENV];
63
+ const nonce = env[CALLBACK_NONCE_ENV];
64
+ if (target === undefined && nonce === undefined) {
65
+ return { ok: false, reason: "callback-env-absent" };
66
+ }
67
+ return parseCallbackPair(target, nonce);
68
+ }