@junghanacs/entwurf 0.24.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +564 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/DELIVERY.md +24 -1
  4. package/README.md +2 -2
  5. package/VERIFY.md +20 -2
  6. package/demo/README.md +5 -2
  7. package/docs/acp-backend-rail.md +49 -2
  8. package/docs/setup-clean-host.md +4 -4
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +5 -4
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-send-receipt.js +101 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +17 -1
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +4 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +53 -6
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +38 -3
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +13 -3
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +11 -0
  18. package/mcp/entwurf-bridge/dist/protocol.js +0 -7
  19. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +40 -3
  20. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +72 -0
  21. package/mcp/entwurf-bridge/src/index.ts +5 -4
  22. package/package.json +10 -9
  23. package/pi-extensions/entwurf-control.ts +27 -154
  24. package/pi-extensions/lib/compaction-send-guard.ts +5 -3
  25. package/pi-extensions/lib/control-send-receipt.ts +108 -0
  26. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  27. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -2
  28. package/pi-extensions/lib/entwurf-v2-production.ts +19 -1
  29. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -1
  30. package/pi-extensions/lib/entwurf-v2-send.ts +82 -6
  31. package/pi-extensions/lib/entwurf-v2-surface.ts +42 -3
  32. package/pi-extensions/lib/herdr-fresh-call.ts +13 -3
  33. package/pi-extensions/lib/herdr-placement.ts +11 -0
  34. package/protocol.js +0 -8
  35. package/run.sh +202 -33
  36. package/scripts/check-entwurf-v2-surface.ts +289 -10
  37. package/scripts/check-gate-qualification.ts +2354 -11
  38. package/scripts/check-herdr-fresh-call.ts +10 -2
  39. package/scripts/check-herdr-placement.ts +51 -0
  40. package/scripts/check-herdr-plugin-build.ts +11 -4
  41. package/scripts/check-herdr-plugin.ts +745 -38
  42. package/scripts/check-mux-launcher-fence.ts +37 -0
  43. package/scripts/check-pi-launch.ts +7 -4
  44. package/scripts/check-release-gate-outcomes.ts +165 -0
  45. package/scripts/doctor-pi-provider.ts +44 -4
  46. package/scripts/herdr-rails.mjs +75 -0
  47. package/scripts/lib/qualification-acceptance.ts +146 -0
  48. package/scripts/lib/qualification-artifact.ts +359 -0
  49. package/scripts/lib/qualification-collection.ts +384 -0
  50. package/scripts/lib/qualification-gh.ts +231 -0
  51. package/scripts/lib/qualification-ledger.ts +669 -0
  52. package/scripts/lib/qualification-oracle-core.ts +279 -0
  53. package/scripts/lib/qualification-receipt.ts +561 -0
  54. package/scripts/lib/qualification-release.ts +271 -0
  55. package/scripts/meta-bridge-state.py +18 -1
  56. package/scripts/mutants/bridge-command-boot.json +38 -0
  57. package/scripts/mutants/control-send-receipt.json +235 -0
  58. package/scripts/mutants/herdr-fresh-call.json +4 -4
  59. package/scripts/mutants/herdr-plugin-build.json +7 -11
  60. package/scripts/mutants/herdr-plugin.json +177 -9
  61. package/scripts/mutants/meta-retire.json +22 -0
  62. package/scripts/mutants/pack-install.json +4 -4
  63. package/scripts/mutants/pi-floor.json +17 -0
  64. package/scripts/mutants/qualification-ledger.json +489 -0
  65. package/scripts/mutants/release-gate.json +48 -0
  66. package/scripts/mutants/retired-sent-surface.json +32 -0
  67. package/scripts/mutants/v2-surface.json +7 -7
  68. package/scripts/qualification-oracle.ts +124 -0
  69. package/scripts/smoke-codex-fresh-live.ts +94 -13
  70. package/scripts/smoke-herdr-fresh-call-live.ts +15 -8
  71. package/scripts/smoke-herdr-raw-install-live.sh +66 -4
  72. package/scripts/smoke-meta-install-state.sh +107 -1
  73. package/scripts/smoke-mux-lifecycle-live.ts +9 -2
  74. package/scripts/smoke-pi-provider-state.sh +106 -0
  75. package/scripts/smoke-setup-verdict.sh +18 -2
@@ -143,11 +143,24 @@ log(` project ${projectPath}: ${projScopeDesc}`);
143
143
  log(` EFFECTIVE (${effectiveScope}): ${effectiveDesc}`);
144
144
  // install-state ownership (user scope). absent state on a configured effective is either a
145
145
  // pre-Task-2 install or a user-override we deliberately did not own.
146
+ //
147
+ // `command` is read beside it because it — not the bare name — is the CANONICAL managed command
148
+ // (#116 M3-b2). `register-pi-provider.py` writes the same string into the settings entry and into
149
+ // this record (:332-342, :368-369), and in plugin mode that string is an absolute bridge under the
150
+ // certified stable runtime, because a Herdr-plugin host has nothing entwurf on PATH. Reading it
151
+ // here is what lets this doctor judge drift without re-deriving the stable runtime root: the root
152
+ // is the writer's business, and a second derivation in this file would be a second address axis.
153
+ //
154
+ // `ownership` is the PREIMAGE classification — what stood at the key BEFORE that install — so
155
+ // `absent` means "we created it", not "nobody owns it". Treating it as an ownership verdict is what
156
+ // made every plugin-mode host red while its bridge booted and served all eight verbs.
146
157
  let ownership;
