@junghanacs/entwurf 0.23.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/BASELINE.md +1 -0
  2. package/CHANGELOG.md +628 -0
  3. package/DELIVERY.md +24 -1
  4. package/FAQ.md +47 -0
  5. package/README.md +5 -3
  6. package/VERIFY.md +20 -2
  7. package/demo/README.md +5 -2
  8. package/docs/acp-backend-rail.md +49 -2
  9. package/docs/setup-clean-host.md +8 -7
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +38 -5
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +19 -5
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/callback-env.js +68 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-send-receipt.js +101 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +17 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +4 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +53 -6
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +38 -3
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +54 -22
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +20 -6
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +24 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +19 -10
  23. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +35 -42
  24. package/mcp/entwurf-bridge/dist/protocol.js +0 -7
  25. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +40 -3
  26. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +72 -0
  27. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  28. package/mcp/entwurf-bridge/src/index.ts +46 -5
  29. package/mcp/entwurf-bridge/test.sh +2 -2
  30. package/package.json +11 -9
  31. package/pi-extensions/entwurf-control.ts +83 -154
  32. package/pi-extensions/lib/acp/backend.ts +16 -6
  33. package/pi-extensions/lib/acp/context.ts +30 -10
  34. package/pi-extensions/lib/acp/session-store.ts +2 -2
  35. package/pi-extensions/lib/callback-env.ts +78 -0
  36. package/pi-extensions/lib/compaction-send-guard.ts +6 -1
  37. package/pi-extensions/lib/control-send-receipt.ts +108 -0
  38. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  39. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -2
  40. package/pi-extensions/lib/entwurf-v2-production.ts +19 -1
  41. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -1
  42. package/pi-extensions/lib/entwurf-v2-send.ts +82 -6
  43. package/pi-extensions/lib/entwurf-v2-surface.ts +42 -3
  44. package/pi-extensions/lib/fresh-call-composition.ts +56 -22
  45. package/pi-extensions/lib/herdr-fresh-call.ts +25 -7
  46. package/pi-extensions/lib/herdr-placement.ts +24 -4
  47. package/pi-extensions/lib/mux-fresh-call.ts +23 -8
  48. package/pi-extensions/meta-bridge-omp.ts +35 -48
  49. package/protocol.js +0 -8
  50. package/run.sh +306 -53
  51. package/scripts/check-acp-backend-preflight.ts +117 -9
  52. package/scripts/check-acp-carrier-augment.ts +6 -4
  53. package/scripts/check-acp-event-mapper.ts +17 -6
  54. package/scripts/check-acp-prompt-builder.ts +17 -9
  55. package/scripts/check-acp-prompt-lifecycle.ts +23 -6
  56. package/scripts/check-acp-session-reuse.ts +64 -51
  57. package/scripts/check-acp-session-store.ts +31 -8
  58. package/scripts/check-acp-stop-reason.ts +20 -2
  59. package/scripts/check-acp-stream-hooks.ts +22 -5
  60. package/scripts/check-acp-usage-accounting.ts +33 -9
  61. package/scripts/check-entwurf-bridge-boot.ts +16 -1
  62. package/scripts/check-entwurf-v2-surface.ts +289 -10
  63. package/scripts/check-fresh-call-dispatch.ts +4 -5
  64. package/scripts/check-gate-qualification.ts +51 -9
  65. package/scripts/check-herdr-activation.ts +73 -0
  66. package/scripts/check-herdr-fresh-call.ts +30 -6
  67. package/scripts/check-herdr-placement.ts +51 -0
  68. package/scripts/check-herdr-plugin-build.ts +11 -4
  69. package/scripts/check-herdr-plugin.ts +745 -38
  70. package/scripts/check-herdr-sandbox.ts +14 -2
  71. package/scripts/check-install-container.sh +1 -1
  72. package/scripts/check-mux-launcher-fence.ts +37 -0
  73. package/scripts/check-pi-launch.ts +8 -2
  74. package/scripts/check-release-gate-outcomes.ts +165 -0
  75. package/scripts/doctor-pi-provider.ts +44 -4
  76. package/scripts/herdr-rails.mjs +75 -0
  77. package/scripts/lib/mutation-qualify.ts +1 -1
  78. package/scripts/meta-bridge-state.py +18 -1
  79. package/scripts/mutants/acp-backend-preflight.json +20 -0
  80. package/scripts/mutants/bridge-boot-resume.json +26 -0
  81. package/scripts/mutants/bridge-command-boot.json +38 -0
  82. package/scripts/mutants/control-send-receipt.json +235 -0
  83. package/scripts/mutants/herdr-activation.json +16 -0
  84. package/scripts/mutants/herdr-fresh-call.json +15 -4
  85. package/scripts/mutants/herdr-plugin-build.json +7 -11
  86. package/scripts/mutants/herdr-plugin.json +177 -9
  87. package/scripts/mutants/meta-retire.json +22 -0
  88. package/scripts/mutants/mux-fresh-call.json +21 -8
  89. package/scripts/mutants/omp-fresh.json +5 -9
  90. package/scripts/mutants/pack-install.json +4 -4
  91. package/scripts/mutants/pi-floor.json +17 -0
  92. package/scripts/mutants/release-gate.json +48 -0
  93. package/scripts/mutants/retired-sent-surface.json +32 -0
  94. package/scripts/mutants/v2-surface.json +7 -7
  95. package/scripts/probe-bridge-command.ts +1 -0
  96. package/scripts/smoke-acp-session-reuse-live.ts +23 -5
  97. package/scripts/smoke-agy-install-state.sh +2 -2
  98. package/scripts/smoke-codex-fresh-live.ts +94 -13
  99. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  100. package/scripts/smoke-herdr-fresh-call-live.ts +71 -36
  101. package/scripts/smoke-herdr-raw-install-live.sh +90 -5
  102. package/scripts/smoke-meta-install-state.sh +107 -1
  103. package/scripts/smoke-mux-lifecycle-live.ts +9 -2
  104. package/scripts/smoke-omp-fresh-live.ts +4 -2
  105. package/scripts/smoke-omp-mcp-state.sh +1 -1
  106. package/scripts/smoke-pi-provider-state.sh +108 -2
  107. package/scripts/smoke-setup-verdict.sh +18 -2
