@junghanacs/entwurf 0.23.0 → 0.24.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 (102) hide show
  1. package/AGENTS.md +3 -1
  2. package/BASELINE.md +2 -0
  3. package/CHANGELOG.md +307 -0
  4. package/FAQ.md +47 -0
  5. package/README.md +5 -3
  6. package/VERIFY.md +43 -2
  7. package/demo/README.md +1 -1
  8. package/docs/acp-backend-rail.md +97 -18
  9. package/docs/setup-clean-host.md +8 -7
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +33 -1
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +19 -5
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/callback-env.js +68 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +54 -22
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +7 -3
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +13 -4
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +19 -10
  21. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +35 -42
  22. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  23. package/mcp/entwurf-bridge/src/index.ts +41 -1
  24. package/mcp/entwurf-bridge/test.sh +2 -2
  25. package/package.json +15 -13
  26. package/pi-extensions/entwurf-control.ts +56 -0
  27. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  28. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  29. package/pi-extensions/lib/acp/backend.ts +20 -10
  30. package/pi-extensions/lib/acp/context.ts +30 -10
  31. package/pi-extensions/lib/acp/event-mapper.ts +5 -5
  32. package/pi-extensions/lib/acp/session-store.ts +2 -2
  33. package/pi-extensions/lib/acp/tool-surface.ts +2 -1
  34. package/pi-extensions/lib/callback-env.ts +78 -0
  35. package/pi-extensions/lib/compaction-send-guard.ts +4 -1
  36. package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
  37. package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
  38. package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
  39. package/pi-extensions/lib/fresh-call-composition.ts +56 -22
  40. package/pi-extensions/lib/herdr-fresh-call.ts +12 -4
  41. package/pi-extensions/lib/herdr-placement.ts +13 -4
  42. package/pi-extensions/lib/mux-fresh-call.ts +23 -8
  43. package/pi-extensions/meta-bridge-omp.ts +35 -48
  44. package/run.sh +327 -246
  45. package/scripts/check-acp-backend-preflight.ts +117 -9
  46. package/scripts/check-acp-carrier-augment.ts +6 -4
  47. package/scripts/check-acp-event-mapper.ts +17 -6
  48. package/scripts/check-acp-prompt-builder.ts +17 -9
  49. package/scripts/check-acp-prompt-lifecycle.ts +23 -6
  50. package/scripts/check-acp-session-reuse.ts +64 -51
  51. package/scripts/check-acp-session-store.ts +31 -8
  52. package/scripts/check-acp-stop-reason.ts +20 -2
  53. package/scripts/check-acp-stream-hooks.ts +22 -5
  54. package/scripts/check-acp-usage-accounting.ts +42 -18
  55. package/scripts/check-entwurf-bridge-boot.ts +16 -1
  56. package/scripts/check-fresh-call-dispatch.ts +4 -5
  57. package/scripts/check-gate-qualification.ts +45 -5
  58. package/scripts/check-herdr-activation.ts +73 -0
  59. package/scripts/check-herdr-fresh-call.ts +22 -6
  60. package/scripts/check-herdr-sandbox.ts +14 -2
  61. package/scripts/check-install-container.sh +1 -1
  62. package/scripts/check-pi-launch.ts +361 -0
  63. package/scripts/check-release-gate-outcomes.ts +7 -0
  64. package/scripts/inventory-verification-surface.ts +86 -10
  65. package/scripts/lib/mutation-qualify.ts +1 -1
  66. package/scripts/mutants/acp-backend-preflight.json +20 -0
  67. package/scripts/mutants/bridge-boot-resume.json +26 -0
  68. package/scripts/mutants/herdr-activation.json +16 -0
  69. package/scripts/mutants/herdr-fresh-call.json +11 -0
  70. package/scripts/mutants/herdr-plugin-build.json +1 -1
  71. package/scripts/mutants/mux-fresh-call.json +21 -8
  72. package/scripts/mutants/omp-fresh.json +5 -9
  73. package/scripts/mutants/pack-install.json +3 -3
  74. package/scripts/mutants/pi-launch.json +113 -0
  75. package/scripts/mutants/resume-args.json +6 -6
  76. package/scripts/mutants/v2-surface.json +2 -2
  77. package/scripts/mutants/v2-visible-resume.json +17 -17
  78. package/scripts/probe-bridge-command.ts +1 -0
  79. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  80. package/scripts/smoke-acp-session-reuse-live.ts +23 -5
  81. package/scripts/smoke-agy-install-state.sh +2 -2
  82. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  83. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  84. package/scripts/smoke-herdr-fresh-call-live.ts +56 -28
  85. package/scripts/smoke-herdr-raw-install-live.sh +877 -0
  86. package/scripts/smoke-omp-fresh-live.ts +4 -2
  87. package/scripts/smoke-omp-mcp-state.sh +1 -1
  88. package/scripts/smoke-pi-provider-state.sh +2 -2
  89. package/scripts/tsconfig.json +5 -0
  90. package/scripts/check-entwurf-facts.ts +0 -391
  91. package/scripts/check-entwurf-resume-args.ts +0 -122
  92. package/scripts/check-entwurf-v2-decider.ts +0 -657
  93. package/scripts/check-entwurf-v2-lock.ts +0 -371
  94. package/scripts/check-entwurf-v2-mailbox.ts +0 -261
  95. package/scripts/check-entwurf-v2-matrix.ts +0 -401
  96. package/scripts/check-entwurf-v2-native-push.ts +0 -221
  97. package/scripts/check-entwurf-v2-release.ts +0 -151
  98. package/scripts/check-entwurf-v2-runner.ts +0 -448
  99. package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
  100. package/scripts/check-entwurf-v2-send.ts +0 -458
  101. package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
  102. package/scripts/check-socket-discovery.ts +0 -401
