@junghanacs/entwurf 0.34.2 → 0.35.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 (70) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/README.md +7 -0
  3. package/VERIFY.md +16 -5
  4. package/demo/README.md +109 -175
  5. package/demo/demo-baseline.sh +5 -1
  6. package/demo/demo.sh +6 -2
  7. package/demo/herdr-pi-cc-20261011.provenance.json +64 -0
  8. package/docs/acp-backend-rail.md +28 -2
  9. package/docs/assets/entwurf-demo.gif +0 -0
  10. package/docs/setup-clean-host.md +1 -1
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +46 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +67 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +9 -20
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +11 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +27 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +13 -4
  19. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +119 -11
  20. package/package.json +4 -2
  21. package/pi-extensions/lib/acp/backend-adapter.ts +52 -2
  22. package/pi-extensions/lib/acp/model-ids.js +77 -0
  23. package/pi-extensions/lib/acp/models.ts +15 -22
  24. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +12 -0
  25. package/pi-extensions/lib/fresh-call-dispatch.ts +25 -1
  26. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  27. package/pi-extensions/lib/mux-fresh-call.ts +3 -0
  28. package/pi-extensions/lib/resume-launch-identity.ts +12 -4
  29. package/pi-extensions/meta-bridge-pi-durable.ts +153 -13
  30. package/run.sh +118 -20
  31. package/scripts/check-acp-backend-preflight.ts +10 -2
  32. package/scripts/check-acp-cortex.ts +7 -6
  33. package/scripts/check-acp-prompt-lifecycle.ts +2 -2
  34. package/scripts/check-acp-provider-surface.ts +1 -1
  35. package/scripts/check-acp-session-reuse.ts +5 -5
  36. package/scripts/check-acp-stop-reason.ts +2 -2
  37. package/scripts/check-acp-stream-hooks.ts +2 -2
  38. package/scripts/check-acp-usage-accounting.ts +2 -2
  39. package/scripts/check-fresh-call-dispatch.ts +47 -4
  40. package/scripts/check-gate-qualification.ts +8 -5
  41. package/scripts/check-probe-ordering.ts +13 -6
  42. package/scripts/check-release-gate-outcomes.ts +159 -1
  43. package/scripts/check-resume-launch-identity.ts +38 -0
  44. package/scripts/ci-qualify-decide.sh +114 -17
  45. package/scripts/mutants/acp-cortex.json +17 -8
  46. package/scripts/mutants/acp-set-model-readback.json +28 -0
  47. package/scripts/mutants/demo-reproducibility.json +155 -0
  48. package/scripts/mutants/fresh-call-dispatch.json +11 -0
  49. package/scripts/mutants/herdr-dev-checkout.json +70 -0
  50. package/scripts/mutants/pi-durable.json +117 -0
  51. package/scripts/mutants/release-gate.json +28 -2
  52. package/scripts/mutants/resume-launch-identity.json +15 -0
  53. package/scripts/mutants/v2-visible-resume.json +11 -0
  54. package/scripts/resolve-acp-bridge.ts +1 -1
  55. package/scripts/smoke-acp-bundled-mcp-live.ts +1 -1
  56. package/scripts/smoke-acp-carrier-augment-live.ts +1 -1
  57. package/scripts/smoke-acp-long-turn-live.ts +1 -1
  58. package/scripts/smoke-acp-mcp-live.ts +1 -1
  59. package/scripts/smoke-acp-memory-containment-live.ts +1 -1
  60. package/scripts/smoke-acp-ordering-probe-live.ts +1 -1
  61. package/scripts/smoke-acp-overlay-live.ts +1 -1
  62. package/scripts/smoke-acp-provider-live.ts +1 -1
  63. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  64. package/scripts/smoke-acp-session-reuse-live.ts +1 -1
  65. package/scripts/smoke-acp-skill-live.ts +1 -1
  66. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  67. package/scripts/smoke-acp-v2-send-live.ts +1 -1
  68. package/scripts/smoke-entwurf-chain-live.ts +1 -1
  69. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  70. package/scripts/tsconfig.json +1 -0
@@ -33,9 +33,18 @@
33
33
  // re-export of `getBuiltinModels`. When pi removes compat we migrate to whatever
34
34
  // the loader then exposes.