@@ -0,0 +1,101 @@
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
+ /** The closed boundary set, in the order a reader should learn them. */
44
+ export const ACCEPTANCE_BOUNDARIES = ["sent", "queued-steer", "queued-follow-up", "accepted-unknown-boundary"];
45
+ /**
46
+ * RECEIVER SIDE. The whole classification, from the two facts the receiver already has at the
47
+ * moment it accepts: was it idle, and which queue did the caller ask for. Pure — it reads no
48
+ * clock, no socket and no session, so the same inputs always name the same boundary.
49
+ *
50
+ * `idle` wins over `mode`: an idle receiver takes the message as a direct trigger and neither
51
+ * queue is involved, which is why `mode` is meaningless there (`entwurf-control.ts` passes no
52
+ * `deliverAs` on that branch).
53
+ */
54
+ export function sendReceiptBoundary(input) {
55
+ if (input.idle)
56
+ return "sent";
57
+ return input.mode === "follow_up" ? "queued-follow-up" : "queued-steer";
58
+ }
59
+ /** Is this an acceptance a receiver could have observed about itself? */
60
+ export function isObservedBoundary(value) {
61
+ return value === "sent" || value === "queued-steer" || value === "queued-follow-up";
62
+ }
63
+ /**
64
+ * SENDER SIDE. Read the receiver's typed boundary off an RPC `data` payload.
65
+ *
66
+ * Anything this build does not recognise — a missing field, an older receiver's shape, a value
67
+ * outside the set — is `accepted-unknown-boundary` and NOT an error: by the time `data` exists the
68
+ * RPC has already answered success, so the message has been accepted. Inventing a failure here
69
+ * would invite a retry that double-delivers, and answering `sent` would claim a direct injection
70
+ * nobody reported.
71
+ */
72
+ export function parseAcceptanceBoundary(data) {
73
+ if (typeof data !== "object" || data === null)
74
+ return "accepted-unknown-boundary";
75
+ const value = data.boundary;
76
+ return isObservedBoundary(value) ? value : "accepted-unknown-boundary";
77
+ }
78
+ /** How much of an unrecognised answer may be repeated back. A receiver's payload is untrusted
79
+ * input from another process; a full object on a status line is an injection surface and an
80
+ * unbounded one at that. */
81
+ const UNKNOWN_DIAGNOSTIC_MAX = 80;
82
+ /**
83
+ * A BOUNDED, single-line description of an answer we could not classify — for the operator who has
84
+ * to decide whether this is version skew or a broken peer. Returns `undefined` when there is
85
+ * nothing worth saying, so a caller renders the bare boundary rather than an empty parenthesis.
86
+ *
87
+ * It describes the SHAPE and never trusts the content: the raw value is stringified, stripped of
88
+ * newlines and truncated. Never the whole object.
89
+ */
90
+ export function describeUnknownBoundary(data) {
91
+ if (typeof data !== "object" || data === null)
92
+ return data === undefined ? undefined : `data: ${typeof data}`;
93
+ const value = data.boundary;
94
+ if (value === undefined)
95
+ return "no boundary field";
96
+ const raw = typeof value === "string" ? value : typeof value;
97
+ const flat = raw.replace(/\s+/g, " ").trim();
98
+ if (flat === "")
99
+ return "empty boundary";
100
+ return flat.length > UNKNOWN_DIAGNOSTIC_MAX ? `${flat.slice(0, UNKNOWN_DIAGNOSTIC_MAX)}…` : flat;
101
+ }
@@ -228,8 +228,12 @@ export const ENTWURF_V2_TRANSPORTS = ["control-socket", "meta-mailbox", "native-
228
228
  // and a future visible-resume verb would speak them again.
229
229
  export const ENTWURF_V2_ACTIONS = ["send"];
230
230
  export const ENTWURF_V2_OWNERSHIPS = ["ack-only"];
231
- // Delivery mode of the message to the target (how it is injected) — steer =
232
- // interrupt the current turn, follow_up = queue after it. A SEPARATE axis from
231
+ // Delivery mode of the message to the target: WHICH QUEUE a busy receiver is asked for.
232
+ // `[측정 2026-09-22, pi-agent-core 0.87.0; 재측정 2026-09-23, 0.87.1 — 좌표 동일]` neither is an interrupt — `dist/agent-loop.js:186`
233
+ // drains steering after each turn and `:191-197` drains follow-ups only once the inner loop has
234
+ // ended, and the two queues (`dist/agent.js:96-97,137-138`) have no FIFO between them, so a later
235
+ // steer overtakes every earlier follow_up. Both are volatile process memory and an abort drops
236
+ // them. A SEPARATE axis from
233
237
  // both the intent/ownership axis (F1) and the liveness-routing axis. The removed
234
238
  // v1 `entwurf_send` carried the same steer|follow_up surface, so this axis is
235
239
  // inherited vocabulary, not a second live delivery verb.
@@ -28,6 +28,7 @@
28
28
  * canonical socket path; `indeterminate` (EACCES/unknown) fails loud rather than silently
29
29
  * claiming "no conflict". The decider's later `inspectSocket` probe is a SEPARATE step.
30
30
  */
31
+ import { describeUnknownBoundary, parseAcceptanceBoundary } from "./control-send-receipt.js";
31
32
  import { sendRpcCommand as realSendRpc, } from "./entwurf-control-rpc.js";
32
33
  import { mailboxConversationalDeliverable, resolveMailboxReceiverFacts, } from "./entwurf-deliverability.js";
33
34
  import { isOutOfSocketDomainGardenIdConflict } from "./entwurf-facts.js";
@@ -190,7 +191,22 @@ export function makeProductionEntwurfV2Deps(opts) {
190
191
  wants_reply: plan.wantsReply,
191
192
  sender: opts.senderProvider(),
192
193
  });