@@ -44,8 +44,8 @@ import { type ChildProcessByStdio, spawn } from "node:child_process";
44
44
  import { readFileSync } from "node:fs";
45
45
  import { Readable, Writable } from "node:stream";
46
46
  import { ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
47
- import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
48
- import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
47
+ import type { Api, AssistantMessage, Model, SimpleStreamOptions, TranscriptContext } from "@earendil-works/pi-ai";
48
+ import { createAssistantMessageEventStream, getCurrentTools } from "@earendil-works/pi-ai";
49
49
  import {
50
50
  type AcpClientHandlers,
51
51
  type AcpConnectionLike,
@@ -81,7 +81,7 @@ import {
81
81
  resolveLifecyclePolicy,
82
82
  writeSessionRecord,
83
83
  } from "./session-store.js";
84
- import { assertExcludeToolsHonored, PI_BUILTIN_BACKED_TOOLS } from "./tool-surface.js";
84
+ import { assertExcludeToolsHonored } from "./tool-surface.js";
85
85
 
86
86
  // Bootstrap boundaries ONLY. initialize / newSession / set-model are handshake
87
87
  // steps that make no model progress, so a stuck one is a dead session and a cold
@@ -137,7 +137,7 @@ function finiteOrZero(value: unknown): number {
137
137
  * "the accounting-grade figure per the SDK" — it also counts Task subagents,
138
138
  * sidechains, and INTERNAL CALLS SUCH AS COMPACTION, so its rows "can total more
139
139
  * than `token_count`" and are "the fuller picture, not a decomposition of it"
140
- * (read at claude-agent-acp 0.76.0 `dist/acp-agent.js:6538-6558`). The narrower
140
+ * (read at claude-agent-acp 0.79.0 `dist/acp-agent.js:6551-6571`). The narrower
141
141
  * `PromptResponse.usage` (== `quota.token_count`) is the MAIN AGENT LOOP only.
142
142
  *
143
143
  * The wider one is the right numerator because the denominator already has that
@@ -1108,7 +1108,7 @@ async function applyProviderPayloadHook<T extends { sessionId: string }>(
1108
1108
  */
1109
1109
  export function streamShellAcp(
1110
1110
  model: Model<Api>,
1111
- context: Context,
1111
+ context: TranscriptContext,
1112
1112
  options?: SimpleStreamOptions,
1113
1113
  ): ReturnType<typeof createAssistantMessageEventStream> {
1114
1114
  return streamAcpTurn(model, context, options, defaultDeps());
@@ -1117,7 +1117,7 @@ export function streamShellAcp(
1117
1117
  /** The seam-aware turn driver. `streamShellAcp` calls this with the real deps. */
1118
1118
  export function streamAcpTurn(
1119
1119
  model: Model<Api>,
1120
- context: Context,
1120
+ context: TranscriptContext,
1121
1121
  options: SimpleStreamOptions | undefined,
1122
1122
  deps: AcpTurnDeps,
1123
1123
  ): ReturnType<typeof createAssistantMessageEventStream> {
@@ -1237,7 +1237,7 @@ export function streamAcpTurn(
1237
1237
  // Writing zeros is not a placeholder for a better number we could compute:
1238
1238
  // the per-request partition is genuinely absent from the wire. The vendor
1239
1239
  // builds it in `lastAssistantUsage` and sends only its scalar sum (read at
1240
- // claude-agent-acp 0.76.0 `dist/acp-agent.js:3857-3882`) — #96.
1240
+ // claude-agent-acp 0.79.0 `dist/acp-agent.js:3920-3945`) — #96.
1241
1241
  //
1242
1242
  // But silence is NOT the resting state. A cache miss the operator never sees
1243
1243
  // is a false reading, not a modest one: a session can run for hours believing
@@ -1356,9 +1356,9 @@ export function streamAcpTurn(
1356
1356
  // between them is exactly why the notice below names a MECHANISM and
1357
1357
  // never a cause: claude-agent-acp's `conversation_reset` handler only
1358
1358
  // switches the SDK to a fresh conversation and touches no cost at all
1359
- // (read at 0.76.0 `dist/acp-agent.js:4286-4293`), while claude-agent-sdk
1359
+ // (read at 0.79.0 `dist/acp-agent.js:4349-4356`), while claude-agent-sdk
1360
1360
  // separately documents that "a mid-session /clear resets the running
1361
- // total" (read at 0.3.257 `sdk.d.ts:4884`). A reset therefore PLAUSIBLY
1361
+ // total" (read at 0.3.274 `sdk.d.ts:5342`). A reset therefore PLAUSIBLY
1362
1362
  // explains a backwards total, but nothing here has MEASURED that it did,
1363
1363
  // and asserting the cause would be the same unmeasured claim this lane
1364
1364
  // exists to end.
@@ -1510,7 +1510,17 @@ export function streamAcpTurn(
1510
1510
  // rather than lie to the model. Uses the RESOLVED tool surface (S2g) so an
1511
1511
  // operator-narrowed `tools` is what the truthfulness check honors.
1512
1512
  try {
1513
- const activeToolNames = context.tools?.map((t) => t.name) ?? [...PI_BUILTIN_BACKED_TOOLS];
1513
+ // pi 0.86 folded `Context.systemPrompt`/`Context.tools` into a leading
1514
+ // `role:"system"` message (`normalizeContext`, pi-ai
1515
+ // `utils/transcript.ts`), so a provider only ever sees a
1516
+ // `TranscriptContext`. The active surface is therefore REPLAYED from the
1517
+ // system messages — `getCurrentTools` applies every toolsAdded/toolsRemoved
1518
+ // delta in order. No `?? [...PI_BUILTIN_BACKED_TOOLS]` fallback: under 0.86
1519
+ // the old `context.tools` read was ALWAYS undefined, so that fallback
1520
+ // silently declared the full builtin set and the truthfulness preflight
1521
+ // could never fire (false green). An empty replayed list means the operator
1522
+ // excluded everything — keep that honest and let the preflight reject.
1523
+ const activeToolNames = getCurrentTools(context.messages).map((t) => t.name);
1514
1524
  assertExcludeToolsHonored(activeToolNames, { backend: adapter.backend, tools: config.tools });
1515
1525
  } catch (err) {
1516
1526
  finishError(err, false);
@@ -8,16 +8,26 @@
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
 
20
- import type { AssistantMessage, Context, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
24
+ import type {
25
+ AssistantMessage,
26
+ Message,
27
+ ToolResultMessage,
28
+ TranscriptContext,
29
+ UserMessage,
30
+ } from "@earendil-works/pi-ai";
21
31
 
22
32
  // MUST equal event-mapper.ts `LIFECYCLE_NOTICE_SIGNATURE` (the SSOT/producer).
23
33
  // It is mirrored, not imported: the strip-types deterministic gates load these
@@ -80,16 +90,26 @@ function renderMessage(message: Message): string | undefined {
80
90
  const tag = message.isError ? "Tool error" : "Tool result";
81
91
  return text ? `${tag} (${message.toolName}): ${text}` : undefined;
82
92
  }
93
+ case "system":
94
+ // The never-forward-systemPrompt invariant, stated rather than inherited.
95
+ // Since pi 0.86 the system prompt and the declared tool surface arrive AS
96
+ // transcript messages (`normalizeContext`), so what used to be "we simply
97
+ // do not read `context.systemPrompt`" is now an active decision on every
98
+ // turn. `default` would already skip it; the explicit case exists so
99
+ // deleting it is a visible change and a new pi role cannot quietly start
100
+ // leaking the prompt into the ACP child's transcript.
101
+ return undefined;
83
102
  default:
84
103
  return undefined;
85
104
  }
86
105
  }
87
106
 
88
107
  /**
89
- * Flatten a pi Context into a single transcript string. Excludes
90
- * `context.systemPrompt` and `context.tools` by construction.
108
+ * Flatten a pi transcript into a single transcript string. Excludes the system
109
+ * prompt and the declared tool surface by construction — since 0.86 both ride
110
+ * the leading `role:"system"` message, which `renderMessage` drops explicitly.
91
111
  */
92
- export function contextTranscript(context: Context): string {
112
+ export function contextTranscript(context: TranscriptContext): string {
93
113
  const lines: string[] = [];
94
114
  for (const message of context.messages) {
95
115
  const line = renderMessage(message);
@@ -99,11 +119,11 @@ export function contextTranscript(context: Context): string {
99
119
  }
100
120
 
101
121
  /**
102
- * Convert a pi Context into the ACP `prompt` array (a single text block holding
122
+ * Convert a pi transcript into the ACP `prompt` array (a single text block holding
103
123
  * the flattened transcript). Empty history yields an empty array — the caller
104
124
  * decides whether that is a hard error.
105
125
  */
106
- export function contextToAcpPrompt(context: Context): AcpTextBlock[] {
126
+ export function contextToAcpPrompt(context: TranscriptContext): AcpTextBlock[] {
107
127
  const transcript = contextTranscript(context);
108
128
  if (!transcript) return [];
109
129
  return [{ type: "text", text: transcript }];
@@ -129,7 +149,7 @@ export type AcpBootstrapPath = "new" | "reuse" | "resume" | "load";
129
149
  * (reuse/resume/load): re-sending the whole transcript there would duplicate
130
150
  * history the backend already remembers.
131
151
  */
132
- export function latestUserDelta(context: Context): AcpTextBlock[] {
152
+ export function latestUserDelta(context: TranscriptContext): AcpTextBlock[] {
133
153
  let lastAssistantIdx = -1;
134
154
  for (let i = context.messages.length - 1; i >= 0; i--) {
135
155
  if (context.messages[i].role === "assistant") {
@@ -160,7 +180,7 @@ export function latestUserDelta(context: Context): AcpTextBlock[] {
160
180
  * `contextMessageSignatures` prefix-compat gate (mismatch → fall back to
161
181
  * `"new"` + full transcript); this pure function only splits the scope.
162
182
  */
163
- export function buildAcpPrompt(context: Context, bootstrapPath: AcpBootstrapPath): AcpTextBlock[] {
183
+ export function buildAcpPrompt(context: TranscriptContext, bootstrapPath: AcpBootstrapPath): AcpTextBlock[] {
164
184
  switch (bootstrapPath) {
165
185
  case "new":
166
186
  return contextToAcpPrompt(context);
@@ -60,8 +60,8 @@ export type AcpPiStreamState = {
60
60
  *
61
61
  * Last write wins, never a sum: both are latest session-level observations, and
62
62
  * one turn can legitimately see several (claude emits one per `result` message,
63
- * including a sub-agent's own — read at claude-agent-acp 0.76.0
64
- * `dist/acp-agent.js:3471-3486`), each carrying that result's current values.
63
+ * including a sub-agent's own — read at claude-agent-acp 0.79.0
64
+ * `dist/acp-agent.js:3504-3519`), each carrying that result's current values.
65
65
  */
66
66
  observedSessionCostUsd?: number;
67
67
  observedContextOccupancyTokens?: number;
@@ -346,9 +346,9 @@ export function applyAcpSessionUpdate(
346
346
  case "usage_update": {
347
347
  // `used` is OCCUPANCY-shaped — the backend's post-turn context size, not
348
348
  // the prompt response's turn aggregate. Claude sends `lastAssistantTotalUsage`
349
- // as `used` (read at claude-agent-acp 0.76.0
350
- // `dist/acp-agent.js:3871-3882`), after constructing that scalar from the
351
- // latest assistant snapshot (`:3853-3866`). pi reads `usage.totalTokens` as
349
+ // as `used` (read at claude-agent-acp 0.79.0
350
+ // `dist/acp-agent.js:3934-3945`), after constructing that scalar from the
351
+ // latest assistant snapshot (`:3916-3929`). pi reads `usage.totalTokens` as
352
352
  // exactly that occupancy (`calculateContextTokens(usage) = usage.totalTokens
353
353
  // || input + output + cacheRead + cacheWrite`, read at pi-coding-agent
354
354
  // `dist/core/compaction/compaction.js:86-88`). The assignment below is the
@@ -27,7 +27,7 @@ import { createHash } from "node:crypto";
27
27
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
28
28
  import { homedir } from "node:os";
29
29
  import { join } from "node:path";
30
- import type { Context, Message, ToolResultMessage } from "@earendil-works/pi-ai";
30
+ import type { Message, ToolResultMessage, TranscriptContext } from "@earendil-works/pi-ai";
31
31
  import type { AcpBootstrapPath } from "./context.js";
32
32
 
33
33
  // MUST equal event-mapper.ts `LIFECYCLE_NOTICE_SIGNATURE` (the SSOT/producer).
@@ -236,7 +236,7 @@ function rawMessageSignature(message: Message): string {
236
236
  * record never stores raw prompt/tool text — the prefix check works the same on
237
237
  * the digest array (GPT `c617cb` hardening).
238
238
  */
239
- export function contextMessageSignatures(context: Context): string[] {
239
+ export function contextMessageSignatures(context: TranscriptContext): string[] {
240
240
  return context.messages.map((m: Message) => sha256(rawMessageSignature(m)));
241
241
  }
242
242
 
@@ -154,7 +154,8 @@ export function buildClaudeSessionMeta(
154
154
  // claude-agent-acp also accepts a STRING PATH, and at 0.76.0 the readFile +
155
155
  // JSON.parse for that form moved out of `resolvedProvider` up onto the
156
156
  // unconditional `session/new` path — a shape that WOULD reach us, and does not,
157
- // only because we never hand it a path. Keep it inline: a path would put file
157
+ // only because we never hand it a path. Still on that path at 0.79.0
158
+ // (`dist/acp-agent.js:6010-6012`). Keep it inline: a path would put file
158
159
  // IO, and its failure modes, inside every session creation.
159
160
  settings: {
160
161
  permissions: {
@@ -0,0 +1,78 @@
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
+
22
+ import { SESSION_ID_RE } from "./session-id.js";
23
+
24
+ export const CALLBACK_TARGET_ENV = "ENTWURF_CALLBACK_TARGET";
25
+ export const CALLBACK_NONCE_ENV = "ENTWURF_CALLBACK_NONCE";
26
+
27
+ /** Exactly what `mintNonce` emits (`fresh-call-composition.ts`): `mux-fresh-call-` + 12 random
28
+ * bytes as hex. ONE minter, ONE grammar — both rails call the same `mintNonce`, so a second
29
+ * accepted prefix would be a spelling no launcher produces and this leaf exists to refuse
30
+ * exactly that. A gate fixture is an oracle and never a reason to widen the subject. */
31
+ export const CALLBACK_NONCE_RE = /^mux-fresh-call-[0-9a-f]{24}$/;
32
+
33
+ export type CallbackEnvReject = "callback-env-absent" | "callback-env-malformed" | "codex-callback-env-unsupported";
34
+
35
+ export type CallbackEnvRead = { ok: true; target: string; nonce: string } | { ok: false; reason: CallbackEnvReject };
36
+
37
+ function parseCallbackPair(target: unknown, nonce: unknown): CallbackEnvRead {
38
+ if (typeof target !== "string" || typeof nonce !== "string") {
39
+ return { ok: false, reason: "callback-env-malformed" };
40
+ }
41
+ if (!SESSION_ID_RE.test(target) || !CALLBACK_NONCE_RE.test(nonce)) {
42
+ return { ok: false, reason: "callback-env-malformed" };
43
+ }
44
+ return { ok: true, target, nonce };
45
+ }
46
+
47
+ /**
48
+ * Format the two `KEY=value` assignments a launcher injects. Throws rather than
49
+ * emitting a pair the verb would refuse — a launch that cannot name its caller
50
+ * must not open a window whose first action is a guaranteed reject.
51
+ */
52
+ export function callbackEnvAssignments(params: { target: string; nonce: string }): readonly [string, string] {
53
+ const parsed = parseCallbackPair(params.target, params.nonce);
54
+ if (!parsed.ok) {
55
+ throw new Error(`callback-env: refusing to inject ${parsed.reason}`);
56
+ }
57
+ return [`${CALLBACK_TARGET_ENV}=${parsed.target}`, `${CALLBACK_NONCE_ENV}=${parsed.nonce}`];
58
+ }
59
+
60
+ /**
61
+ * Read the pair from a process environment. Codex provenance is a named refuse
62
+ * even when the pair is well-formed: that bridge is an app-server child, and a
63
+ * pane-injected value would be the wrong citizen's (or last-launch-wins).
64
+ *
65
+ * Both names absent → `callback-env-absent`. One present, or either failing
66
+ * grammar → `callback-env-malformed`. No fallback to a model-supplied target.
67
+ */
68
+ export function readCallbackEnv(env: NodeJS.ProcessEnv = process.env): CallbackEnvRead {
69
+ if (env.ENTWURF_BRIDGE_NATIVE_HOST?.trim() === "codex") {
70
+ return { ok: false, reason: "codex-callback-env-unsupported" };
71
+ }
72
+ const target = env[CALLBACK_TARGET_ENV];
73
+ const nonce = env[CALLBACK_NONCE_ENV];
74
+ if (target === undefined && nonce === undefined) {
75
+ return { ok: false, reason: "callback-env-absent" };
76
+ }
77
+ return parseCallbackPair(target, nonce);
78
+ }
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * compaction-send-guard — whether a control-socket `send` may call `pi.sendMessage`.
3
3
  *
4
- * Pi 0.85.1 has no public `ExtensionContext.isCompacting()`. Compaction is non-idle
4
+ * Pi 0.86.0 has no public `ExtensionContext.isCompacting()` (`[측정 2026-09-20]` the
5
+ * installed `dist/core/extensions/types.d.ts` declares `isIdle(): boolean` at :233 and
6
+ * `signal` at :237, and carries no `isCompacting` member at all; the identifier exists
7
+ * only inside the interactive/rpc modes' own bundles). Compaction is non-idle
5
8
  * (`AgentSession.isIdle` = no agent run AND not compacting) but `sendCustomMessage`
6
9
  * does not refuse it. A non-streaming `triggerTurn:true` send starts `_runAgentPrompt`
7
10
  * while `compact()` is rewriting the session tree — the field path that can make
@@ -111,7 +111,7 @@ export type DispatchLiveness = (typeof DISPATCH_LIVENESSES)[number];
111
111
  // unreachable (the native-push rail measures it — entwurf-v2-decider.ts). Widening
112
112
  // THIS set is a deliberate future decision, gated by a real compatible
113
113
  // control-socket predicate — never by silently mapping sessions to dead/indeterminate
114
- // (R1 핵심). check-entwurf-facts pins this == ["pi"] and asserts the native-push
114
+ // (R1 핵심). pi-extensions/lib/entwurf-facts.test.ts pins this == ["pi"] and asserts the native-push
115
115
  // domain is disjoint from it.
116
116
  export const LIVENESS_DOMAIN_BACKENDS = ["pi"] as const;
117
117
  export type LivenessDomainBackend = (typeof LIVENESS_DOMAIN_BACKENDS)[number];
@@ -125,7 +125,7 @@ export function isLivenessSupported(backend: string): boolean {
125
125
  // live vendor app-server target probe), NOT a control socket. This domain is DISJOINT
126
126
  // from LIVENESS_DOMAIN_BACKENDS: an Antigravity or Codex session is `unsupported` on
127
127
  // the socket FACT axis (entwurf_peers) yet independently measured on the native-push
128
- // axis. The two are separate rails on purpose — check-entwurf-facts pins both sets
128
+ // axis. The two are separate rails on purpose — pi-extensions/lib/entwurf-facts.test.ts pins both sets
129
129
  // and asserts their intersection is ∅ (a backend can never be in both domains).
130
130
  export const NATIVE_PUSH_BACKENDS = ["antigravity", "codex"] as const;
131
131
  export type NativePushBackend = (typeof NATIVE_PUSH_BACKENDS)[number];
@@ -82,7 +82,7 @@ export const LOCK_SUFFIX = ".lock";
82
82
  export const LOCK_OWNER = "entwurf_v2" as const;
83
83
 
84
84
  /** The reject reason a lock conflict maps to. Kept as a literal here (the lock
85
- * primitive stays decoupled from the full contract); `check-entwurf-v2-lock`
85
+ * primitive stays decoupled from the full contract); `pi-extensions/lib/entwurf-v2-lock.test.ts`
86
86
  * cross-checks it against the contract's ENTWURF_V2_REJECT_REASONS so the two
87
87
  * cannot drift. */
88
88
  export const LOCK_CONFLICT_REASON = "target-locked" as const;
@@ -201,7 +201,7 @@ export async function executeDispatch(
201
201
  /**
202
202
  * 5d-2a: the deps `runEntwurfV2` joins. `decide` is the WHOLE decider as ONE injected
203
203
  * function — NOT `DispatchDeciderDeps`. The runner does not re-validate the 5b decider
204
- * logic (that is `check-entwurf-v2-decider`'s job); it proves only the decide→execute
204
+ * logic (that is `pi-extensions/lib/entwurf-v2-decider.test.ts`'s job); it proves only the decide→execute
205
205
  * COMPOSITION contract over a fake `decide`. Production wraps the real decider as
206
206
  * `decide: (input) => decideDispatch(input, productionDeciderDeps)` (assembled in 5d-2b),
207
207
  * so the runner stays gate-provable without any decider IO seam leaking in.
@@ -54,6 +54,17 @@ export type FreshCallBackend = (typeof FRESH_CALL_BACKENDS)[number];
54
54
  * permission dialect: omp's approval layer consults the same minted string (`source-audit.md`).
55
55
  */
56
56
  export const FRESH_CALL_CALLBACK_TOOL: Record<FreshCallBackend, string> = {
57
+ pi: "entwurf_callback",
58
+ "claude-code": "mcp__entwurf-bridge__entwurf_callback",
59
+ copilot: "entwurf-bridge-entwurf_callback",
60
+ omp: "mcp__entwurf_bridge_entwurf_callback",
61
+ // Codex's tool process is the operator app-server, not the pane: the no-arg
62
+ // verb refuses there by name. First action stays the delivery verb with args.
63
+ codex: "mcp__entwurf_bridge__entwurf_v2",
64
+ };
65
+
66
+ /** Where a sibling SENDS the task result. Always the delivery verb, never the birth callback. */
67
+ export const FRESH_CALL_DELIVERY_TOOL: Record<FreshCallBackend, string> = {
57
68
  pi: "entwurf_v2",
58
69
  "claude-code": "mcp__entwurf-bridge__entwurf_v2",
59
70
  copilot: "entwurf-bridge-entwurf_v2",
@@ -101,8 +112,11 @@ export const FRESH_CALL_PEERS_TOOL: Record<FreshCallBackend, string> = {
101
112
  * `[측정, n=3, same model]` adding the sentence below produced 3/3 callbacks. It is a FACT about the
102
113
  * runtime, in the register the rest of this framing uses since the prohibitions came out: it grants
103
114
  * nothing, forbids nothing, and names the one call that turns a listed name into a callable tool.
104
- * Both tools ride ONE `ToolSearch` select, so the corroboration the framing offers stays reachable
105
- * for a child that takes the offer.
115
+ * ALL THREE tools ride ONE `ToolSearch` select the birth callback, the DELIVERY verb the last
116
+ * line of the framing names, and the peers listing the corroboration sentence offers. The delivery
117
+ * verb has to be in the same select or the deferral simply moves from the first action to the last:
118
+ * a sibling that finishes its task and cannot call `entwurf_v2` is the same production silence,
119
+ * spent instead of saved.
106
120
  *
107
121
  * Every other backend gets an EMPTY array, deliberately: pi has no deferred-tool surface, and the
108
122
  * other three were never measured to need it. A hint invented for a runtime nobody probed would be
@@ -112,7 +126,7 @@ export const FRESH_CALL_TOOL_LOAD_HINT: Record<FreshCallBackend, readonly string
112
126
  pi: [],
113
127
  "claude-code": [
114
128
  "If ${callbackTool} is not callable yet, the entwurf-bridge server is still connecting or its",
115
- 'tools are deferred: load them first with ToolSearch("select:${callbackTool},${peersTool}") —',
129
+ 'tools are deferred: load them first with ToolSearch("select:${callbackTool},${deliveryTool},${peersTool}") —',
116
130
  "it waits for the server — then call ${callbackTool}.",
117
131
  ],
118
132
  copilot: [],
@@ -144,22 +158,22 @@ export const OMP_BOOTSTRAP_FLAG = "entwurf-bootstrap";
144
158
 
145
159
  /** Payload grammar version, matched exactly by the decoder. A bump means a stale installed
146
160
  * unit, which is the one thing `doctor-omp-bridge` exists to say out loud. */
147
- export const OMP_BOOTSTRAP_VERSION = 1;
161
+ export const OMP_BOOTSTRAP_VERSION = 2;
148
162
 
149
163
  /**
150
164
  * The whole of what a fresh omp sibling is launched with.
151
165
  *
152
- * THREE FIELDS, CLOSED. The decoder refuses an unknown key, so this object is the entire
153
- * contract: who to call back, the nonce that proves it is this call, and the task that is
154
- * released only after that callback succeeds. There is no command here, no path, no env name
155
- * and no model the model is already an explicit argv token, and a second copy of it inside
156
- * a payload would be a second place for it to disagree with the launch.
166
+ * TWO FIELDS, CLOSED. Address (target + nonce) rides process env, not this payload a second
167
+ * copy here would be a second address axis. The decoder refuses an unknown key, so this object
168
+ * is the entire remaining contract: the task released after the no-arg callback succeeds.
169
+ * `callerGardenId` / `nonce` stay on the function so launcher call sites do not grow a second
170
+ * shape; they are not serialised.
157
171
  */
158
172
  export function buildOmpBootstrapPayload(params: { callerGardenId: string; nonce: string; task: string }): string {
173
+ void params.callerGardenId;
174
+ void params.nonce;
159
175
  return JSON.stringify({
160
176
  v: OMP_BOOTSTRAP_VERSION,
161
- target: params.callerGardenId,
162
- nonce: params.nonce,
163
177
  task: params.task,
164
178
  });
165
179
  }
@@ -298,7 +312,11 @@ export function composeBackendArgs(
298
312
  case "pi":
299
313
  return [composition.prompt, "--entwurf-control", "--model", model];
300
314
  case "claude-code":
301
- return [composition.prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
315
+ return [
316
+ composition.prompt,
317
+ `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]},${FRESH_CALL_DELIVERY_TOOL["claude-code"]}`,
318
+ `--model=${model}`,
319
+ ];
302
320
  case "copilot":
303
321
  return ["copilot", "--interactive", composition.prompt, "--model", model, "--yolo"];
304
322
  case "omp":
@@ -354,28 +372,44 @@ export function composeFreshCallFraming(params: {
354
372
  * default: a sibling told nothing about where it is would be told something false by silence. */
355
373
  openingLine: string;
356
374
  }): string[] {
357
- const tool = FRESH_CALL_CALLBACK_TOOL[params.backend];
375
+ const birthTool = FRESH_CALL_CALLBACK_TOOL[params.backend];
376
+ const deliveryTool = FRESH_CALL_DELIVERY_TOOL[params.backend];
358
377
  const peersTool = FRESH_CALL_PEERS_TOOL[params.backend];
359
378
  // The hint is a per-backend TEMPLATE so the two tool dialects stay the single source of their
360
379
  // own spelling here too — a hint that hard-coded Claude's name would go stale the day a dialect
361
380
  // moves, and it would go stale silently, in the one sentence a stuck child depends on.
362
381
  const loadHint = FRESH_CALL_TOOL_LOAD_HINT[params.backend].map((line) =>
363
- line.replaceAll("${callbackTool}", tool).replaceAll("${peersTool}", peersTool),
382
+ line
383
+ .replaceAll("${callbackTool}", birthTool)
384
+ .replaceAll("${deliveryTool}", deliveryTool)
385
+ .replaceAll("${peersTool}", peersTool),
364
386
  );
365
387
  const hintBlock = loadHint.length === 0 ? [] : ["", ...loadHint];
366
388
  if (params.openingLine.length === 0) {
367
389
  throw new Error("fresh-call composition: openingLine is empty — the rail must state where it placed the sibling");
368
390
  }
391
+ const firstAction =
392
+ params.backend === "codex"
393
+ ? [
394
+ `FIRST ACTION, before reading files or anything else: call ${birthTool} with ` +
395
+ `target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
396
+ `message set to exactly ${params.nonce} — that string alone, nothing added.`,
397
+ `${params.nonce} is a correlation tag this caller minted for this one launch: it names no`,
398
+ "secret and grants no access. Sending it is how the caller learns the garden id you were born",
399
+ "with, which it has no other way to know. Your own record does not carry the caller's address,",
400
+ "so reporting your identity here does not reach it — that tool call does.",
401
+ "entwurf_callback is refused on this rail: the tool process is the operator app-server, not this pane.",
402
+ ]
403
+ : [
404
+ `FIRST ACTION, before reading files or anything else: call ${birthTool} with no arguments.`,
405
+ "That call is how the caller learns the garden id you were born with. There are no",
406
+ "parameters: a target or message you type would be a second address axis.",
407
+ "Your own record does not carry the caller's address, so reporting your identity here does not reach it — that tool call does.",
408
+ ];
369
409
  return [
370
410
  params.openingLine,
371
411
  "",
372
- `FIRST ACTION, before reading files or anything else: call ${tool} with ` +
373
- `target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
374
- `message set to exactly ${params.nonce} — that string alone, nothing added.`,
375
- `${params.nonce} is a correlation tag this caller minted for this one launch: it names no`,
376
- "secret and grants no access. Sending it is how the caller learns the garden id you were born",
377
- "with, which it has no other way to know. Your own record does not carry the caller's address,",
378
- "so reporting your identity here does not reach it — that tool call does.",
412
+ ...firstAction,
379
413
  "",
380
414
  `You can corroborate the caller first if you want to: ${peersTool} is a read-only listing of`,
381
415
  `this garden's citizens and ${params.callerGardenId} is one of them. That listing is capped, so`,
@@ -393,7 +427,7 @@ export function composeFreshCallFraming(params: {
393
427
  // its window is not the delivery. Nothing here watches for completion or sends on the
394
428
  // sibling's behalf — this rail launches and lets go (Hard Rule 16, and the reason there is no
395
429
  // supervisor anywhere in it).
396
- `When the task reaches its requested final result, send that result to the same target with ${tool}.`,
430
+ `When the task reaches its requested final result, send that result to the same target with ${deliveryTool}.`,
397
431
  "Output in this sibling window is not delivered to the caller.",
398
432
  ];
399
433
  }
@@ -44,6 +44,7 @@
44
44
  import { createHash } from "node:crypto";
45
45
  import { statSync } from "node:fs";
46
46
  import path from "node:path";
47
+ import { callbackEnvAssignments } from "./callback-env.ts";
47
48
  import {
48
49
  buildOmpBootstrapPayload,
49
50
  composeBackendArgs,
@@ -443,7 +444,11 @@ export function herdrAgentNameFromNonce(nonce: string): string {
443
444
  * not from a screen. Repeating the flag is the grammar; `--env KEY=` injects the EMPTY value
444
445
  * rather than dropping the key.
445
446
  */
446
- export function buildHerdrTabCreateArgs(params: { workspaceId: string; cwd?: string }): string[] {
447
+ export function buildHerdrTabCreateArgs(params: {
448
+ workspaceId: string;
449
+ cwd?: string;
450
+ callback: { target: string; nonce: string };
451
+ }): string[] {
447
452
  return [
448
453
  "tab",
449
454
  "create",
@@ -455,6 +460,7 @@ export function buildHerdrTabCreateArgs(params: { workspaceId: string; cwd?: str
455
460
  "PI_SESSION_ID=",
456
461
  "--env",
457
462
  "PI_AGENT_ID=",
463
+ ...callbackEnvAssignments(params.callback).flatMap((assignment) => ["--env", assignment]),
458
464
  ];
459
465
  }
460
466
 
@@ -849,6 +855,7 @@ export async function herdrFreshCall(
849
855
  buildHerdrTabCreateArgs({
850
856
  workspaceId: callerPane.workspaceId,
851
857
  ...(cwd === undefined ? {} : { cwd }),
858
+ callback: { target: callerGardenId, nonce },
852
859
  }),
853
860
  );
854
861
  if (tabRun.status !== 0) {
@@ -1205,9 +1212,10 @@ export function renderHerdrFreshCall(result: HerdrFreshCallResult): { text: stri
1205
1212
  `mean the sibling is running, that its first turn ran, or that the task was delivered. The tab and pane ` +
1206
1213
  `coordinates are a view and can change under the sibling — they are not an address and nothing may be ` +
1207
1214
  `dispatched to them.\n` +
1208
- `The sibling's garden id arrives separately — it calls entwurf_v2 back with the nonce above as its first ` +
1209
- `action, and the sender envelope of THAT message is the address. Nothing is polling for it; if it never ` +
1210
- `comes, the pane is visible and can be read directly.`,
1215
+ `The sibling's garden id arrives separately — its first action is the zero-argument callback verb, and ` +
1216
+ `the sender envelope of THAT message is the address. The nonce above is the correlation tag it carries; ` +
1217
+ `the sibling reads it from its own launch env, never from this receipt. Nothing is polling for it; if it ` +
1218
+ `never comes, the pane is visible and can be read directly.`,
1211
1219
  isError: false,
1212
1220
  };
1213
1221
  }
@@ -21,7 +21,7 @@
21
21
  * THE TWO AXES ARE NOT EQUALLY EXACT, AND THE DIFFERENCE IS NAMED. On the claude axis
22
22
  * the reported value IS the key, byte for byte. On the pi axis it is a session FILE
23
23
  * PATH, and the key is recovered from that filename by a strict conversion measured on
24
- * pi 0.85.1 — a VENDOR FLOOR, not a key equality. Calling both "a unique-key join"
24
+ * pi 0.86.0 — a VENDOR FLOOR, not a key equality. Calling both "a unique-key join"
25
25
  * would hide which one can drift when a vendor renames a file. The conversion is
26
26
  * deliberately strict at both ends so that drift lands as a missed join, never a wrong
27
27
  * one, and a listing that declined to read anything says so (see `declinedReports`).
@@ -112,14 +112,23 @@ const OFFICIAL_REPORTS: Readonly<
112
112
  };
113
113
 
114
114
  /**
115
- * A pi session filename, measured 2026-09-14 on pi 0.85.1:
115
+ * A pi session filename, measured 2026-09-20 on pi 0.86.0:
116
116
  *
117
- * 2026-09-14T05-17-03-979Z_01a09e58-f06a-70e8-b14a-1f0f0c7f7c7d.jsonl
117
+ * 2026-09-20T00-35-44-197Z_01a0bc3d-87c4-738e-b391-265c8ba5a1b0.jsonl
118
118
  * └────────── start stamp ─────────┘ └──────── nativeSessionId ────────┘
119
119
  *
120
+ * `[측정 2026-09-20]` that is a REAL file a sandboxed pi 0.86.0 wrote, and its own
121
+ * `{"type":"session"}` header carries `id: 01a0bc3d-87c4-738e-b391-265c8ba5a1b0` —
122
+ * the exact string `piNativeSessionIdFromPath` recovers from the name. The layout was
123
+ * re-measured rather than carried: pi 0.86.0 rewrote session-manager.ts (+269 −141
124
+ * over v0.85.1), but every line that BUILDS a name is byte-unchanged
125
+ * (`${fileTimestamp}_${this.sessionId}.jsonl`, session-manager.ts:991/1527/1703); the
126
+ * only diff in that file's `.jsonl` lines is a lambda parameter rename on the READER
127
+ * side. A source read alone would not have settled it, so the real file is the receipt.
128
+ *
120
129
  * WHY THIS IS PINNED HERE AND NOT INFERRED. The uuid is the join key and it lives in
121
130
  * a VENDOR filename, so this rule depends on pi's naming and would break silently if
122
- * pi changed it. **Vendor floor: pi 0.85.1, measured 2026-09-14** — whether that layout
131
+ * pi changed it. **Vendor floor: pi 0.86.0, measured 2026-09-20** — whether that layout
123
132
  * is a vendor contract or a convention is NOT measured, so this is the one place in the
124
133
  * axis that can drift under us. It is therefore strict on both ends — the name must end
125
134
  * in `.jsonl` and the tail after the last `_` must be a well-formed uuid — and a name