35
35
  import { getModels } from "@earendil-works/pi-ai/compat";
36
+ import { CURATED_ANCHOR_MODEL_ID, SUPPORTED_ANTHROPIC_MODEL_IDS } from "./model-ids.js";
36
37
 
37
- /** Provider id — current pre-rename surface; S1 renames this load-bearing id to `entwurf`. */
38
- export const PROVIDER_ID = "entwurf";
38
+ // The provider id and the curated ID lists live in the dependency-free
39
+ // `model-ids.js` leaf, shared with the launch seams (#128); this file is the only
40
+ // one that loads the upstream catalog to turn them into registry rows.
41
+ export {
42
+ CORTEX_MODEL_PREFIX,
43
+ CURATED_ANCHOR_MODEL_ID,
44
+ PROVIDER_ID,
45
+ SUPPORTED_ANTHROPIC_MODEL_IDS,
46
+ SUPPORTED_CORTEX_MODEL_IDS,
47
+ } from "./model-ids.js";
39
48
 
40
49
  // #26 auth-boundary sentinel. `pi.registerProvider` requires an apiKey when a
41
50
  // provider defines custom models, but the ACP plugin consumes NO key: backend
@@ -49,17 +58,8 @@ export const PROVIDER_ID = "entwurf";
49
58
  // check-auth-boundary gate pins this.
50
59
  export const ENTWURF_ACP_NO_AUTH_SENTINEL = "entwurf-no-auth";
51
60
 
52
- // The curated Claude ids. Adding one here is a commitment to verify it across
53
- // both axes (protocol smoke + agent interview) — do not extend casually.
54
- // Exported so the claude backend adapter (backend-adapter.ts) can answer
55
- // `routeModel` without re-deriving the set from curatedClaudeModels().
56
- export const SUPPORTED_ANTHROPIC_MODEL_IDS = ["claude-sonnet-5", "claude-opus-5", "claude-fable-5-1"] as const;
57
-
58
- /** The anchor model whose absence is a hard registry regression, not a soft skip. */
59
- export const CURATED_ANCHOR_MODEL_ID = "claude-opus-5";
60
-
61
- // Anthropic's registry reports 1M for Sonnet 5, Opus 5 and Fable 5.1, and the
62
- // entwurf surface exposes the full 1M for all three. Sonnet 5's 1M window is the
61
+ // Anthropic's registry reports 1M for Sonnet 5.5, Opus 5.5 and Fable 5.1, and the
62
+ // entwurf surface exposes the full 1M for all three. Sonnet 5's 1M window was the
63
63
  // whole point of the 0.12.3 bump — it is the compact-free long-context floor the
64
64
  // earlier 200K Sonnet cap could not provide. We still clamp to a 1M ceiling so a
65
65
  // future registry value can't silently inflate the surface past what we verify.
@@ -92,7 +92,7 @@ function claudeContextWindow(model: { id: string; contextWindow: number }): numb
92
92
  // carries the reserved `cortex-` prefix: the prefix is the SINGLE routing
93
93
  // authority (backend-adapter.ts `routeModel`) and keeps the ids off the Claude
94
94
  // native ids Cortex routes to (`cortex-claude-sonnet-5` vs the unprefixed
95
- // `claude-sonnet-5` the claude adapter owns). `routeModel` strips the prefix to
95
+ // `claude-sonnet-5-5` the claude adapter owns). `routeModel` strips the prefix to
96
96
  // recover the native id, which is enforced per-turn via
97
97
  // session/set_config_option — never a launch `-m` pin (CP0-M).
98
98
  //
@@ -101,14 +101,7 @@ function claudeContextWindow(model: { id: string; contextWindow: number }): numb
101
101
  // the running cortex no longer serves fails LOUD at set-model, before the
102
102
  // prompt (measured: `Unsupported model: …` names the live set). Adding an id
103
103
  // here is the same verify-both-axes commitment as the Claude set — do not
104
- // extend casually.
105
- export const CORTEX_MODEL_PREFIX = "cortex-";
106
- export const SUPPORTED_CORTEX_MODEL_IDS = [
107
- "cortex-auto",
108
- "cortex-claude-opus-5",
109
- "cortex-claude-sonnet-5",
110
- "cortex-openai-gpt-5.4",
111
- ] as const;
104
+ // extend casually. The prefix and the four ids are declared in model-ids.js.
112
105
 