193
- return { success: response.success, error: response.error };
194
+ // #120 P2 THE WIRE SEAM. `response.data` is another process's payload, so it is
195
+ // classified here by one total function and never carried raw any further: a shape we
196
+ // do not recognise becomes `accepted-unknown-boundary`, an ACCEPTANCE we could not
197
+ // name rather than a failure — the RPC already answered success, so the message is out
198
+ // of our hands and inventing a failure here would invite a double delivery. Only a
199
+ // SUCCESSFUL answer gets a boundary; a refusal carries its error instead.
200
+ const boundary = response.success ? parseAcceptanceBoundary(response.data) : undefined;
201
+ if (boundary === "accepted-unknown-boundary") {
202
+ // The operator has to be able to tell version skew from a broken peer, and the
203
+ // receipt itself may not carry another process's payload onto a status line.
204
+ // Hard Rule 15: diagnostics go to stderr, bounded and single-line.
205
+ const shape = describeUnknownBoundary(response.data);
206
+ process.stderr.write(`[entwurf_v2] ${plan.targetGardenId} accepted the message but named no boundary this build knows` +
207
+ `${shape === undefined ? "" : ` (${shape})`}\n`);
208
+ }
209
+ return { success: response.success, error: response.error, boundary };
194
210
  },
195
211
  classifyConnect: io.classifyConnect,
196
212
  releaseLock: release,
@@ -55,6 +55,7 @@ export async function executeDispatch(decision, deps) {
55
55
  outcome: r.outcome,
56
56
  rejectReason: r.rejectReason,
57
57
  messagePath: r.messagePath,
58
+ boundary: r.boundary,
58
59
  },
59
60
  };
60
61
  }
@@ -67,6 +68,9 @@ export async function executeDispatch(decision, deps) {
67
68
  transport,
68
69
  error: errorMessage(err),
69
70
  finalizedOutcome: err.finalizedOutcome,
71
+ finalizedBoundary: err.finalizedBoundary,
72
+ finalizedMessagePath: err.finalizedMessagePath,
73
+ finalizedRejectReason: err.finalizedRejectReason,
70
74
  releaseFailed: true,
71
75
  retrySafe: false,
72
76
  };
@@ -32,6 +32,19 @@
32
32
  * `releaseLock` throw never MASKS a send failure — the original error wins (5b).
33
33
  */
34
34
  import { decideReleasePolicy, initialReleaseState, reduceRelease, } from "./entwurf-v2-release.js";
35
+ /**
36
+ * A successful SOCKET answer always carries a boundary: the wire seam classifies every answer with
37
+ * one total function, and an answer it cannot place is `accepted-unknown-boundary` rather than
38
+ * nothing. So `undefined` here is not a host state, it is a bypassed seam — a dep wired straight
39
+ * past the classifier. Fail loud. A silent default would make the one shape that must never be
40
+ * mistaken for a direct trigger — delivered, boundary unknown to us — look exactly like one.
41
+ */
42
+ function requireBoundary(result) {
43
+ if (result.boundary === undefined) {
44
+ throw new Error("entwurf-v2-send: a successful control-socket RPC reported no acceptance boundary — the wire seam that classifies one was bypassed (contract violation).");
45
+ }
46
+ return result.boundary;
47
+ }
35
48
  /**
36
49
  * Execute a control-socket send to a final outcome and release the held lock exactly
37
50
  * once. `lock` MUST be the in-domain claim the decider handed over —
@@ -58,7 +71,12 @@ export async function executeControlSocketSend(plan, lock, deps) {
58
71
  drive = { outcome: "failed", error: err };
59
72
  }
60
73
  finalizeRelease(policy, deps, held, drive);
61
- return { outcome: drive.outcome, rejectReason: drive.rejectReason, messagePath: drive.messagePath };
74
+ return {
75
+ outcome: drive.outcome,
76
+ rejectReason: drive.rejectReason,
77
+ messagePath: drive.messagePath,
78
+ boundary: drive.boundary,
79
+ };
62
80
  }
63
81
  /** Drive the 1차 send and route a connect failure through the F3 split. */