158
+ let stateCommand;
147
159
  if (existsSync(statePath)) {
148
160
  try {
149
161
  const st = JSON.parse(readFileSync(statePath, "utf8"));
150
162
  ownership = typeof st.ownership === "string" ? st.ownership : undefined;
163
+ stateCommand = typeof st.command === "string" ? st.command : undefined;
151
164
  log(` state: install-state present (ownership=${ownership}).`);
152
165
  }
153
166
  catch {
@@ -200,9 +213,33 @@ else {
200
213
  hardFail = 1;
201
214
  }
202
215
  }
203
- if (!isBare) {
204
- // Ownership classification never rounds a broken runtime up to green. It only says who may
205
- // repair the non-canonical entry after the independent boot verdict above.
216
+ // Ownership classification never rounds a broken runtime up to green. It only says who may
217
+ // repair the entry after the independent boot verdict above.
218
+ //
219
+ // A MANAGED record is the pair (known preimage ownership, recorded command). For that pair the
220
+ // recorded command is canonical and the comparison runs REGARDLESS of whether the effective
221
+ // command happens to be the bare name. Keeping this decision inside a `!isBare` branch is how
222
+ // real drift escaped: a record naming the absolute plugin bridge, with the settings repointed to
223
+ // the bare `entwurf-bridge`, is drift — and a bare bridge that boots would otherwise carry it to
224
+ // green. "The effective command is spellable as our old default" is not evidence that it is the
225
+ // command this install wrote.
226
+ //
227
+ // Everything else — a record that names no command, or one whose ownership is a word this
228
+ // doctor does not know — falls through to the ORIGINAL logic below, unchanged. Repairing this
229
+ // defect is not a licence to migrate the state schema, to grant an amnesty to records written
230
+ // before `command` existed, or to let an unknown ownership word buy a managed green because a
231
+ // string happened to match. The state is rewritten by `./run.sh setup`, not by its reader.
232
+ const knownManagedOwnership = ownership === "absent" || ownership === "managed-current" || ownership === "managed-legacy";
233
+ if (knownManagedOwnership && stateCommand !== undefined) {
234
+ if (cmd !== stateCommand) {
235
+ log(` FAIL: state records '${stateCommand}' as the managed command (ownership=${ownership}), but the effective command is '${cmd}' — the key drifted to something entwurf never wrote.`);
236
+ hardFail = 1;
237
+ }
238
+ else {
239
+ log(` ok: effective command is the managed command this install recorded ('${stateCommand}', preimage ownership=${ownership}).`);
240
+ }
241
+ }
242
+ else if (!isBare) {
206
243
  if (ownership && ownership !== "user-override") {
207
244
  log(` FAIL: state owns entwurf-bridge (ownership=${ownership}) but the effective command drifted to '${cmd}'.`);
208
245
  hardFail = 1;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * herdr-rails — the ONE place the herdr rail's backend set and its per-backend model SYNTAX
3
+ * example are written down (#116 A7).
4
+ *
5
+ * WHY THIS FILE EXISTS. Three surfaces were about to say the same two things in three
6
+ * dialects: `herdr-fresh-call.ts` owned the closed backend tuple, `build.mjs` narrated one
7
+ * model id per harness at install time, and the status pane was about to print a RAILS block.
8
+ * Three copies of one fact is how a rail quietly grows a fourth backend in prose that no
9
+ * dispatch honours. So the fact lives here and the three read it.
10
+ *
11
+ * WHY `.mjs` AND NOT `.ts`. It is read by two runtimes that cannot agree on anything else:
12
+ * the Herdr plugin runs `node lib/status.mjs` with no loader flags at all, and the pi
13
+ * extension is typechecked by `tsc`. A `.ts` leaf would be unloadable by the first; a second
14
+ * copy would defeat the point. `protocol.js` set this precedent for exactly the same reason
15
+ * (root tsconfig.json `allowJs`), and this file keeps its shape: dependency-free, no imports,
16
+ * no environment, no IO, so importing it can never be a side effect.
17
+ *
18
+ * WHAT THIS FILE IS NOT, and each absence is load-bearing:
19
+ * - NOT a model catalogue. One example per backend, chosen to show the SHAPE of the string
20
+ * (`provider/model` vs a bare vendor id). Adding a second example starts a list, a list
21
+ * invites completeness, and completeness is a catalogue Entwurf would then be expected to
22
+ * keep current for every vendor. `entwurf_fresh_call` takes a model string with no default
23
+ * and validates nothing; that is the contract, and an example must not imply otherwise.
24
+ * - NOT a supported-model set, a recommendation, or a validator. Nothing reads MODEL_SYNTAX_EXAMPLE
25
+ * to decide anything — it is display text on three surfaces.
26
+ * - NOT a provider, billing or quota statement. No rail here names a provider, a subscription
27
+ * or a cost. `#76` is about which rail a sibling may be launched on, and a syntax example
28
+ * that mentioned a provider would read as an answer to it. It does not answer it.
29
+ * - NOT a launcher, and not a claim that any binary exists. `HERDR_AGENT_KIND` (the herdr
30
+ * `--kind` token) stays with the rail that spends it; this leaf carries no argv.
31
+ */
32
+ /**
33
+ * The closed pilot set for the herdr launch rail. Herdr's own `--kind` enum is much larger and
34
+ * that is not evidence of support: every other backend is a named pre-mutation reject.
35
+ *
36
+ * The JSDoc cast is what keeps the TYPE half of this single source honest. Without it TypeScript
37
+ * infers `readonly string[]` from a `.mjs` file and `HerdrFreshCallBackend` silently widens to
38
+ * `string` — the tuple would still be one source at runtime while the compiler had stopped
39
+ * checking anything. A gate pins both halves.
40
+ *
41
+ * @type {readonly ["pi", "claude-code"]}
42
+ */
43
+ export const HERDR_FRESH_CALL_BACKENDS = Object.freeze(["pi", "claude-code"]);
44
+ /**
45
+ * ONE model string per backend, and it is an example of SYNTAX, never of choice.
46
+ *
47
+ * `[관측: GLG, 날것 PC, 2026-09-17]` "일단 정확한 모델명을 모른다" — after a green install the first
48
+ * thing missing is a call that works, and the two runtimes spell a model differently enough that
49
+ * guessing fails: pi wants a provider-qualified path, Claude Code wants the vendor id alone.
50
+ * That difference is the whole content of this map.
51
+ *
52
+ * @type {Readonly<Record<"pi" | "claude-code", string>>}
53
+ */
54
+ export const MODEL_SYNTAX_EXAMPLE = Object.freeze({
55
+ pi: "openai-codex/gpt-5.6-terra",
56
+ "claude-code": "claude-sonnet-5",
57
+ });
58
+ /**
59
+ * How a citizen on each backend is REACHED once it exists, in the operator's words. This is the
60
+ * rail fact the status pane's RAILS block renders and the install narration states — the same
61
+ * sentence, so a host cannot be told two different things about one backend.
62
+ *
63
+ * `[관측: GLG, 날것 PC, 2026-09-17]` from the operator's chair a plain `pi` looks identical to a
64
+ * citizen pi: the install says green, `pi` starts, and nothing is there. Citizenship is argv-gated
65
+ * on purpose, and that gate is invisible unless something says it out loud.
66
+ *
67
+ * @type {Readonly<Record<"pi" | "claude-code", string>>}
68
+ */
69
+ export const RAIL_ENTRY_NOTE = Object.freeze({
70
+ pi: "start it as `pi --entwurf-control` to be a garden citizen — a plain `pi` loads the extension but has no garden id, no control socket and no entwurf tools",
71
+ "claude-code": "nothing to add — an ordinary `claude` picks up the entwurf tools through MCP",
72
+ });
@@ -431,7 +431,9 @@ server.tool(
431
431
  "alone does not say which rail that citizen answers on. Give target + intent; the decider picks transport " +
432
432
  "from liveness (live socket citizen → control-socket send; deliverable self-fetch citizen → meta-bridge " +
433
433
  "mailbox; probe-alive native-push citizen → direct injection into its conversation) and reports ONE " +
434
- "outcome (delivered / rejected / delivered-but-lock-dirty). EXISTING targets only; discover with " +
434
+ "outcome, named per rail: a control receiver's acceptance boundary (`sent` / `queued-steer` / " +
435
+ "`queued-follow-up` / `accepted-unknown-boundary` — those four only), a mailbox ENQUEUE receipt, a " +
436
+ "native-push INJECTION, a rejection, or lock-dirty. EXISTING targets only; discover with " +
435
437
  "entwurf_peers. A peer entwurf_peers shows as liveness=alive → fire-and-forget. A " +
436
438
  "citizen with NO socket liveness (liveness=unsupported) is ALSO fire-and-forget — unsupported means only " +
437
439
  '"no control-socket probe." The decider resolves the actual rail at dispatch time: a self-fetch ' +
@@ -472,9 +474,8 @@ server.tool(
472
474
  .enum(["steer", "follow_up"])
473
475
  .optional()
474
476
  .describe(
475
- "Injection style for a CONTROL-SOCKET send only: steer (interrupt the current turn) or " +
476
- "follow_up (queue after it). The mailbox and native-push plans carry no mode, so it has no " +
477
- "effect on those rails.",
477
+ "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. " +
478
+ "The mailbox and native-push plans carry no mode, so it has no effect on those rails.",
478
479
  ),
479
480
  wants_reply: z.boolean().optional().describe("Human-conversation reply hint (default false)"),
480
481
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
4
4
  "description": "Garden-citizen dispatch substrate and meta-bridge for pi, Claude Code, Copilot CLI, OMP, Antigravity, and Codex CLI; Claude and Snowflake Cortex Code ACP backends.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -92,16 +92,17 @@
92
92
  "zod": "^3.25.0 || ^4.0.0"
93
93
  },
94
94
  "peerDependencies": {
95
- "@earendil-works/pi-ai": ">=0.86.0 <0.87",
96
- "@earendil-works/pi-coding-agent": ">=0.86.0 <0.87",
97
- "@earendil-works/pi-tui": ">=0.86.0 <0.87",
95
+ "@earendil-works/pi-ai": ">=0.87.1 <0.88",
96
+ "@earendil-works/pi-coding-agent": ">=0.87.1 <0.88",
97
+ "@earendil-works/pi-tui": ">=0.87.1 <0.88",
98
98
  "typebox": "*"
99
99
  },
100
100
  "devDependencies": {
101
101
  "@biomejs/biome": "^2.4.13",
102
- "@earendil-works/pi-ai": "0.86.0",
103
- "@earendil-works/pi-coding-agent": "0.86.0",
104
- "@earendil-works/pi-tui": "0.86.0",
102
+ "@earendil-works/pi-agent-core": "0.87.1",
103
+ "@earendil-works/pi-ai": "0.87.1",
104
+ "@earendil-works/pi-coding-agent": "0.87.1",
105
+ "@earendil-works/pi-tui": "0.87.1",
105
106
  "@types/node": "^24.3.0",
106
107
  "husky": "^9.1.7",
107
108
  "rregex": "1.13.1",
@@ -175,8 +176,8 @@
175
176
  "check:full": "bash scripts/check-elapsed.sh check:full check:core check:hermetic check:package",
176
177
  "check:core": "pnpm run check:toolchain && pnpm run check:vitest && pnpm run check:contracts",
177
178
  "check:toolchain": "pnpm lint && pnpm typecheck",
178
- "check:vitest": "./run.sh check-mux-fresh-call && ./run.sh check-acp-sdk-surface && ./run.sh check-tests-beside-behavior",
179
- "check:contracts": "./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-v3-record && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-harness-admission-parity && ./run.sh check-capability-bundle-reach && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-capability-source && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-node-floor-coherence && ./run.sh check-claude-floor-coherence && ./run.sh check-copilot-statusline && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-send && ./run.sh check-compaction-send-guard && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-native-push && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-resume-launch-identity && ./run.sh check-mux-placement && ./run.sh check-mux-launch && ./run.sh check-mux-resume-call && ./run.sh check-mux-parent-artifact && ./run.sh check-mux-launcher-fence && ./run.sh check-entwurf-v2-visible-resume && ./run.sh check-control-socket-path && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-herdr-placement && ./run.sh check-herdr-fresh-call && ./run.sh check-herdr-supply && ./run.sh check-fresh-call-dispatch && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-native-push-adapter && ./run.sh check-native-push-register && ./run.sh check-auth-boundary && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-carrier-augment && ./run.sh check-codex-sender-identity && ./run.sh check-codex-native-push",
179
+ "check:vitest": "./run.sh check-mux-fresh-call && ./run.sh check-acp-sdk-surface && ./run.sh check-pi-queue-oracle && ./run.sh check-tests-beside-behavior",
180
+ "check:contracts": "./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-v3-record && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-harness-admission-parity && ./run.sh check-capability-bundle-reach && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-capability-source && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-node-floor-coherence && ./run.sh check-claude-floor-coherence && ./run.sh check-copilot-statusline && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-runner && ./run.sh check-control-send-receipt && ./run.sh check-compaction-send-guard && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-native-push && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-resume-launch-identity && ./run.sh check-mux-placement && ./run.sh check-mux-launch && ./run.sh check-mux-resume-call && ./run.sh check-mux-parent-artifact && ./run.sh check-mux-launcher-fence && ./run.sh check-entwurf-v2-visible-resume && ./run.sh check-control-socket-path && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-herdr-placement && ./run.sh check-herdr-fresh-call && ./run.sh check-herdr-supply && ./run.sh check-fresh-call-dispatch && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-native-push-adapter && ./run.sh check-native-push-register && ./run.sh check-auth-boundary && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-carrier-augment && ./run.sh check-codex-sender-identity && ./run.sh check-codex-native-push",
180
181
  "check:hermetic": "./run.sh check-bridge-delivery && ./run.sh smoke-pi-attach && ./run.sh check-fresh-cut-gate && ./run.sh check-hook-launch-topology && ./run.sh check-meta-hook-session-switch && ./run.sh check-copilot-birth-hook && ./run.sh check-copilot-receive-arm && ./run.sh check-copilot-launch && ./run.sh check-pi-launch && ./run.sh check-codex-app-server-launch && ./run.sh check-meta-identity-consumers && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh check-meta-doctor-oracle && ./run.sh check-agy-permission-matrix && ./run.sh smoke-agy-statusline-state && ./run.sh smoke-copilot-statusline-state && ./run.sh smoke-copilot-mcp-state && ./run.sh smoke-omp-bridge-state && ./run.sh smoke-omp-mcp-state && ./run.sh smoke-omp-receive-state && ./run.sh check-omp-birth-hook && ./run.sh check-omp-receive-arm && ./run.sh check-omp-fresh-preflight && ./run.sh smoke-agy-hooks-state && ./run.sh smoke-pi-provider-state && ./run.sh smoke-user-scope-citizen && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-bridge-boot && ./run.sh check-probe-bridge-command && ./run.sh check-meta-facts && ./run.sh check-peer-facts && ./run.sh check-herdr-plugin && ./run.sh check-herdr-plugin-profile && ./run.sh check-herdr-plugin-build && ./run.sh check-herdr-runtime-bootstrap && ./run.sh check-herdr-activation && ./run.sh check-agy-sender-identity && ./run.sh check-acp-provider-surface && ./run.sh check-acp-stop-reason && ./run.sh check-acp-prompt-lifecycle && ./run.sh check-acp-usage-accounting && ./run.sh check-acp-launch-namespace && ./run.sh check-acp-stream-hooks && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-typing-call-fence && ./run.sh check-gate-manifests && ./run.sh check-release-gate-outcomes && ./run.sh check-probe-ordering && ./run.sh check-probe-cli-shim && ./run.sh check-acp-cortex && ./run.sh check-codex-bridge-identity && ./run.sh check-codex-birth-hook && ./run.sh smoke-codex-config-state && ./run.sh smoke-codex-birth && ./run.sh check-herdr-sandbox",
181
182
  "check:package": "./run.sh check-install-surface && ./run.sh smoke-meta-install-state && ./run.sh smoke-agy-install-state && ./run.sh smoke-setup-verdict && ./run.sh check-pack-pin-matcher && ./run.sh check-package-source-routing && ./run.sh check-install-preflight && ./run.sh check-pack"
182
183
  },
@@ -78,9 +78,8 @@ import type {
78
78
  ExtensionContext,
79
79
  MessageRenderer,
80
80
  } from "@earendil-works/pi-coding-agent";
81
- import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
82
- import { Box, type Component, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
83
- import { ENTWURF_SENT_MESSAGE_TYPE } from "../protocol.js";
81
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
82
+ import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
84
83
  import { readCallbackEnv } from "./lib/callback-env.js";
85
84
  import {
86
85
  type CompactionGuard,
@@ -89,6 +88,7 @@ import {
89
88
  noteCompactionBefore,
90
89
  noteCompactionTerminal,
91
90
  } from "./lib/compaction-send-guard.js";
91
+ import { sendReceiptBoundary } from "./lib/control-send-receipt.js";
92
92
  import { CONTROL_SOCKET_SUFFIX, controlSocketPathIn, defaultControlSocketDir } from "./lib/control-socket-path.js";
93
93
  import {
94
94
  attachAcceptedSocketDisconnectPolicy,
@@ -112,15 +112,6 @@ const EMACS_AGENT_SOCKET_FLAG = "emacs-agent-socket";
112
112
  // comes from the `.js` leaf both runtime lanes can import.
113
113
  const ENTWURF_DIR = defaultControlSocketDir(os.homedir());
114
114
  const SESSION_MESSAGE_TYPE = "entwurf-message";
115
- // Sender-side UI marker. Layer B (ACP path) emits a CustomMessage with this
116
- // customType so the operator sees a first-class [entwurf sent →] box paired
117
- // with the receive-side [entwurf received ⟵] box. The provider-level context
118
- // filter in index.ts drops this customType before the LLM sees it — colocated
119
- // with the emitter so sessions without --entwurf-control are still protected.
120
- // There is no second emitter: the v1 native send path ("Layer A") that also drew
121
- // this box through renderSentMessage() was removed in the 0.12 cutover, and
122
- // renderSentMessage is now registered for exactly one customType (see
123
- // registerMessageRenderer below). Do not describe a native sender-side box.
124
115
  const SENDER_INFO_PATTERN = /<sender_info>[\s\S]*?<\/sender_info>/g;
125
116
 
126
117
  // ============================================================================
@@ -478,10 +469,12 @@ const renderSessionMessage: MessageRenderer = (message, { expanded }, theme) =>
478
469
  //
479
470
  // The label is "[entwurf received ⟵]" with a left-pointing arrow so the
480
471
  // receiving operator immediately sees the directionality (this is an
481
- // incoming message). The corresponding sender-side surface in
482
- // mcp/entwurf-bridge/entwurf_v2 renders "[entwurf sent →]" — same
483
- // transport, opposite arrows, no confusion about who-said-what when the
484
- // transcript is read end-to-end.
472
+ // incoming message). There is no matching box on the sending side: the one
473
+ // that used to be described here had zero producers and was removed (#120 P3).
474
+ // A sender sees a TEXT receipt naming the acceptance boundary
475
+ // (`entwurf-v2-surface.ts`), which is the honest thing to show — a box would
476
+ // claim the message was rendered into someone's transcript, and only the
477
+ // receiver can say that.
485
478
  //
486
479
  // wants_reply defaults to false (etiquette marker, not protocol contract).
487
480
  // We show the badge only when the sender explicitly set it true; an
@@ -523,124 +516,6 @@ const renderSessionMessage: MessageRenderer = (message, { expanded }, theme) =>
523
516
  return box;
524
517
  };
525
518
 
526
- // Sender-side payload — what renderSentMessage needs to draw the [entwurf sent →]
527
- // box. Carried by the ACP path's CustomMessage `details` — the ONLY carrier since
528
- // the v1 native renderResult path was removed (0.12 cutover). All four envelope fields are intentionally
529
- // echoed in the box even though the sender is "this same session" — operators
530
- // reading a busy multi-session transcript should be able to verify at a glance
531
- // which 담당자 is on the wire (cwd) and which model identity (agentId) actually
532
- // signed the message, without scrolling up to find the session header.
533
- //
534
- // timestamp is captured at execute() / send-emit time, not at render time, so
535
- // re-renders (resize, expand toggle) keep showing the moment the message was
536
- // actually delivered rather than drifting forward to "now".
537
- //
538
- // wants_reply mirrors the receive-side etiquette badge. It stays optional because
539
- // the box is drawn from a CustomMessage whose `details` may predate the field; the
540
- // v1 `registerSessionTool` / `entwurfSendParameters` schema this note used to point
541
- // at is gone (0.12 cutover), so there is no native call site left to grow.
542
- interface SentBoxData {
543
- to: string; // target sessionId
544
- from?: string; // sender agentId, e.g. "entwurf/claude-opus-5"
545
- cwd?: string; // sender cwd (raw, abbreviateHome applied at render)
546
- timestamp?: string; // ISO 8601 UTC; rendered in KST
547
- mode?: string; // "steer" | "follow_up" | string passed through
548
- wants_reply?: boolean;
549
- deliveredAs?: string; // RPC echo — surfaces when receiver remapped (e.g. queued as followUp)
550
- body: string; // message text the operator sent
551
- }
552
-
553
- // Visual mirror of renderSessionMessage. Same Box / Markdown / theme tokens —
554
- // the two boxes must share the customMessageBg / customMessageLabel /
555
- // customMessageText surface so they are pixel-equivalent in any theme. The
556
- // only deliberate visual differences are:
557
- // - label: [entwurf sent →] vs [entwurf received ⟵]
558
- // - "to:" leads, "from:" follows vs "from:" only
559
- // - mode: line (no equivalent on receive side — receiver
560
- // doesn't see how the sender queued it)
561
- //
562
- // `expanded` truncates the body the same way as renderSessionMessage so a
563
- // large send shows the same preview shape as a large receive. operators
564
- // reading the transcript should not need different mental models.
565
- // Return type is `Component`, the interface `MessageRenderer` actually asks for
566
- // (`Component | undefined`, read at pi-coding-agent
567
- // `dist/core/extensions/types.d.ts:889`) — NOT the concrete `Container`.
568
- // `[측정 2026-09-06]` pi 0.85.0's mouse work gave `Container` a `private mouseLayout?`
569
- // (`pi-tui dist/tui.d.ts:198`; 0.84.4's `Container` had no private member at all), and
570
- // `Box` declares a SEPARATE private `mouseLayout` of its own. TypeScript only accepts a
571
- // private member from the same declaration, so the structural assignment `Box -> Container`
572
- // that held through 0.84.4 became TS2322 at 0.85.x. This was an UNDECLARED break — the
573
- // upstream Breaking section names only `createGatewayBindingFetch`. Annotating the shared
574
- // interface is the honest fix: nothing here ever needed Container's own surface.
575
- const buildSentMessageBox = (data: SentBoxData, expanded: boolean, theme: Theme): Component => {
576
- let body = data.body || "(no content)";
577
- if (!expanded) {
578
- const lines = body.split("\n");
579
- if (lines.length > 5) {
580
- body = `${lines.slice(0, 5).join("\n")}\n...`;
581
- }
582
- }
583
-
584
- const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
585
- const labelBase = theme.fg("customMessageLabel", `\x1b[1m[entwurf sent →]\x1b[22m`);
586
-
587
- // Header line: label + KST + optional (wants reply) badge — matches the
588
- // receive-side header layout 1:1.
589
- const kst = formatTimestampKst(data.timestamp) ?? "(unknown time)";
590
- const replyBadge = data.wants_reply === true ? " (wants reply)" : "";
591
- const headerLine = `${labelBase} ${theme.fg("dim", `${kst}${replyBadge}`)}`;
592
- box.addChild(new Text(headerLine, 0, 0));
593
-
594
- // to: <sessionId> — target peer
595
- box.addChild(new Text(theme.fg("dim", `to: ${data.to || "(unknown sessionId)"}`), 0, 0));
596
-
597
- // from: <agentId> @ <cwd> — self identity. Shown even though it's "us"
598
- // because in a multi-session human-greeted topology the operator is
599
- // switching between several pi sessions and needs to confirm which one
600
- // signed this send.
601
- const fromAgent = data.from ?? "(unknown agent)";
602
- const fromCwd = data.cwd ? abbreviateHome(data.cwd) : "(unknown cwd)";
603
- box.addChild(new Text(theme.fg("dim", `from: ${fromAgent} @ ${fromCwd}`), 0, 0));
604
-
605
- // mode: <mode>[ → deliveredAs] — show RPC remap when it differs from
606
- // what the caller asked for (e.g. caller said "steer" but receiver was
607
- // idle so it became a direct prompt). Silent when they agree.
608
- if (data.mode) {
609
- const remap = data.deliveredAs && data.deliveredAs !== data.mode ? theme.fg("muted", ` → ${data.deliveredAs}`) : "";
610
- box.addChild(new Text(theme.fg("dim", `mode: ${data.mode}${remap}`), 0, 0));
611
- }
612
-
613
- box.addChild(new Spacer(1));
614
- box.addChild(
615
- new Markdown(body, 0, 0, getMarkdownTheme(), {
616
- color: (value: string) => theme.fg("customMessageText", value),
617
- }),
618
- );
619
- return box;
620
- };
621
-
622
- // CustomMessageRenderer adapter for Layer B (ACP path). The CustomMessage
623
- // carries the SentBoxData under `details` (set by index.ts streamShellAcp
624
- // when a completed mcp__entwurf-bridge__entwurf_v2 is observed). `content`
625
- // holds the raw message body too, but we prefer details.body because the
626
- // content channel may have been routed through string-only persistence and
627
- // trimmed.
628
- const renderSentMessage: MessageRenderer = (message, { expanded }, theme) => {
629
- const details = (message.details ?? {}) as Partial<SentBoxData>;
630
- const fallbackBody = extractTextContent(message.content);
631
- const data: SentBoxData = {
632
- to: details.to ?? "(unknown sessionId)",
633
- from: details.from,
634
- cwd: details.cwd,
635
- timestamp: details.timestamp,
636
- mode: details.mode,
637
- wants_reply: details.wants_reply,
638
- deliveredAs: details.deliveredAs,
639
- body: details.body ?? fallbackBody,
640
- };
641
- return buildSentMessageBox(data, expanded, theme);
642
- };
643
-
644
519
  // ============================================================================
645
520
  // Command Handlers
646
521
  // ============================================================================
@@ -824,9 +699,17 @@ async function handleCommand(
824
699
  return;
825
700
  }
826
701
 
702
+ // #120 P2: the receiver reports the BOUNDARY it actually observed — `sent` for an idle
703
+ // direct trigger, `queued-steer` / `queued-follow-up` for the two in-process queues — and
704
+ // nothing else. The bare `delivered:true` that used to lead this payload is GONE rather
705
+ // than kept for compatibility: it was equally true of all three states and therefore said
706
+ // nothing, which is the bare `delivered` DELIVERY.md refuses by name. The `deliveredAs`
707
+ // beside it had no consumer at all. Skew is safe in both directions without a translator —
708
+ // an old sender keys on `response.success` and never reads `data`, and a new sender reads
709
+ // an old receiver's payload as `accepted-unknown-boundary`, the honest name for an
710
+ // acceptance it cannot classify.
827
711
  respond(true, "send", {
828
- delivered: true,
829
- deliveredAs: isIdle ? "direct" : mode === "follow_up" ? "followUp" : "steer",
712
+ boundary: sendReceiptBoundary({ idle: isIdle, mode }),
830
713
  wants_reply: wantsReply,
831
714
  });
832
715
  return;
@@ -1104,17 +987,6 @@ export default function (pi: ExtensionAPI) {
1104
987
  };
1105
988
 
1106
989
  pi.registerMessageRenderer(SESSION_MESSAGE_TYPE, renderSessionMessage);
1107
- // Layer B (ACP path) sender-side UI box. Registered unconditionally because
1108
- // renderer registration happens at extension load, BEFORE we know whether
1109
- // this session ends up with a routable identity: `updateSessionEnv` only
1110
- // publishes PI_SESSION_ID once a garden id exists, and clears it otherwise.
1111
- // A session without `--entwurf-control` therefore does NOT send — a bundled
1112
- // `entwurf_v2` sender call there fails loud on the missing envelope (the
1113
- // documented provider-vs-citizen boundary), and this renderer simply never
1114
- // fires. Registering it up front keeps one registration path instead of a
1115
- // conditional seam, and guarantees the [entwurf sent →] box exists for every
1116
- // session that DOES send.
1117
- pi.registerMessageRenderer(ENTWURF_SENT_MESSAGE_TYPE, renderSentMessage);
1118
990
 
1119
991
  if (shouldRegisterControlTools(pi)) {
1120
992
  registerListSessionsTool(pi);
@@ -1387,7 +1259,7 @@ function registerEntwurfV2Tool(pi: ExtensionAPI): void {
1387
1259
  mode: Type.Optional(
1388
1260
  StringEnum(["steer", "follow_up"] as const, {
1389
1261
  description:
1390
- "Injection style for a CONTROL-SOCKET send only: steer (immediate) or follow_up (after task). " +
1262
+ "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. " +
1391
1263
  "The mailbox and native-push plans carry no mode, so it has no effect on those rails.",
1392
1264
  }),
1393
1265
  ),
@@ -1415,9 +1287,11 @@ function registerEntwurfV2Tool(pi: ExtensionAPI): void {
1415
1287
  description: `CANONICAL DELIVERY SURFACE for garden ids: message, reply, or hand off to whoever an id names. The id alone
1416
1288
  does not say which rail that citizen answers on. Give target + intent; the decider picks transport from
1417
1289
  liveness (live socket citizen → control-socket send; deliverable self-fetch citizen → meta-bridge mailbox;
1418
- probe-alive native-push citizen → direct injection into its conversation) and reports ONE outcome
1419
- (delivered / rejected / delivered-but-lock-dirty). EXISTING targets only; discover with entwurf_peers.
1420
- A peer entwurf_peers shows as liveness=alive → fire-and-forget. A citizen with NO socket liveness
1290
+ probe-alive native-push citizen → direct injection into its conversation) and reports ONE outcome, per rail: a control
1291
+ receiver's acceptance boundary ("sent" / "queued-steer" / "queued-follow-up" /
1292
+ "accepted-unknown-boundary" — those four only), a mailbox ENQUEUE receipt, a native-push INJECTION,
1293
+ a rejection, or lock-dirty. EXISTING targets only (see entwurf_peers).
1294
+ A peer shown as liveness=alive → fire-and-forget. A citizen with NO socket liveness
1421
1295
  (liveness=unsupported) is ALSO fire-and-forget — unsupported means only "no control-socket probe" — and the
1422
1296
  decider picks its own rail: a self-fetch backend (e.g. Claude Code) gets the mailbox, a native-push backend
1423
1297
  (e.g. Antigravity) gets direct injection and has NO mailbox at all. THERE IS A THIRD RESULT: the mailbox
@@ -1426,9 +1300,8 @@ rather than queued for an inbox nobody drains. The native-push probe is 3-valued
1426
1300
  alive → injected; dead → native-push-target-dead; indeterminate → native-push-probe-indeterminate
1427
1301
  (unestablished ≠ gone). DORMANT IS UNREACHABLE: a socket-domain citizen that is not running gets
1428
1302
  dormant-fire-forget-unsupported — same receiver rule as the mailbox, no active drainer means no
1429
- delivery. The intent that used to answer there, owned-outcome, resumed it by
1430
- launching a hidden background child; it was withdrawn under the visible-first rule, so re-open the session
1431
- yourself and dispatch again. LOCK: taken for a control-socket-DOMAIN dispatch. The mailbox and native-push
1303
+ delivery. The intent that used to answer there, owned-outcome, was withdrawn
1304
+ under the visible-first rule — re-open the session yourself and dispatch again. LOCK: taken for a control-socket-DOMAIN dispatch. The mailbox and native-push
1432
1305
  rails are lock-free — deliverability and the adapter probe guard them. mode applies to a CONTROL-SOCKET
1433
1306
  send only; other plans carry no mode. wants_reply rides every rail. message caps at 16000 chars; send an
1434
1307
  artifact path + digest for more.`,
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * compaction-send-guard — whether a control-socket `send` may call `pi.sendMessage`.
3
3
  *
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
4
+ * Pi has no public `ExtensionContext.isCompacting()`. `[측정 2026-09-20, pi 0.86.0]` and
5
+ * re-measured `[측정 2026-09-22, pi 0.87.0]` and `[측정 2026-09-23, pi 0.87.1]`: the installed
6
+ * `dist/core/extensions/types.d.ts` declares `isIdle(): boolean` at :233 and `signal` at :237 —
7
+ * the SAME coordinates at all three floors — and carries no `isCompacting` member at all (grep
8
+ * count 0, re-counted on 0.87.1); the identifier exists
7
9
  * only inside the interactive/rpc modes' own bundles). Compaction is non-idle
8
10
  * (`AgentSession.isIdle` = no agent run AND not compacting) but `sendCustomMessage`
9
11
  * does not refuse it. A non-streaming `triggerTurn:true` send starts `_runAgentPrompt`
@@ -0,0 +1,108 @@
1
+ /**
2
+ * control-send-receipt — the ONE place the control-socket's acceptance boundary is named,
3
+ * on both ends of the wire (#120 P2).
4
+ *
5
+ * WHY A BOUNDARY AXIS AT ALL, AND WHY IT IS NOT THE ROUTE AXIS.
6
+ * `SendFinalOutcome` (`entwurf-v2-send.ts`) answers WHICH LEG delivered — first socket,
7
+ * re-resolved socket, mailbox — and it is what the release reducer keys on. It is an INTERNAL
8
+ * route/release algebra and it is NOT the sender-visible delivery boundary: `sent` and
9
+ * `fallback-sent` say a receiver accepted the bytes, and nothing whatever about what the receiver
10
+ * then did with them. Answering "what happened to my message" with a route word is how
11
+ * `control-socket → sent` came to mean four different things, one of which was
12
+ * "queued behind ten steers and dropped when the operator hit ESC".
13
+ *
14
+ * `[측정 2026-09-22, pi-agent-core 0.87.0; 재측정 2026-09-23, 0.87.1 — 좌표 동일]` the receiver's two queues are independent and each is
15
+ * `one-at-a-time` (`dist/agent.js:60-71,96-97,137-138`); steering is drained after every turn
16
+ * (`dist/agent-loop.js:85,186`) while follow-ups are drained ONLY when the inner loop has ended
17
+ * (`:191-197`). There is no global FIFO across them. So `steer` and `follow_up` are not two
18
+ * spellings of one queue — they are two different promises about when, and a receipt that cannot
19
+ * tell them apart cannot be used to reason about either.
20
+ *
21
+ * WHAT EACH BOUNDARY CLAIMS, EXACTLY:
22
+ * sent the receiver was idle and the message was accepted as a DIRECT
23
+ * turn trigger. It is an accepted REQUEST, not proof that the model
24
+ * saw it and not proof the turn completed.
25
+ * queued-steer accepted into the receiver's in-process steering queue. Volatile
26
+ * process memory: no durability, no ordering against the other
27
+ * queue, and an abort clears it.
28
+ * queued-follow-up accepted into the receiver's in-process follow-up queue. Same
29
+ * volatility, and it waits for the inner loop to end — a steer that
30
+ * arrives later still goes first.
31
+ * accepted-unknown-boundary the receiver ACCEPTED (its RPC answered success) but did not name
32
+ * a boundary this build understands. Version skew, not failure: the
33
+ * message is gone from our hands either way, so this must never be
34
+ * rendered as a failure to retry — and never as `sent`, which would
35
+ * put back the exact false claim this axis exists to remove.
36
+ *
37
+ * NO LEGACY MAPPER LIVES HERE. The previous wire carried `delivered:true` plus a `deliveredAs`
38
+ * producer nothing consumed; both are gone rather than translated. A new receiver answers an old
39
+ * sender fine (it keys on `response.success` and ignores `data`), and an old receiver answers a
40
+ * new sender as `accepted-unknown-boundary` — which is the honest name for it. A dual route would
41
+ * be a second address for one fact.
42
+ */
43
+
44
+ /** The closed boundary set, in the order a reader should learn them. */
45
+ export const ACCEPTANCE_BOUNDARIES = ["sent", "queued-steer", "queued-follow-up", "accepted-unknown-boundary"] as const;
46
+
47
+ export type AcceptanceBoundary = (typeof ACCEPTANCE_BOUNDARIES)[number];
48
+
49
+ /** What a receiver can actually observe about itself. `accepted-unknown-boundary` is a READER's
50
+ * verdict about an answer it could not classify, so a receiver may never produce it. */
51
+ export type ObservedBoundary = Exclude<AcceptanceBoundary, "accepted-unknown-boundary">;
52
+
53
+ /**
54
+ * RECEIVER SIDE. The whole classification, from the two facts the receiver already has at the
55
+ * moment it accepts: was it idle, and which queue did the caller ask for. Pure — it reads no
56
+ * clock, no socket and no session, so the same inputs always name the same boundary.
57
+ *
58
+ * `idle` wins over `mode`: an idle receiver takes the message as a direct trigger and neither
59
+ * queue is involved, which is why `mode` is meaningless there (`entwurf-control.ts` passes no
60
+ * `deliverAs` on that branch).
61
+ */
62
+ export function sendReceiptBoundary(input: { idle: boolean; mode: "steer" | "follow_up" }): ObservedBoundary {
63
+ if (input.idle) return "sent";
64
+ return input.mode === "follow_up" ? "queued-follow-up" : "queued-steer";
65
+ }
66
+
67
+ /** Is this an acceptance a receiver could have observed about itself? */
68
+ export function isObservedBoundary(value: unknown): value is ObservedBoundary {
69
+ return value === "sent" || value === "queued-steer" || value === "queued-follow-up";
70
+ }
71
+
72
+ /**
73
+ * SENDER SIDE. Read the receiver's typed boundary off an RPC `data` payload.
74
+ *
75
+ * Anything this build does not recognise — a missing field, an older receiver's shape, a value
76
+ * outside the set — is `accepted-unknown-boundary` and NOT an error: by the time `data` exists the
77
+ * RPC has already answered success, so the message has been accepted. Inventing a failure here
78
+ * would invite a retry that double-delivers, and answering `sent` would claim a direct injection
79
+ * nobody reported.
80
+ */
81
+ export function parseAcceptanceBoundary(data: unknown): AcceptanceBoundary {
82
+ if (typeof data !== "object" || data === null) return "accepted-unknown-boundary";
83
+ const value = (data as { boundary?: unknown }).boundary;
84
+ return isObservedBoundary(value) ? value : "accepted-unknown-boundary";
85
+ }
86
+
87
+ /** How much of an unrecognised answer may be repeated back. A receiver's payload is untrusted
88
+ * input from another process; a full object on a status line is an injection surface and an
89
+ * unbounded one at that. */
90
+ const UNKNOWN_DIAGNOSTIC_MAX = 80;
91
+
92
+ /**
93
+ * A BOUNDED, single-line description of an answer we could not classify — for the operator who has
94
+ * to decide whether this is version skew or a broken peer. Returns `undefined` when there is
95
+ * nothing worth saying, so a caller renders the bare boundary rather than an empty parenthesis.
96
+ *
97
+ * It describes the SHAPE and never trusts the content: the raw value is stringified, stripped of
98
+ * newlines and truncated. Never the whole object.
99
+ */
100
+ export function describeUnknownBoundary(data: unknown): string | undefined {
101
+ if (typeof data !== "object" || data === null) return data === undefined ? undefined : `data: ${typeof data}`;
102
+ const value = (data as { boundary?: unknown }).boundary;
103
+ if (value === undefined) return "no boundary field";
104
+ const raw = typeof value === "string" ? value : typeof value;
105
+ const flat = raw.replace(/\s+/g, " ").trim();
106
+ if (flat === "") return "empty boundary";
107
+ return flat.length > UNKNOWN_DIAGNOSTIC_MAX ? `${flat.slice(0, UNKNOWN_DIAGNOSTIC_MAX)}…` : flat;
108
+ }
@@ -51,7 +51,7 @@ export const EntwurfV2InputSchema = Type.Object(
51
51
  mode: Type.Optional(
52
52
  StringEnum(ENTWURF_V2_MODES, {
53
53
  description:
54
- "delivery mode (steer = interrupt current turn, follow_up = queue) — NOT the ownership axis (F1) nor liveness routing. MEANINGLESS on the meta-mailbox transport (F-mailbox): a mailbox ack is enqueue+doorbell, not a turn injection, so steer/follow_up does not apply when the verdict transport is meta-mailbox.",
54
+ "delivery mode — NOT the ownership axis (F1) nor liveness routing. 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. MEANINGLESS on the meta-mailbox transport (F-mailbox): a mailbox ack is enqueue+doorbell, not a turn injection, so steer/follow_up does not apply when the verdict transport is meta-mailbox.",
55
55
  }),
56
56
  ),
57
57
  wantsReply: Type.Optional(
@@ -265,8 +265,12 @@ export type EntwurfV2Transport = (typeof ENTWURF_V2_TRANSPORTS)[number];
265
265
  // and a future visible-resume verb would speak them again.
266
266
  export const ENTWURF_V2_ACTIONS = ["send"] as const;
267
267
  export const ENTWURF_V2_OWNERSHIPS = ["ack-only"] as const;
268
- // Delivery mode of the message to the target (how it is injected) — steer =
269
- // interrupt the current turn, follow_up = queue after it. A SEPARATE axis from
268
+ // Delivery mode of the message to the target: WHICH QUEUE a busy receiver is asked for.
269
+ // `[측정 2026-09-22, pi-agent-core 0.87.0; 재측정 2026-09-23, 0.87.1 — 좌표 동일]` neither is an interrupt — `dist/agent-loop.js:186`
270
+ // drains steering after each turn and `:191-197` drains follow-ups only once the inner loop has
271
+ // ended, and the two queues (`dist/agent.js:96-97,137-138`) have no FIFO between them, so a later
272
+ // steer overtakes every earlier follow_up. Both are volatile process memory and an abort drops
273
+ // them. A SEPARATE axis from
270
274
  // both the intent/ownership axis (F1) and the liveness-routing axis. The removed
271
275
  // v1 `entwurf_send` carried the same steer|follow_up surface, so this axis is
272
276
  // inherited vocabulary, not a second live delivery verb.