113
106
  // Hand-set conservative context window. Cortex reports the LIVE window via its
114
107
  // own ACP session config, not this curated metadata — this is a floor for the
@@ -47,6 +47,7 @@
47
47
  * outcome for this module to claim.
48
48
  */
49
49
 
50
+ import { ACP_MODEL_NOT_CURATED_HINT, AcpModelNotCuratedError } from "./acp/model-ids.js";
50
51
  import { buildResumePiArgs } from "./entwurf-resume-args.ts";
51
52
  import {
52
53
  type AcquireLockResult,
@@ -93,6 +94,7 @@ export const RESUME_OBSERVE_INTERVAL_MS = 500;
93
94
  export type VisibleResumeRejectReason =
94
95
  | "target-invalid"
95
96
  | "target-not-pi"
97
+ | "acp-model-not-curated"
96
98
  | "target-locked"
97
99
  | "target-live"
98
100
  | "target-indeterminate"
@@ -227,6 +229,15 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
227
229
  try {
228
230
  identity = deps.resolveIdentity(targetGardenId);
229
231
  } catch (err) {
232
+ // #128: the recorded model is not an exact curated ACP id, so Pi would pick another row
233
+ // by substring. Same shape as `target-not-pi`: a named refusal matched on its FIELD.
234
+ if (err instanceof AcpModelNotCuratedError) {
235
+ return {
236
+ ok: false,
237
+ reason: err.reason,
238
+ detail: `${targetGardenId} would launch model ${JSON.stringify(err.model)} under the entwurf provider (the resolved launch pair, which may differ from the recorded provider)`,
239
+ };
240
+ }
230
241
  if (err instanceof ResumeBackendUnsupportedError) {
231
242
  return {
232
243
  ok: false,
@@ -321,6 +332,7 @@ export async function observeSameGidSocket(
321
332
  const REJECT_HINT: Record<VisibleResumeRejectReason, string> = {
322
333
  "target-invalid": "no citizen has that id; discover targets with entwurf_peers",
323
334
  "target-not-pi": "that citizen's backend has no same-id resume — reach it with entwurf_v2, or open a fresh sibling",
335
+ "acp-model-not-curated": ACP_MODEL_NOT_CURATED_HINT,
324
336
  "target-locked": "another resume or dispatch holds this citizen's lock right now",
325
337
  "target-live": "that citizen is already running — this verb only reopens dormant ones",
326
338
  "target-indeterminate": "the socket probe could not prove the citizen is dormant, so nothing was started",
@@ -25,8 +25,9 @@
25
25
  */
26
26
 
27
27
  import { spawn as spawnChildProcess } from "node:child_process";
28
+ import { ACP_MODEL_NOT_CURATED, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
28
29
  import { codexCallerFreshPreflight, codexFreshPreflight } from "./codex-fresh-preflight.ts";
29
- import { mintNonce } from "./fresh-call-composition.ts";
30
+ import { isSafeFreshCallModel, mintNonce } from "./fresh-call-composition.ts";
30
31
  import {
31
32
  createHerdrRunner,
32
33
  type HerdrFreshCallResult,
@@ -67,6 +68,23 @@ export type DispatchedFreshCall =
67
68
  | { readonly rail: "tmux"; readonly result: FreshCallResult }
68
69
  | { readonly rail: "herdr"; readonly result: HerdrFreshCallResult };
69
70
 
71
+ /**
72
+ * #128: a pi sibling asked for under the `entwurf` provider must name an EXACT curated ACP id. Pi
73
+ * resolves `--model` by substring among that provider's rows, so a retired `entwurf/claude-sonnet-5`
74
+ * would open on `cortex-claude-sonnet-5` — another vendor and billing rail — with a receipt that
75
+ * still names the requested id. Asked here, at the one root both rails share, so neither rail can
76
+ * forget it. It reads the model the way the rails will (trimmed), and only a model that already
77
+ * passes their grammar: a malformed one keeps its own `model-invalid` answer from the rail.
78
+ */
79
+ export function isUncuratedEntwurfPiModel(backend: string, model: string): boolean {
80
+ if (backend !== "pi") return false;
81
+ const trimmed = model.trim();
82
+ if (!isSafeFreshCallModel(trimmed)) return false;
83
+ const slash = trimmed.indexOf("/");
84
+ if (slash < 0 || trimmed.slice(0, slash) !== PROVIDER_ID) return false;
85
+ return !isCuratedAcpModelId(trimmed.slice(slash + 1));
86
+ }
87
+
70
88
  /**
71
89
  * Open a sibling on whichever rail this process is actually standing in.
72
90
  *
@@ -88,6 +106,12 @@ export async function dispatchFreshCall(
88
106
  spawn?: SpawnFn,
89
107
  nonce: string = mintNonce(),
90
108
  ): Promise<DispatchedFreshCall> {
109
+ // Before either rail is asked anything: both would open a pane on the wrong row.
110
+ if (isUncuratedEntwurfPiModel(request.backend, request.model)) {
111
+ return selectFreshCallRail(env) === "herdr"
112
+ ? { rail: "herdr", result: { ok: false, reason: ACP_MODEL_NOT_CURATED } }
113
+ : { rail: "tmux", result: { ok: false, reason: ACP_MODEL_NOT_CURATED } };
114
+ }
91
115
  if (selectFreshCallRail(env) === "herdr") {
92
116
  const context = resolveHerdrContext(env);
93
117
  if (!context.ok) {
@@ -119,6 +119,7 @@ export type HerdrFreshCallRejectReason =
119
119
  // The caller-facing input contract, shared with the tmux rail so a mistyped model or an
120
120
  // oversized task is answered with the SAME word on either placement owner.
121
121
  | FreshCallInputRejectReason
122
+ | "acp-model-not-curated"
122
123
  | "herdr-context-missing"
123
124
  | "herdr-caller-pane-missing"
124
125
  /** herdr would not tell us where the CALLER is sitting. Three separate names because the
@@ -1102,6 +1103,10 @@ const HERDR_REJECT_HINT: Record<HerdrFreshCallRejectReason | HerdrLaunchFailureR
1102
1103
  "this surface has no record-backed identity, so a sibling would have nowhere to call home",
1103
1104
  "model-empty": "pass the model you want the sibling opened with — it is never inherited from this process",
1104
1105
  "model-invalid": "the model is not in the accepted grammar (no whitespace, no leading dash)",
1106
+ // #128: decided at the dispatch root (`fresh-call-dispatch.ts`), worded here because this rail's
1107
+ // import fence admits no ACP module. The curated list itself lives in `acp/model-ids.js`.
1108
+ "acp-model-not-curated":
1109
+ "an entwurf model must be one exact curated ACP id — Pi would otherwise pick another row by substring (a retired claude-sonnet-5 lands on cortex-claude-sonnet-5), so nothing was opened and nothing was aliased",
1105
1110
  "task-empty": "a sibling opened with nothing to do is a window nobody asked for",
1106
1111
  "task-too-long": `the task is over the ${TASK_MAX_CHARS}-character interface bound this verb shares with entwurf_v2`,
1107
1112
  "herdr-context-missing":
@@ -86,6 +86,7 @@
86
86
  * there is no "session created" field because nothing here creates one.
87
87
  */
88
88
 
89
+ import { ACP_MODEL_NOT_CURATED_HINT } from "./acp/model-ids.js";
89
90
  import { callbackEnvAssignments } from "./callback-env.ts";
90
91
  import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
91
92
  import {
@@ -265,6 +266,7 @@ export type FreshCallRejectReason =
265
266
  | CodexPreflightRejectReason
266
267
  | CodexCallerPreflightRejectReason
267
268
  | CodexCallerSeatRejectReason
269
+ | "acp-model-not-curated"
268
270
  | "caller-identity-unavailable"
269
271
  | "model-empty"
270
272
  | "model-invalid"
@@ -678,6 +680,7 @@ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
678
680
  "no session with that exact name answers on this agent's tmux server (or that server stopped answering) — nothing was created, so open the session yourself and call again",
679
681
  "model-empty": "model is empty after trimming; fresh calls require an explicit model",
680
682
  "model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
683
+ "acp-model-not-curated": ACP_MODEL_NOT_CURATED_HINT,
681
684
  "task-empty": "task is empty after trimming",
682
685
  "task-too-long": `task exceeds ${TASK_MAX_CHARS} characters`,
683
686
  "runtime-unresolved": "the backend's runtime is not installed on PATH",
@@ -20,12 +20,13 @@
20
20
 
21
21
  import { existsSync } from "node:fs";
22
22
  import path from "node:path";
23
+ import { AcpModelNotCuratedError, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
23
24
  import { getEntwurfExplicitExtensions, readSessionIdentity } from "./entwurf-core.ts";
24
25
  import { readAddressableMetaIdentity } from "./meta-session.ts";
25
26
 
26
27
  /**
27
- * The ONE expected refusal on this leaf: the target is a citizen of a backend that has no
28
- * same-id resume at all.
28
+ * One of the TWO expected refusals on this leaf: the target is a citizen of a backend that has no
29
+ * same-id resume at all. The other is `AcpModelNotCuratedError` (#128, `acp/model-ids.js`).
29
30
  *
30
31
  * Everything else this function throws is a defect in the record, the transcript or the
31
32
  * environment — a stale path, a foreign session file, an unrecorded model, an unresolvable
@@ -152,11 +153,18 @@ export function resolveResumeLaunchIdentity(gardenId: string): LaunchIdentity {
152
153
  if (!identity.cwd) {
153
154
  throw new Error(`resume-launch-identity: ${gardenId} header has no cwd (the cold-resume authority, #9).`);
154
155
  }
156
+ // #128: the pair checked is the one the launch will EMIT, not the recorded provider alone —
157
+ // `getEntwurfExplicitExtensions` also routes a bare Claude model id to `entwurf`. Under that
158
+ // provider Pi resolves `--model` by substring, so a retired `claude-sonnet-5` would reopen on
159
+ // `cortex-claude-sonnet-5`. The record and transcript stay as they are; the resume is refused.
160
+ const provider = explicitExtensions.provider ?? identity.provider;
161
+ const model = explicitExtensions.modelOverride ?? resumeModel;
162
+ if (provider === PROVIDER_ID && !isCuratedAcpModelId(model)) throw new AcpModelNotCuratedError(model);
155
163
  return {
156
164
  sessionFile,
157
165
  cwd: identity.cwd,
158
166
  explicitExtensionArgs: explicitExtensions.args,
159
- provider: explicitExtensions.provider ?? identity.provider,
160
- model: explicitExtensions.modelOverride ?? resumeModel,
167
+ provider,
168
+ model,
161
169
  };
162
170
  }
@@ -58,6 +58,24 @@
58
58
  * vanished signal, a failed ring, a rejected admission or close retires the doorbell — watch
59
59
  * closed, our own marker removed, the reason kept by name — and nothing retries it.
60
60
  *
61
+ * WITHDRAW (#140). Each notice is a native `followUp`, placed one per run; a read drains EVERY
62
+ * unread body. So after a read the notices still queued for that mail would each cost a turn and an
63
+ * empty read. The contact keeps the native `Submission` handle of each notice it admitted, with the
64
+ * `.msg` names that notice announced, only while that mail is unread. After every
65
+ * `entwurf_inbox_read` attempt — whatever its garden id or outcome — it lists its OWN mailbox once
66
+ * and withdraws, through the native `Submission.abort` and the read's own invocation Context, each
67
+ * notice none of whose names is still `.msg.delivered`. That per-notice file predicate is the only
68
+ * authority: no garden-id guard, no requestId lookup, no queue of our own, no rewritten text. A placed
69
+ * or settled notice answers so and is let go; a withdraw that fails is let go too, named in one
70
+ * stderr line and on the receiver report, never retried and never retiring the doorbell; a mailbox
71
+ * that cannot be listed proves nothing absent, so every notice is kept and that is named the same
72
+ * way. The read's own result or error is returned unchanged — no diagnostic joins it. Ring passes
73
+ * and reads share one gate: a read waits for an admission in flight (else its drain could beat the
74
+ * handle it must withdraw), and an arrival during a read is rung once the read lets go. The count
75
+ * in a notice is its admission-time snapshot. Not covered: notices admitted before a restart (no
76
+ * handle), mail drained by another process (until this contact's next read), and operator abort,
77
+ * which natively withdraws queued notices while their mail stays unread.
78
+ *
61
79
  * VISIBLE IDENTITY (a static projection). `withGardenIdentity` hands the native TUI a view whose
62
80
  * every conversation label carries this citizen's garden id, so the footer the TUI keeps on screen
63
81
  * names the citizen; the bootstrap composes it only after birth succeeded. That is evidence of what
@@ -79,7 +97,8 @@
79
97
  *
80
98
  * NOT HERE: crash-safe receive. `entwurf_inbox_read` archives a body before the durable tool
81
99
  * result commits, so a crash between the two loses that body to the model; recovery settles the
82
- * call as interrupted and no doorbell rings again. No resume.
100
+ * call as interrupted and no doorbell rings again. A drain that fails part-way has archived the
101
+ * bodies before it (the bridge returns its error), and those too never reach the model. No resume.
83
102
  */
84
103
 
85
104
  import * as fs from "node:fs";
@@ -530,9 +549,14 @@ export interface DurableDoorbellDraft {
530
549
  readonly requestId: string;
531
550
  }
532
551
 
533
- /** The part of the native `Submission` handle a doorbell records; admission is not completion. */
552
+ /** What the native `Submission.abort` answers: only a still-queued input is withdrawn. */
553
+ export type DurableWithdrawal = "aborted" | "already_placed" | "settled";
554
+
555
+ /** The part of the native `Submission` handle a doorbell keeps; admission is not completion. */
534
556
  export interface DurableAdmittedSubmission {
535
557
  readonly id: unknown;
558
+ /** Native `Submission.abort(context)`, handed the read's own invocation Context unchanged. */
559
+ abort(context: DurableCallContext | undefined): Promise<DurableWithdrawal>;
536
560
  }
537
561
 
538
562
  /** The overlay's `OpenDurableResult.submitToRoot`. */
@@ -562,21 +586,40 @@ export interface PiDurableDoorbellAdmission {
562
586
  unread: number;
563
587
  }
564
588
 
589
+ /**
590
+ * The LATEST notice withdrawal that failed. `inspection-failed`: the own mailbox could not be listed,
591
+ * so no notice was judged and every one is kept. `abort-failed`: the native withdraw of the notice
592
+ * named by `requestId` rejected; that notice is let go, not retried.
593
+ */
594
+ export interface PiDurableWithdrawalFailure {
595
+ reason: "inspection-failed" | "abort-failed";
596
+ requestId?: string;
597
+ detail: string;
598
+ }
599
+
565
600
  /**
566
601
  * The receiver's diagnostic. `admissions` is the LATEST successful admission only — an empty array
567
602
  * before the first ring, then exactly one entry, replaced by each later success; a retirement keeps
568
603
  * the last one. It is a diagnostic, not a history: it never grows with the host's lifetime, and its
569
604
  * length is no count of how often the root was asked (the root's own submissions are that record).
605
+ * `withdrawalFailure`, present only once a withdrawal failed, is likewise the latest one only.
570
606
  */
571
607
  export type PiDurableReceiverReport =
572
608
  | { status: "off" }
573
- | { status: "armed"; gardenId: string; markerPath: string; admissions: PiDurableDoorbellAdmission[] }
609
+ | {
610
+ status: "armed";
611
+ gardenId: string;
612
+ markerPath: string;
613
+ admissions: PiDurableDoorbellAdmission[];
614
+ withdrawalFailure?: PiDurableWithdrawalFailure;
615
+ }
574
616
  | {
575
617
  status: "retired";
576
618
  gardenId: string;
577
619
  reason: PiDurableReceiverRetirement;
578
620
  detail: string;
579
621
  admissions: PiDurableDoorbellAdmission[];
622
+ withdrawalFailure?: PiDurableWithdrawalFailure;
580
623
  };
581
624
 
582
625
  export interface PiDurableAttachment {
@@ -641,6 +684,13 @@ interface ArmedDoorbell {
641
684
  submitToRoot: DurableSubmitToRoot;
642
685
  }
643
686
 
687
+ /** One admitted notice: its native handle and the `.msg` names it announced. */
688
+ interface HeldNotice {
689
+ readonly requestId: string;
690
+ readonly fresh: readonly string[];
691
+ readonly submission: DurableAdmittedSubmission;
692
+ }
693
+
644
694
  const TOOL_DESCRIPTIONS: Record<PiDurableToolName, string> = {
645
695
  entwurf_self:
646
696
  "Return this durable host's garden identity envelope (sessionId = garden id, agentId, cwd, replyable, rail) " +
@@ -730,7 +780,8 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
730
780
  throw new Error(`${name} arguments must be an object`);
731
781
  }
732
782
  const client = await waitReady(context?.abortSignal);
733
- const result = await client.callTool(name, (args ?? {}) as Record<string, unknown>, context?.abortSignal);
783
+ const call = () => client.callTool(name, (args ?? {}) as Record<string, unknown>, context?.abortSignal);
784
+ const result = name === "entwurf_inbox_read" ? await readInbox(call, context) : await call();
734
785
  if (result.isError) throw new Error(result.text || `${name} failed`);
735
786
  return { content: [{ type: "text", text: result.text }] };
736
787
  },
@@ -745,8 +796,34 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
745
796
  let doorbell: ArmedDoorbell | null = null;
746
797
  let report: PiDurableReceiverReport = { status: "off" };
747
798
  const admissions: PiDurableDoorbellAdmission[] = [];
748
- let ringing = false;
799
+ /** The armed mailbox, kept past a retirement so notices admitted before it can still be judged. */
800
+ let inbox: string | null = null;
801
+ /** Every admitted notice still owed a withdraw judgement, oldest first. */
802
+ const notices: HeldNotice[] = [];
803
+ let withdrawalFailure: PiDurableWithdrawalFailure | undefined;
804
+ // One gate for ring passes and inbox reads; `pending` is a signal that landed while it was held.
805
+ let gated = false;
749
806
  let pending = false;
807
+ const gateWaiters: (() => void)[] = [];
808
+
809
+ const enterGate = (): Promise<void> => {
810
+ if (!gated) {
811
+ gated = true;
812
+ return Promise.resolve();
813
+ }
814
+ return new Promise<void>((resolve) => gateWaiters.push(resolve));
815
+ };
816
+
817
+ /** Hand the gate to the next waiting read, else open it — and ring once for a signal that landed meanwhile. */
818
+ const leaveGate = (): void => {
819
+ const next = gateWaiters.shift();
820
+ if (next !== undefined) {
821
+ next();
822
+ return;
823
+ }
824
+ gated = false;
825
+ if (pending) void ring("signal");
826
+ };
750
827
 
751
828
  /**
752
829
  * Give the doorbell back: close the watch and remove the marker, in one step, because a
@@ -791,19 +868,19 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
791
868
  /**
792
869
  * One contract with the other self-fetch doorbells: a fresh `*.msg` is the trigger, it is
793
870
  * stamped `.msg.delivered` before the announce, and the announced count is every
794
- * `.msg.delivered`. Events that land while an admission is in flight coalesce into one more
795
- * pass. A failed pass retires the doorbell; it is not retried. Only a fresh `.msg` rings —
796
- * the startup pass included — so a `.msg.delivered` left undrained by an earlier host is not
797
- * announced again on its own; it shows only in the count of the next fresh arrival.
871
+ * `.msg.delivered`. Events that land while an admission or an inbox read holds the gate coalesce
872
+ * into one more pass. A failed pass retires the doorbell; it is not retried. Only a fresh `.msg`
873
+ * rings — the startup pass included — so a `.msg.delivered` left undrained by an earlier host is
874
+ * not announced again on its own; it shows only in the count of the next fresh arrival.
798
875
  */
799
876
  const ring = async (why: PiDurableDoorbellAdmission["why"]): Promise<void> => {
800
877
  const armed = doorbell;
801
878
  if (armed === null) return;
802
- if (ringing) {
879
+ if (gated) {
803
880
  pending = true;
804
881
  return;
805
882
  }
806
- ringing = true;
883
+ gated = true;
807
884
  try {
808
885
  do {
809
886
  pending = false;
@@ -836,11 +913,71 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
836
913
  }
837
914
  // Latest only: the diagnostic names the LAST success and never grows with the host's lifetime.
838
915
  admissions.splice(0, admissions.length, { why, requestId, submissionId: submission.id, fresh, unread });
916
+ notices.push({ requestId, fresh, submission });
839
917
  } while (pending && doorbell === armed);
840
918
  } catch (error) {
841
919
  retire("doorbell-failed", describeError(error));
842
920
  } finally {
843
- ringing = false;
921
+ leaveGate();
922
+ }
923
+ };
924
+
925
+ /**
926
+ * One named stderr line per failed withdrawal — stderr ALWAYS, as `entwurf-control` does, since
927
+ * nothing in the native app reads the receiver report — and the report keeps the latest one.
928
+ */
929
+ const noteWithdrawalFailure = (failure: PiDurableWithdrawalFailure): void => {
930
+ withdrawalFailure = failure;
931
+ const subject = failure.requestId === undefined ? "" : ` ${failure.requestId}`;
932
+ const detail = failure.detail.replace(/\s+/g, " ");
933
+ process.stderr.write(`[entwurf pi-durable] notice withdrawal ${failure.reason}${subject}: ${detail}\n`);
934
+ };
935
+
936
+ /**
937
+ * Withdraw every held notice none of whose announced `.msg` names is still `.msg.delivered` in the
938
+ * armed mailbox, listed once. A mailbox that cannot be listed proves nothing absent: every notice
939
+ * is kept and that is named. A judged notice is let go whatever the native withdraw answers — or
940
+ * when it rejects, which is named and never retried — so no handle outlives its mail by more
941
+ * than one read. Never throws: the read's own answer is what the caller gets.
942
+ */
943
+ const withdrawDrained = async (context: DurableCallContext | undefined): Promise<void> => {
944
+ if (inbox === null || notices.length === 0) return;
945
+ let unread: Set<string>;
946
+ try {
947
+ unread = new Set(fs.readdirSync(inbox).filter((name) => name.endsWith(".msg.delivered")));
948
+ } catch (error) {
949
+ noteWithdrawalFailure({ reason: "inspection-failed", detail: `${inbox}: ${describeError(error)}` });
950
+ return;
951
+ }
952
+ for (const notice of [...notices]) {
953
+ if (notice.fresh.some((name) => unread.has(`${name}.delivered`))) continue;
954
+ notices.splice(notices.indexOf(notice), 1);
955
+ try {
956
+ await notice.submission.abort(context);
957
+ } catch (error) {
958
+ noteWithdrawalFailure({ reason: "abort-failed", requestId: notice.requestId, detail: describeError(error) });
959
+ }
960
+ }
961
+ };
962
+
963
+ /**
964
+ * An inbox read under the doorbell's gate, then the withdrawal its drain made due — after a
965
+ * failed read too, whose partial drain may already have archived mail. The bridge's own answer,
966
+ * result or rejection, is returned unchanged.
967
+ */
968
+ const readInbox = async (
969
+ call: () => Promise<BridgeCallResult>,
970
+ context: DurableCallContext | undefined,
971
+ ): Promise<BridgeCallResult> => {
972
+ await enterGate();
973
+ try {
974
+ return await call();
975
+ } finally {
976
+ try {
977
+ await withdrawDrained(context);
978
+ } finally {
979
+ leaveGate();
980
+ }
844
981
  }
845
982
  };
846
983
 
@@ -893,6 +1030,7 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
893
1030
  receiversDir: roots.receiversDir,
894
1031
  });
895
1032
  doorbell = { gardenId, ownerPid, mailbox, signal, markerPath, watcher, submitToRoot };
1033
+ inbox = mailbox;
896
1034
  report = { status: "armed", gardenId, markerPath, admissions };
897
1035
  return markerPath;
898
1036
  } catch (error) {
@@ -1011,13 +1149,15 @@ export function createPiDurableContact(options: PiDurableContactOptions): PiDura
1011
1149
  settleFailed(describeError(error));
1012
1150
  },
1013
1151
  receiver() {
1014
- return report;
1152
+ return withdrawalFailure === undefined || report.status === "off" ? report : { ...report, withdrawalFailure };
1015
1153
  },
1016
1154
  async close() {
1017
1155
  const previous = state;
1018
1156
  settleFailed("the contact closed before attach completed");
1019
1157
  state = { kind: "closed" };
1020
1158
  const client = previous.kind === "ready" ? previous.client : undefined;
1159
+ // Queued notices stay native (a continued session places them); this contact judges no more.
1160
+ notices.length = 0;
1021
1161
  await closeAll(disarmOnClose, async () => {
1022
1162
  await client?.close();
1023
1163
  });