64
82
  async function driveSend(plan, lock, deps) {
@@ -78,7 +96,7 @@ async function driveSend(plan, lock, deps) {
78
96
  }
79
97
  // A completed RPC: ack ⇒ sent; in-band refusal ⇒ rejected, NO fallback. Preserve
80
98
  // its receiver error verbatim when supplied; do not make a reason up when absent.
81
- return result.success ? { outcome: "sent" } : inBandRejected(result);
99
+ return result.success ? { outcome: "sent", boundary: requireBoundary(result) } : inBandRejected(result);
82
100
  }
83
101
  /**
84
102
  * The dead-path fallback: re-resolve ONCE under the held lock, then run the resolver's
@@ -116,7 +134,7 @@ async function driveDeadFallback(plan, lock, deps) {
116
134
  // connect failure finalizes as failed (no further fallback).
117
135
  try {
118
136
  const r = await deps.sendOverSocket(rePlan);
119
- return r.success ? { outcome: "fallback-sent" } : inBandRejected(r);
137
+ return r.success ? { outcome: "fallback-sent", boundary: requireBoundary(r) } : inBandRejected(r);
120
138
  }
121
139
  catch (err) {
122
140
  return { outcome: "failed", error: err };
@@ -152,12 +170,35 @@ async function driveDeadFallback(plan, lock, deps) {
152
170
  */
153
171
  export class SendDeliveredReleaseFailedError extends Error {
154
172
  finalizedOutcome;
173
+ /** #120 P2: the ACCEPTANCE the send actually reached, carried through the failure so the
174
+ * dirty-lock line can say what happened instead of a bare route word. Absent on a `rejected`
175
+ * finalization (nothing was accepted) and on the mailbox fallback leg (which carries a file
176
+ * instead) — those two are exactly why this is optional rather than defaulted. */
177
+ finalizedBoundary;
178
+ /** #98 R receipt, same reason: a mailbox fallback that then failed to release still wrote a
179
+ * `.msg`, and the operator has to be told WHICH one before touching the lock. */
180
+ finalizedMessagePath;
181
+ /** The receiver's own refusal text on a `rejected` finalization. Without it the dirty-lock line
182
+ * is the ONE place a refusal loses its reason — and it is the place an operator reads while
183
+ * deciding by hand whether the message still needs sending. */
184
+ finalizedRejectReason;
155
185
  releaseError;
156
- constructor(finalizedOutcome, releaseError) {
186
+ constructor(finalizedOutcome, releaseError, accepted = {}) {
157
187
  const detail = releaseError instanceof Error ? releaseError.message : String(releaseError);
158
- super(`entwurf-v2-send: ${finalizedOutcome} delivered but releaseLock failed (lock dirty, do NOT re-send): ${detail}`);
188
+ // NOT "delivered". The word was wrong on two of the three shapes that reach here — a
189
+ // `rejected` finalization delivered nothing at all, and a `queued-*` one is sitting in
190
+ // volatile memory — and DELIVERY.md refuses a bare `delivered` for exactly that reason.
191
+ // What is true of all three is that the send REACHED A TERMINAL RESULT and must not be
192
+ // repeated.
193
+ const what = finalizedOutcome === "rejected"
194
+ ? `rejected${accepted.rejectReason ? ` (${accepted.rejectReason})` : ""}`
195
+ : (accepted.boundary ?? "mailbox-enqueued");
196
+ super(`entwurf-v2-send: the send finalized as \`${what}\` and releaseLock then failed (lock dirty, do NOT re-send): ${detail}`);
159
197
  this.name = "SendDeliveredReleaseFailedError";
160
198
  this.finalizedOutcome = finalizedOutcome;
199
+ this.finalizedBoundary = accepted.boundary;
200
+ this.finalizedMessagePath = accepted.messagePath;
201
+ this.finalizedRejectReason = accepted.rejectReason;
161
202
  this.releaseError = releaseError;
162
203
  }
163
204
  }
@@ -183,7 +224,13 @@ function finalizeRelease(policy, deps, lock, drive) {
183
224
  throw original;
184
225
  // N1: the delivery/refusal already happened — surface it as a structured,
185
226
  // retry-unsafe error rather than a bare release throw.
186
- throw new SendDeliveredReleaseFailedError(drive.outcome, releaseErr);
227
+ // The acceptance truth rides along: without it the dirty-lock line is the ONE place
228
+ // left that still answers "what happened to my message" with a route word.
229
+ throw new SendDeliveredReleaseFailedError(drive.outcome, releaseErr, {
230
+ boundary: drive.boundary,
231
+ messagePath: drive.messagePath,
232
+ rejectReason: drive.rejectReason,
233
+ });
187
234
  }
188
235
  }
189
236
  if (drive.outcome === "failed")
@@ -124,9 +124,34 @@ export function renderEntwurfV2Result(result) {
124
124
  // socket-to-socket retry (no file) and on every non-mailbox outcome, so the
125
125
  // line degrades to the bare outcome rather than printing "undefined".
126
126
  const enqueued = o.messagePath ? ` (enqueued ${basename(o.messagePath)})` : "";
127
+ if (delivered) {
128
+ // #120 P2 — THE BOUNDARY IS THE RECEIPT, and the route is an annotation.
129
+ // `sent` / `fallback-sent` name which LEG carried the bytes. An operator
130
+ // cannot act on that: for 78 minutes this line said `sent` about a message
131
+ // lying in a queue. So the visible word is what the RECEIVER observed —
132
+ // `sent` only for an idle direct trigger, `queued-steer` /
133
+ // `queued-follow-up` for the two volatile in-process queues, and
134
+ // `accepted-unknown-boundary` when a peer answered success in a shape this
135
+ // build cannot classify. That last one is never spelled with the word
136
+ // "sent": it was accepted, and saying "sent (unknown)" would put back the
137
+ // exact false claim this axis exists to remove.
138
+ //
139
+ // A re-resolved MAILBOX leg has no turn-injection boundary at all — it
140
+ // wrote a durable file — so it says `mailbox-enqueued` rather than a bare
141
+ // route word. The route survives as a separate annotation either way,
142
+ // because "your first socket was dead" is a real thing to have been told.
143
+ const route = o.outcome === "fallback-sent" ? " (via a re-resolved route)" : "";
144
+ // A re-resolved leg with no boundary IS the mailbox leg — that rail reports a
145
+ // durable file instead. A PRIMARY leg with no boundary is structurally
146
+ // impossible (the send hand throws on it), so if one ever arrives here it is
147
+ // named as the acceptance we could not classify rather than dressed as a
148
+ // mailbox file that was never written.
149
+ const boundary = o.boundary ?? (o.outcome === "fallback-sent" ? "mailbox-enqueued" : "accepted-unknown-boundary");
150
+ return { text: `entwurf_v2 control-socket → ${boundary}${route}${enqueued}`, isError: false };
151
+ }
127
152
  return {
128
153
  text: `entwurf_v2 control-socket → ${o.outcome}${reason}${enqueued}`,
129
- isError: !delivered,
154
+ isError: true,
130
155
  };
131
156
  }
132
157
  if (o.transport === "native-push") {
@@ -150,9 +175,19 @@ export function renderEntwurfV2Result(result) {
150
175
  }
151
176
  case "execution-failed": {
152
177
  if (result.releaseFailed && result.finalizedOutcome) {
153
- // N1: the delivery/refusal reached a terminal outcome but releaseLock then threw.
178
+ // N1: the send reached a terminal result and `releaseLock` then threw.
179
+ //
180
+ // #120 P2 — THIS LINE USED TO SAY `DELIVERED (<route>)` ABOUT ALL THREE SHAPES THAT
181
+ // REACH IT, and it was wrong about two of them: a `rejected` finalization delivered
182
+ // nothing at all, and a `queued-*` one is sitting in volatile receiver memory. So
183
+ // the same acceptance vocabulary the ordinary receipt uses is named here too — a
184
+ // dirty lock is a reason to be MORE precise about what happened, not less, because
185
+ // the operator is about to decide by hand whether anything needs re-sending.
186
+ const what = result.finalizedOutcome === "rejected"
187
+ ? `rejected${result.finalizedRejectReason ? ` (${result.finalizedRejectReason})` : ""} — the receiver refused it; nothing was accepted`
188
+ : `${result.finalizedBoundary ?? "mailbox-enqueued"}${result.finalizedMessagePath ? ` (enqueued ${basename(result.finalizedMessagePath)})` : ""}`;
154
189
  return {
155
- text: `entwurf_v2 ${result.transport} DELIVERED (${result.finalizedOutcome}) but the lock is DIRTY ` +
190
+ text: `entwurf_v2 ${result.transport} finalized as ${what}, and the lock is DIRTY ` +
156
191
  `(release failed) — do NOT retry, a re-send would double-deliver. Clear the lock by hand.` +
157
192
  `\n error: ${result.error}`,
158
193
  isError: true,
@@ -50,6 +50,16 @@ export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot", "omp", "code
50
50
  * permission dialect: omp's approval layer consults the same minted string (`source-audit.md`).
51
51
  */
52
52
  export const FRESH_CALL_CALLBACK_TOOL = {
53
+ pi: "entwurf_callback",
54
+ "claude-code": "mcp__entwurf-bridge__entwurf_callback",
55
+ copilot: "entwurf-bridge-entwurf_callback",
56
+ omp: "mcp__entwurf_bridge_entwurf_callback",
57
+ // Codex's tool process is the operator app-server, not the pane: the no-arg
58
+ // verb refuses there by name. First action stays the delivery verb with args.
59
+ codex: "mcp__entwurf_bridge__entwurf_v2",
60
+ };
61
+ /** Where a sibling SENDS the task result. Always the delivery verb, never the birth callback. */
62
+ export const FRESH_CALL_DELIVERY_TOOL = {
53
63
  pi: "entwurf_v2",
54
64
  "claude-code": "mcp__entwurf-bridge__entwurf_v2",
55
65
  copilot: "entwurf-bridge-entwurf_v2",
@@ -95,8 +105,11 @@ export const FRESH_CALL_PEERS_TOOL = {
95
105
  * `[측정, n=3, same model]` adding the sentence below produced 3/3 callbacks. It is a FACT about the
96
106
  * runtime, in the register the rest of this framing uses since the prohibitions came out: it grants
97
107
  * nothing, forbids nothing, and names the one call that turns a listed name into a callable tool.
98
- * Both tools ride ONE `ToolSearch` select, so the corroboration the framing offers stays reachable
99
- * for a child that takes the offer.
108
+ * ALL THREE tools ride ONE `ToolSearch` select — the birth callback, the DELIVERY verb the last
109
+ * line of the framing names, and the peers listing the corroboration sentence offers. The delivery
110
+ * verb has to be in the same select or the deferral simply moves from the first action to the last:
111
+ * a sibling that finishes its task and cannot call `entwurf_v2` is the same production silence,
112
+ * spent instead of saved.
100
113
  *
101
114
  * Every other backend gets an EMPTY array, deliberately: pi has no deferred-tool surface, and the
102
115
  * other three were never measured to need it. A hint invented for a runtime nobody probed would be
@@ -106,7 +119,7 @@ export const FRESH_CALL_TOOL_LOAD_HINT = {
106
119
  pi: [],
107
120
  "claude-code": [
108
121
  "If ${callbackTool} is not callable yet, the entwurf-bridge server is still connecting or its",
109
- 'tools are deferred: load them first with ToolSearch("select:${callbackTool},${peersTool}") —',
122
+ 'tools are deferred: load them first with ToolSearch("select:${callbackTool},${deliveryTool},${peersTool}") —',
110
123
  "it waits for the server — then call ${callbackTool}.",
111
124
  ],
112
125
  copilot: [],
@@ -125,21 +138,21 @@ export const FRESH_CALL_TOOL_LOAD_HINT = {
125
138
  export const OMP_BOOTSTRAP_FLAG = "entwurf-bootstrap";
126
139
  /** Payload grammar version, matched exactly by the decoder. A bump means a stale installed
127
140
  * unit, which is the one thing `doctor-omp-bridge` exists to say out loud. */
128
- export const OMP_BOOTSTRAP_VERSION = 1;
141
+ export const OMP_BOOTSTRAP_VERSION = 2;
129
142
  /**
130
143
  * The whole of what a fresh omp sibling is launched with.
131
144
  *
132
- * THREE FIELDS, CLOSED. The decoder refuses an unknown key, so this object is the entire
133
- * contract: who to call back, the nonce that proves it is this call, and the task that is
134
- * released only after that callback succeeds. There is no command here, no path, no env name
135
- * and no model — the model is already an explicit argv token, and a second copy of it inside
136
- * a payload would be a second place for it to disagree with the launch.
145
+ * TWO FIELDS, CLOSED. Address (target + nonce) rides process env, not this payload — a second
146
+ * copy here would be a second address axis. The decoder refuses an unknown key, so this object
147
+ * is the entire remaining contract: the task released after the no-arg callback succeeds.
148
+ * `callerGardenId` / `nonce` stay on the function so launcher call sites do not grow a second
149
+ * shape; they are not serialised.
137
150
  */
138
151
  export function buildOmpBootstrapPayload(params) {
152
+ void params.callerGardenId;
153
+ void params.nonce;
139
154
  return JSON.stringify({
140
155
  v: OMP_BOOTSTRAP_VERSION,
141
- target: params.callerGardenId,
142
- nonce: params.nonce,
143
156
  task: params.task,
144
157
  });
145
158
  }
@@ -271,7 +284,11 @@ export function composeBackendArgs(backend, composition, model, resolveCodexSock
271
284
  case "pi":
272
285
  return [composition.prompt, "--entwurf-control", "--model", model];
273
286
  case "claude-code":
274
- return [composition.prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
287
+ return [
288
+ composition.prompt,
289
+ `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]},${FRESH_CALL_DELIVERY_TOOL["claude-code"]}`,
290
+ `--model=${model}`,
291
+ ];
275
292
  case "copilot":
276
293
  return ["copilot", "--interactive", composition.prompt, "--model", model, "--yolo"];
277
294
  case "omp":
@@ -319,26 +336,41 @@ export function composeBackendArgs(backend, composition, model, resolveCodexSock
319
336
  * corroborate the caller instead of an instruction not to look.
320
337
  */
321
338
  export function composeFreshCallFraming(params) {
322
- const tool = FRESH_CALL_CALLBACK_TOOL[params.backend];
339
+ const birthTool = FRESH_CALL_CALLBACK_TOOL[params.backend];
340
+ const deliveryTool = FRESH_CALL_DELIVERY_TOOL[params.backend];
323
341
  const peersTool = FRESH_CALL_PEERS_TOOL[params.backend];
324
342
  // The hint is a per-backend TEMPLATE so the two tool dialects stay the single source of their
325
343
  // own spelling here too — a hint that hard-coded Claude's name would go stale the day a dialect
326
344
  // moves, and it would go stale silently, in the one sentence a stuck child depends on.
327
- const loadHint = FRESH_CALL_TOOL_LOAD_HINT[params.backend].map((line) => line.replaceAll("${callbackTool}", tool).replaceAll("${peersTool}", peersTool));
345
+ const loadHint = FRESH_CALL_TOOL_LOAD_HINT[params.backend].map((line) => line
346
+ .replaceAll("${callbackTool}", birthTool)
347
+ .replaceAll("${deliveryTool}", deliveryTool)
348
+ .replaceAll("${peersTool}", peersTool));
328
349
  const hintBlock = loadHint.length === 0 ? [] : ["", ...loadHint];
329
350
  if (params.openingLine.length === 0) {
330
351
  throw new Error("fresh-call composition: openingLine is empty — the rail must state where it placed the sibling");
331
352
  }
353
+ const firstAction = params.backend === "codex"
354
+ ? [
355
+ `FIRST ACTION, before reading files or anything else: call ${birthTool} with ` +
356
+ `target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
357
+ `message set to exactly ${params.nonce} — that string alone, nothing added.`,
358
+ `${params.nonce} is a correlation tag this caller minted for this one launch: it names no`,
359
+ "secret and grants no access. Sending it is how the caller learns the garden id you were born",
360
+ "with, which it has no other way to know. Your own record does not carry the caller's address,",
361
+ "so reporting your identity here does not reach it — that tool call does.",
362
+ "entwurf_callback is refused on this rail: the tool process is the operator app-server, not this pane.",
363
+ ]
364
+ : [
365
+ `FIRST ACTION, before reading files or anything else: call ${birthTool} with no arguments.`,
366
+ "That call is how the caller learns the garden id you were born with. There are no",
367
+ "parameters: a target or message you type would be a second address axis.",
368
+ "Your own record does not carry the caller's address, so reporting your identity here does not reach it — that tool call does.",
369
+ ];
332
370
  return [
333
371
  params.openingLine,
334
372
  "",
335
- `FIRST ACTION, before reading files or anything else: call ${tool} with ` +
336
- `target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
337
- `message set to exactly ${params.nonce} — that string alone, nothing added.`,
338
- `${params.nonce} is a correlation tag this caller minted for this one launch: it names no`,
339
- "secret and grants no access. Sending it is how the caller learns the garden id you were born",
340
- "with, which it has no other way to know. Your own record does not carry the caller's address,",
341
- "so reporting your identity here does not reach it — that tool call does.",
373
+ ...firstAction,
342
374
  "",
343
375
  `You can corroborate the caller first if you want to: ${peersTool} is a read-only listing of`,
344
376
  `this garden's citizens and ${params.callerGardenId} is one of them. That listing is capped, so`,
@@ -356,7 +388,7 @@ export function composeFreshCallFraming(params) {
356
388
  // its window is not the delivery. Nothing here watches for completion or sends on the
357
389
  // sibling's behalf — this rail launches and lets go (Hard Rule 16, and the reason there is no
358
390
  // supervisor anywhere in it).
359
- `When the task reaches its requested final result, send that result to the same target with ${tool}.`,
391
+ `When the task reaches its requested final result, send that result to the same target with ${deliveryTool}.`,
360
392
  "Output in this sibling window is not delivered to the caller.",
361
393
  ];
362
394
  }
@@ -43,12 +43,23 @@
43
43
  import { createHash } from "node:crypto";
44
44
  import { statSync } from "node:fs";
45
45
  import path from "node:path";
46
+ import { HERDR_FRESH_CALL_BACKENDS } from "../../scripts/herdr-rails.mjs";
47
+ import { callbackEnvAssignments } from "./callback-env.js";
46
48
  import { buildOmpBootstrapPayload, composeBackendArgs, composeFreshCallFraming, normalizeFreshCallInputs, TASK_MAX_CHARS, } from "./fresh-call-composition.js";
47
- /** The pilot set, closed. `[#116 decision]` herdr's own `--kind` enum is much larger, and that is
49
+ /**
50
+ * The pilot set, closed. `[#116 decision]` herdr's own `--kind` enum is much larger, and that is
48
51
  * NOT evidence of support: every other backend is a pre-mutation named reject here, and there is
49
52
  * no fallback to the tmux rail — a caller inside herdr who asks for codex gets a refusal, not a
50
- * window somewhere else. */
51
- export const HERDR_FRESH_CALL_BACKENDS = ["pi", "claude-code"];
53
+ * window somewhere else.
54
+ *
55
+ * The VALUE now lives in `scripts/herdr-rails.mjs`, the one leaf the status pane and the install
56
+ * narration also read (#116 A7), so a fourth backend cannot appear in prose on one surface while
57
+ * this rail still refuses it. The re-export keeps this module's public name unchanged. The TYPE
58
+ * still has to be a tuple: a `.mjs` leaf infers `readonly string[]` without the JSDoc cast there,
59
+ * and `HerdrFreshCallBackend` would silently widen to `string` while every runtime check still
60
+ * passed. A gate pins that.
61
+ */
62
+ export { HERDR_FRESH_CALL_BACKENDS };
52
63
  /** Our backend name → the `--kind` token herdr accepts. `[측정 2026-09-14]` both round-tripped with
53
64
  * the vendor executable echoed back in `argv`. This is a REQUESTED kind: `[file:line @ c77af189]`
54
65
  * `src/app/agents.rs:197-199` turns it into a bare executable name and writes it into the pane's
@@ -235,6 +246,7 @@ export function buildHerdrTabCreateArgs(params) {
235
246
  "PI_SESSION_ID=",
236
247
  "--env",
237
248
  "PI_AGENT_ID=",
249
+ ...callbackEnvAssignments(params.callback).flatMap((assignment) => ["--env", assignment]),
238
250
  ];
239
251
  }
240
252
  /** `agent start <name> --kind <kind> --pane <id> -- <backend argv>`. Everything after `--` is the
@@ -565,6 +577,7 @@ export async function herdrFreshCall(params, run, env, nonce, clock = HERDR_REAL
565
577
  const tabRun = await run(buildHerdrTabCreateArgs({
566
578
  workspaceId: callerPane.workspaceId,
567
579
  ...(cwd === undefined ? {} : { cwd }),
580
+ callback: { target: callerGardenId, nonce },
568
581
  }));
569
582
  if (tabRun.status !== 0) {
570
583
  // WHETHER ANYTHING EXISTS depends on WHO failed, and the stderr says which. herdr's own
@@ -881,9 +894,10 @@ export function renderHerdrFreshCall(result) {
881
894
  `mean the sibling is running, that its first turn ran, or that the task was delivered. The tab and pane ` +
882
895
  `coordinates are a view and can change under the sibling — they are not an address and nothing may be ` +
883
896
  `dispatched to them.\n` +
884
- `The sibling's garden id arrives separately — it calls entwurf_v2 back with the nonce above as its first ` +
885
- `action, and the sender envelope of THAT message is the address. Nothing is polling for it; if it never ` +
886
- `comes, the pane is visible and can be read directly.`,
897
+ `The sibling's garden id arrives separately — its first action is the zero-argument callback verb, and ` +
898
+ `the sender envelope of THAT message is the address. The nonce above is the correlation tag it carries; ` +
899
+ `the sibling reads it from its own launch env, never from this receipt. Nothing is polling for it; if it ` +
900
+ `never comes, the pane is visible and can be read directly.`,
887
901
  isError: false,
888
902
  };
889
903
  }
@@ -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`).
@@ -65,14 +65,34 @@ const OFFICIAL_REPORTS = {
65
65
  "herdr:pi": { agent: "pi", kind: "path", backend: "pi" },
66
66
  };
67
67
  /**
68
- * A pi session filename, measured 2026-09-14 on pi 0.85.1:
68
+ * A pi session filename, measured 2026-09-20 on pi 0.86.0:
69
69
  *
70
- * 2026-09-14T05-17-03-979Z_01a09e58-f06a-70e8-b14a-1f0f0c7f7c7d.jsonl
70
+ * 2026-09-20T00-35-44-197Z_01a0bc3d-87c4-738e-b391-265c8ba5a1b0.jsonl
71
71
  * └────────── start stamp ─────────┘ └──────── nativeSessionId ────────┘
72
72
  *
73
+ * `[측정 2026-09-20]` that is a REAL file a sandboxed pi 0.86.0 wrote, and its own
74
+ * `{"type":"session"}` header carries `id: 01a0bc3d-87c4-738e-b391-265c8ba5a1b0` —
75
+ * the exact string `piNativeSessionIdFromPath` recovers from the name. The layout was
76
+ * re-measured rather than carried: pi 0.86.0 rewrote session-manager.ts (+269 −141
77
+ * over v0.85.1), but every line that BUILDS a name is byte-unchanged
78
+ * (`${fileTimestamp}_${this.sessionId}.jsonl`, session-manager.ts:991/1527/1703); the
79
+ * only diff in that file's `.jsonl` lines is a lambda parameter rename on the READER
80
+ * side. A source read alone would not have settled it, so the real file is the receipt.
81
+ *
82
+ * `[측정 2026-09-22, pi 0.87.0]` re-measured, and the shape of the evidence CHANGED for the better.
83
+ * Source: the three lines that BUILD a session name are byte-unchanged from 0.86.0
84
+ * (`packages/coding-agent/src/core/session-manager.ts` `${fileTimestamp}_${sessionId}.jsonl` at
85
+ * :1080/:1666/:1842, moved from :991/:1527/:1703 by the +148/−9 `SessionManager`-canonical work
86
+ * 0.87.0 declares as breaking). File: `scripts/check-herdr-placement.ts` now makes the installed
87
+ * `SessionManager` write a real session file into a temp dir on EVERY run — `create()` plus one
88
+ * `appendMessage`, zero model, zero network, zero child process — and asserts this parser reads
89
+ * that basename back to the id the file's own header declares. So the vendor floor below is no
90
+ * longer a receipt somebody took once; it is re-taken by the gate. The 0.86.0 hand-measured file
91
+ * stays a 0.86.0 fact in the bump ledger rather than being re-dated.
92
+ *
73
93
  * WHY THIS IS PINNED HERE AND NOT INFERRED. The uuid is the join key and it lives in
74
94
  * a VENDOR filename, so this rule depends on pi's naming and would break silently if
75
- * pi changed it. **Vendor floor: pi 0.85.1, measured 2026-09-14** — whether that layout
95
+ * pi changed it. **Vendor floor: pi 0.86.0, measured 2026-09-20** — whether that layout
76
96
  * is a vendor contract or a convention is NOT measured, so this is the one place in the
77
97
  * axis that can drift under us. It is therefore strict on both ends — the name must end
78
98
  * in `.jsonl` and the tail after the last `_` must be a well-formed uuid — and a name