@junghanacs/entwurf 0.30.0 → 0.31.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 (69) hide show
  1. package/AGENTS.md +1 -0
  2. package/CHANGELOG.md +229 -0
  3. package/DELIVERY.md +1 -0
  4. package/README.md +49 -4
  5. package/VERIFY.md +23 -4
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +111 -18
  8. package/docs/setup-clean-host.md +104 -4
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +7 -7
  10. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +43 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +7 -1
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +34 -3
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +7 -1
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +28 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +30 -2
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +92 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +288 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +744 -0
  20. package/mcp/entwurf-bridge/src/index.ts +7 -7
  21. package/mcp/entwurf-bridge/tsconfig.build.json +8 -1
  22. package/package.json +15 -12
  23. package/pi/entwurf-capabilities.json +1 -0
  24. package/pi/pi-durable/bootstrap.mjs +281 -0
  25. package/pi/pi-durable/overlay/runtime-contacts.patch +91 -0
  26. package/pi/pi-durable/overlay/upstream-pin.json +16 -0
  27. package/pi-extensions/lib/acp/backend-adapter.ts +68 -0
  28. package/pi-extensions/lib/acp/backend.ts +41 -5
  29. package/pi-extensions/lib/entwurf-deliverability.ts +7 -1
  30. package/pi-extensions/lib/fresh-call-composition.ts +42 -7
  31. package/pi-extensions/lib/meta-sender-identity.ts +7 -1
  32. package/pi-extensions/lib/meta-session.ts +28 -2
  33. package/pi-extensions/lib/mux-fresh-call.ts +34 -2
  34. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +113 -0
  35. package/pi-extensions/lib/pi-durable-runtime.ts +384 -0
  36. package/pi-extensions/meta-bridge-pi-durable.ts +1020 -0
  37. package/run.sh +214 -36
  38. package/scripts/check-fresh-call-dispatch.ts +18 -0
  39. package/scripts/check-gate-qualification.ts +4 -2
  40. package/scripts/check-herdr-plugin-build.ts +159 -57
  41. package/scripts/check-pi-durable-contact.ts +834 -0
  42. package/scripts/check-pi-durable-receive.ts +1116 -0
  43. package/scripts/check-pi-durable-send.ts +1661 -0
  44. package/scripts/fixtures/herdr-supply.json +8 -8
  45. package/scripts/lib/pi-durable-fresh-live-receipts.ts +139 -0
  46. package/scripts/lib/pi-durable-r1.ts +100 -0
  47. package/scripts/lib/pi-durable-reap.ts +27 -0
  48. package/scripts/lib/pi-durable-receive.ts +137 -0
  49. package/scripts/mutants/acp-prompt-lifecycle.json +2 -2
  50. package/scripts/mutants/acp-vendor-rejection.json +62 -0
  51. package/scripts/mutants/control-send-receipt.json +1 -1
  52. package/scripts/mutants/copilot-birth.json +2 -2
  53. package/scripts/mutants/herdr-plugin-build.json +11 -0
  54. package/scripts/mutants/herdr-supply.json +3 -3
  55. package/scripts/mutants/meta-hook-session-switch.json +4 -2
  56. package/scripts/mutants/mux-fresh-call.json +69 -3
  57. package/scripts/mutants/omp-birth.json +2 -2
  58. package/scripts/mutants/omp-fresh.json +6 -4
  59. package/scripts/mutants/pack-install.json +3 -3
  60. package/scripts/mutants/pi-durable.json +390 -0
  61. package/scripts/mutants/pi-floor.json +2 -2
  62. package/scripts/pi-durable-contact-host.mjs +105 -0
  63. package/scripts/pi-durable-receive-host.mjs +127 -0
  64. package/scripts/pi-durable-send-host.mjs +213 -0
  65. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  66. package/scripts/smoke-herdr-fresh-call-live.ts +21 -7
  67. package/scripts/smoke-pi-durable-fresh-live.ts +624 -0
  68. package/scripts/smoke-setup-verdict.sh +21 -2
  69. package/scripts/tsconfig.json +1 -0
@@ -48,6 +48,7 @@ Source of truth: `pi-extensions/lib/acp/backend-adapter.ts`.
48
48
  | `buildSessionMeta` | Build optional `newSession._meta` from the already-loaded carrier. |
49
49
  | `enforceModel` | Make the requested native model authoritative before the prompt. |
50
50
  | `configSignatureFields` | Return a stable primitive map whose changes invalidate reuse. |
51
+ | `readPromptRejection?` | Name a backend-owned typed prompt rejection and its fixed app-policy verdict; absent means the existing verbatim-first failure path. |
51
52
 
52
53
  `backend.ts` resolves the adapter once at turn entry. Common config never branches on
53
54
  backend-specific keys; `adapterSettings` remains opaque until handed back to its owner.
@@ -85,21 +86,58 @@ turned one long turn into four in 0.13.0. Gates: `check-acp-prompt-lifecycle` (b
85
86
  with pi's own classifier as the oracle), `check-probe-ordering` (no production prompt
86
87
  cutoff in source).
87
88
 
89
+ ### Typed Claude prompt rejection — no automatic whole-prompt replay (#127)
90
+
91
+ Claude ACP 0.85.1 can reject a prompt with the wire SDK's `RequestError`, code `-32603`,
92
+ `data.errorKind === "incomplete_tool_call"`, after marking unfinished foreground tools
93
+ failed. Some tools may already have run. This is an RPC failure, not a successful
94
+ `stopReason`, and a failed tool notification alone is not a turn settlement. The code
95
+ is JSON-RPC's internal error; the kind is Claude's extension vocabulary, not a standard
96
+ retry permission. AIR session-failure metadata remains capability-gated off by
97
+ `clientCapabilities: {}`.
98
+
99
+ Only the Claude adapter declares `readPromptRejection`. The common loop passes the
100
+ once-resolved adapter at the two prompt catches, not at bootstrap failures, and skips
101
+ this reader when the operator's abort wins. A recognized rejection seals once as
102
+ `error/error`, drops the new or reused session under the existing policy, and sets a
103
+ fixed semantic `errorMessage` which pi's real retry **and** overflow classifiers must
104
+ reject. No global retry disable, substring sanitizer, or new failure ledger is added.
105
+
106
+ The first raw failure remains in pi's existing `AssistantMessage.diagnostics`: original
107
+ name/message/stack/code, serialized data, and applicable stderr tail, lifecycle, launch
108
+ signal and overflow hint. None is remixed into the semantic verdict. Actual wire data
109
+ is parsed JSON; a hand-built non-wire value that cannot serialize gets an explicit
110
+ unserializable marker rather than escaping before the stream seal. Assistant content,
111
+ context replay and reuse signatures remain unchanged; persisted message JSON gains
112
+ these diagnostics. Cortex, other error kinds/codes, preprompt failures and aborts keep
113
+ the existing path. No warm reuse after this failure is newly claimed.
114
+
115
+ `check-acp-vendor-rejection` owns the source-adjacent adapter/pipeline assertions and
116
+ four defect mutants. Its real classifier oracles, raw positive controls, failed-tool
117
+ ordering, exact-once seal/drop, raw evidence and separate cooperative-cancel cells do
118
+ not certify a real vendor runtime. Target closure installation, focused receipts,
119
+ qualification, full floor and LIVE compatibility remain separate in the
120
+ [#127 thread](https://github.com/junghan0611/entwurf/issues/127).
121
+
88
122
  ## Support contract (#81)
89
123
 
90
124
  What "supported" means here, per declaration class. The classes are kept apart on purpose: one
91
125
  undifferentiated "supported" column is what let a Claude PASS read as if it also certified Cortex.
92
126
  The rows are THIS checkout's declarations, not a shipped release: what a published version carried
93
127
  is that version's CHANGELOG entry, and a candidate's own evidence is its issue thread, LIVE receipts
94
- and exact-SHA CI (#125 for the Pi 1.0 candidate).
128
+ and exact-SHA CI. The pi row shipped in 0.30.0 (#125). The ACP wire SDK, Claude adapter and Claude
129
+ Agent SDK rows are the unpublished #127 candidate: published 0.30.0 still declares
130
+ `claude-agent-acp 0.79.0` and `@agentclientprotocol/sdk 1.4.0`
131
+ (`[측정 2026-10-04]` `npm view @junghanacs/entwurf@0.30.0`; per-bump record in the ROADMAP Dep bump
132
+ ledger, 2026-10-03).
95
133
 
96
134
  | Surface | Declaration | Class | What a green actually says |
97
135
  |---|---|---|---|
98
136
  | Entwurf package | `package.json` `version` | checkout declaration, not a release receipt | names the package label these rows sit beside; whether that label shipped, and with which rows, is CHANGELOG's record |
99
- | pi runtime | devDep exact `1.0.0`, peer `>=1.0.0 <1.1` | **exact** oracle + **closed range** | the source pin this checkout builds and gates against; hosts inside the range are accepted, and the ceiling moves only on measurement. Its 1.0.0 evidence is #125's branch receipts, not a shipped release |
100
- | ACP wire SDK | `@agentclientprotocol/sdk 1.4.0` | **exact** | the shared wire oracle both adapters speak |
101
- | Claude ACP adapter | `@agentclientprotocol/claude-agent-acp 0.79.0` | **exact**, bundled | the adapter we ship and certify; resolved before any PATH fallback |
102
- | Claude Agent SDK | `0.3.274` (transitive) | **exact** oracle | the runtime risk surface behind the adapter |
137
+ | pi runtime | devDep exact `1.0.2`, peer `>=1.0.2 <1.1` | **exact** oracle + **closed range** | the source pin this checkout builds and gates against; hosts inside the range are accepted, and the ceiling moves only on measurement. Unshipped candidate floor: the published 0.30.0 and 0.30.1 declare floor 1.0.0 with the same next-minor ceiling (`[측정 2026-10-05]` `npm view @junghanacs/entwurf@0.30.1 peerDependencies`; acceptance record CHANGELOG 0.30.0, #125 branch evidence). The 1.0.2 move and its receipts are ROADMAP's 2026-10-05 bump entry |
138
+ | ACP wire SDK | `@agentclientprotocol/sdk 1.6.0` | **exact** | the shared wire oracle both adapters speak |
139
+ | Claude ACP adapter | `@agentclientprotocol/claude-agent-acp 0.85.1` | **exact**, bundled | checkout candidate; resolved before any PATH fallback; declaration is not compatibility evidence |
140
+ | Claude Agent SDK | `0.3.286` (transitive) | **exact** oracle | the runtime risk surface behind the adapter |
103
141
  | Anthropic SDK | `0.100.1` | **exact**, peer-resolution only | satisfies the Agent SDK peer floor (0.93.0+); never an API client here (gate L4) |
104
142
  | Claude Code runtime | `>=2.1.217` (`entwurf.claudeCodeFloor`) | **floor** | below it, hook args are silently dropped; entwurf enforces this itself |
105
143
  | Node | `>=24` (`engines.node`) | **floor** | single axis, derived everywhere else |
@@ -181,7 +219,7 @@ different reasons, and collapsing them would hide a real risk**:
181
219
  imported nowhere: `[측정 2026-09-18]` `git grep -c` over `pi-extensions/`, `test/`, `scripts/`,
182
220
  `mcp/` is **0**. We never imported the adapter as a library at all — we spawn its binary.
183
221
  - **0.77.0's `allowDangerouslySkipPermissions` opt-out is a new lever we deliberately do not
184
- pull, and our effective permission mode is unchanged.** 0.76.0 sent
222
+ pull, and at 0.79.0 our effective permission mode was unchanged.** 0.76.0 sent
185
223
  `allowDangerouslySkipPermissions: ALLOW_BYPASS` unconditionally and computed
186
224
  `initialPermissionMode = creationOpts.permissionMode ?? resolvePermissionMode(settings…)`.
187
225
  0.79.0 computes `allowBypass = ALLOW_BYPASS && sessionMeta?.claudeCode?.options?.allowDangerouslySkipPermissions !== false`
@@ -192,7 +230,9 @@ different reasons, and collapsing them would hide a real risk**:
192
230
  versions (`dist/permissions/modes.js` 직독). Our overlay pins
193
231
  `permissions.defaultMode: "bypassPermissions"` (`overlay.ts:122`), which resolves the same
194
232
  under both. The lever now EXISTS for a host that wants a non-bypass sibling; declaring it is a
195
- separate axis, not a one-line flip, and nothing in this bump takes it.
233
+ separate axis, not a one-line flip, and nothing in this bump takes it. That is the 0.79.0
234
+ measurement; at 0.85.1 project or managed settings can drop bypass (the 0.79.0 → 0.85.1 entry
235
+ below).
196
236
  - **0.77.0's system-reminder strip never touches our first-user-message augment.**
197
237
  `INJECTED_CONTEXT_MARKERS = ["system-reminder"]` joins the local-command markers in
198
238
  `stripMarkerTags`, and `stripLocalCommandMetadata` has exactly two call sites
@@ -247,6 +287,51 @@ different reasons, and collapsing them would hide a real risk**:
247
287
  is byte-identical (`md5 cfd031d0…` both versions). The `settings`-as-STRING-PATH branch our
248
288
  call-site contract names is still on the unconditional `session/new` path
249
289
  (`dist/acp-agent.js:6010-6012`).
290
+ - **0.79.0 → 0.85.1 (#127) is a source classification, not a runtime certification.**
291
+ `[측정 2026-10-03, tag v0.85.1 = 686c0c99, src and dist read directly; nothing executed]`.
292
+ Coordinates and evidence classes are in the ROADMAP Dep bump entry for 2026-10-03. The typed
293
+ rejection above is the only failure-policy change Entwurf wrote for this bump. The reachable
294
+ upstream changes below are classified, not new Entwurf contracts:
295
+ - **Permission mode is conditional (#1165, reachable on a host/project basis).** The adapter
296
+ builds its own `SettingsManager(params.cwd, …)` over `claudeConfigDir/settings.json`, the
297
+ cwd's `.claude/settings.json` and `settings.local.json`, and the managed path, then calls
298
+ `resolveSettings({ cwd })` without a `settingSources` argument. That call flow is separate
299
+ from our SDK query's `settingSources: []` (`tool-surface.ts:152`). When those settings carry
300
+ `disableBypassPermissionsMode: "disable"`, `allowBypass` drops, more permission requests reach
301
+ our approve-all-by-kind handler, and `[permission:approved]` notices appear. The SDK's
302
+ default `resolveSettings` source set is UNREAD, and no gate covers this case.
303
+ - **Reachable, classified from source only.**
304
+ - `createSession` starts a background `getContextUsage()` refresh (call at tag `85:8317`), and
305
+ does so again after a model change. Upstream comments (`85:8301`, `85:8350-8352`) describe
306
+ SDK control requests as serialized. The SDK internals are UNREAD, so whether our bounded
307
+ set-model and early-abort grace queue behind it is a lead, and its timing is UNRUN.
308
+ - A turn made only of `<synthetic>` frames emits no `usage_update` (#1132), and our
309
+ keep-prior occupancy seals the prior value.
310
+ - `tool_call_update` omits unchanged fields for every client (#1153). No producer was found
311
+ that sends a pre-terminal `rawOutput.isError`, so a tool failure still arrives as
312
+ `status: "failed"`.
313
+ - #1216's `teardownSession` stops awaiting the interrupt reply. It is reached only
314
+ indirectly, through dispose on stdin EOF or a signal: we never send `session/close`, and
315
+ our `session/cancel` still awaits.
316
+ - A sign-out during an unsettled turn fails it with `RequestError.authRequired()` and no
317
+ extra message (#1179, tag `85:4420`). The stock text is the SDK default
318
+ `Authentication required` (sdk 1.6.0 `jsonrpc.js:1032-1033`), and that message ALONE is not
319
+ transient to pi's real classifier. The sealed legacy `errorMessage` also carries the
320
+ stderr tail, lifecycle and hint (`backend.ts:1463-1490`), and a tail can still match the
321
+ retry pattern. `[측정 2026-10-04, coordinator classifier probe]`: `"Authentication required"`
322
+ → `retryable: false`; the same text plus `\n--- backend stderr (tail) ---\n500` →
323
+ `retryable: true`. #127 does not sanitize this path and certifies no auth runtime.
324
+ - The nine other vendor `internalError` sites keep the same legacy path. Their
325
+ `Internal error:` prefix can match the classifier, and that pre-existing policy is
326
+ unchanged here. This is a source and classifier reading, not a frequency claim and not a
327
+ complete-callee identity claim.
328
+ - **Capability-gated:** `notice`, the one new `sessionUpdate` literal (17 → 18 kinds).
329
+ - **Ambient only:** `CLAUDE_AGENT_ACP_EXPERIMENTAL_V2=1` selects a v1/v2 router. Entwurf
330
+ neither sets nor scrubs it, and the router's v1 path for a v1 client is UNRUN.
331
+ - **Entry identity is not settlement identity.** `newSession`, `prompt`,
332
+ `setSessionConfigOption`, `requestPermission`, `closeSession`, `sessionUsage`,
333
+ `turnQuotaMeta` and `quotaTokenCount` are byte-identical as extracted entry bodies. Turn
334
+ settlement moved inside the changed `runConsumer`.
250
335
 
251
336
  - **The one 0.73.0 → 0.75.1 change that DOES reach us:** context compaction is now surfaced as a
252
337
  synthetic ACP tool lifecycle (0.75.0, #991) — a `tool_call` with `kind: "think"`, title
@@ -373,10 +458,15 @@ caller-session `_meta`, and cross-machine certification.
373
458
 
374
459
  A backend can return `newSession` before its declared MCP server is callable. This was
375
460
  observed intermittently on the Claude rail and directly on Cortex's private `mcp.json`
376
- path. Neither `claude-agent-acp` 0.79.0 nor the Cortex landing adds a client-side
377
- readiness fence over a session's declared MCP servers, and entwurf's common loop
378
- calls `mcpServerStatus()` nowhere.
379
- (Re-measured at the 0.76.0 → 0.79.0 bump, not inherited — the previous bump's argument is
461
+ path. Entwurf's common loop calls `mcpServerStatus()` nowhere; it does not add a
462
+ client-side readiness fence over a session's declared MCP servers. The historical
463
+ 0.76.0 → 0.79.0 source measurement below is dated evidence, not a current-version
464
+ runtime claim. For the 0.85.1 candidate, #127 re-measured the MCP-authentication
465
+ window as byte-identical and ran the MCP/self/send LIVE samples; neither source
466
+ identity nor green samples establish the cause of the intermittent readiness gap
467
+ or close #72. Changed SDK internals and session initialization remain separate
468
+ risk surfaces.
469
+ (Historical measurement at the 0.76.0 → 0.79.0 bump, not inherited — the previous bump's argument is
380
470
  not reused, the way the 0.75.1 → 0.76.0 entry did not reuse 0.73.0 → 0.75.1's.
381
471
  `mcpServerStatus` call sites in `src/acp-agent.ts` are **2 at v0.76.0 and 2 at v0.79.0**
382
472
  `[측정 2026-09-18, upstream v0.79.0/src/acp-agent.ts read directly, grep -n]`; they first
@@ -392,7 +482,8 @@ empty) and the region moved +11 while the file shrank 10,405 → 10,329 lines. T
392
482
  agent-picker removal, the 0.78.0 compaction/checkpoint/AIR work and the 0.79.0 permission
393
483
  presentation touch no part of this path, so the other reachable-surface findings stand as
394
484
  re-measured in the capability-posture section above.
395
- This bump changes no readiness behavior and closes no part of #72.)
485
+ That historical source measurement closes no part of #72; it is not a claim that
486
+ all effective readiness behavior stayed unchanged in the 0.85.1 runtime.)
396
487
 
397
488
  ### 11-7-a/b. Instrument and first measurement
398
489
 
@@ -500,12 +591,14 @@ nothing here is promised and then quietly not delivered. `[QK:PI-QUEUE-NO-MIDFLI
500
591
  while a turn is in flight never joins that turn and arrives as a later one. The delay's SIZE is
501
592
  that turn's duration, which the same cell fixes by construction.
502
593
 
503
- **Forwarding is not adopted here.** `[측정 2026-09-22]` standard ACP has no steer method at all —
504
- enumerating the installed `@agentclientprotocol/sdk`'s `AGENT_METHODS` and `CLIENT_METHODS` yields
505
- zero entries containing `steer`. What exists is a PRIVATE vendor extension,
506
- `_session/steering` in `@agentclientprotocol/claude-agent-acp@0.79.0`
507
- (`dist/acp-agent.js:163`, handler at `:7792`), client→agent, advertised through
508
- `InitializeResponse._meta.steering.supported`. Our client sends `initialize`, `session/new`,
594
+ **Forwarding is not adopted here.** The dated `[측정 2026-09-22]` SDK-method enumeration
595
+ and private-extension observation below belong to the then-installed oracle, not a
596
+ 0.85.1 runtime measurement: `AGENT_METHODS` and `CLIENT_METHODS` contained zero entries
597
+ matching `steer`; `_session/steering` was a PRIVATE vendor extension in
598
+ `@agentclientprotocol/claude-agent-acp@0.79.0` (`dist/acp-agent.js:163`, handler at `:7792`),
599
+ client→agent, advertised through `InitializeResponse._meta.steering.supported`.
600
+ The current boundary is our client's lack of a steering path, not a claim that the
601
+ old vendor coordinates describe the current pin. Our client sends `initialize`, `session/new`,
509
602
  `session/prompt`, `session/set_config_option` and `session/cancel` (`acp-client.ts:138-149`) and
510
603
  has no steering path at all.
511
604
 
@@ -28,7 +28,8 @@ evidence split, and its three states must not collapse into "macOS is supported"
28
28
  | npm/pnpm | npm is bundled with Node; pnpm is required for source setup | package or source installation |
29
29
  | Python 3 | required by `setup`/`install` (project-path normalization + settings writers); `--help`/`check-bridge` stay Python-free | pi/Claude/agy/Copilot/Codex wiring writers |
30
30
  | entwurf | global/project-local `@junghanacs/entwurf`, or a source checkout | operator command and garden capability |
31
- | pi | optional-by-presence, `>=1.0.0 <1.1` — absent is an explicit setup SKIP, below-floor is a named FAIL | ACP provider, control sockets |
31
+ | pi | optional-by-presence, `>=1.0.2 <1.1` — absent is an explicit setup SKIP, below-floor is a named FAIL | ACP provider, control sockets |
32
+ | Native durable (`pi-durable`) | optional-by-presence, operator-provided experimental source runtime at the fixed `…/entwurf/pi-durable/runtime`, pinned to `cd32f77` plus the narrow runtime-contact overlay; not supplied by ordinary Pi npm installation — absent is an explicit setup SKIP, present-but-not-the-pin a named FAIL | optional 0.31.0 candidate contact via `entwurf pi-durable`; first native Pi ↔ pi-durable admission/LIVE accepted on one Linux host, runtime provision and release acceptance separate (§2b) |
32
33
  | Claude Code | optional, **`>=2.1.217`** — the exec-form hook floor | Claude ACP auth/runtime and mailbox-backed native citizen |
33
34
  | GitHub Copilot CLI | optional-by-presence, operator-installed and authenticated — absent is an explicit setup SKIP; detected composes all four units (birth/MCP/receiver/footer) | self-fetch citizen and visible fresh |
34
35
  | OMP (`omp`) | optional-by-presence, operator-installed — absent is an explicit setup SKIP; detected composes all four units (birth/MCP/`tools.xdev` setting/receiver) | self-fetch citizen and visible fresh (accepted on one host — see §4b) |
@@ -190,7 +191,7 @@ on Darwin; that is wiring, not a rail receipt. If `pi` is on PATH,
190
191
  Install the exact release floor, then wire the project:
191
192
 
192
193
  ```bash
193
- npm install -g @earendil-works/pi-coding-agent@1.0.0
194
+ npm install -g @earendil-works/pi-coding-agent@1.0.2
194
195
  pi --version
195
196
 
196
197
  cd ~/entwurf-smoke
@@ -198,7 +199,7 @@ entwurf install .
198
199
  pi -e "$(npm root -g)/@junghanacs/entwurf" --list-models entwurf
199
200
  ```
200
201
 
201
- The supported range is `>=1.0.0 <1.1`. It is a hard minimum: installing this
202
+ The supported range is `>=1.0.2 <1.1`. It is a hard minimum: installing this
202
203
  release onto a 0.83.x pi host upgrades the runtime rather than keeping the older
203
204
  minor. A host using only the external MCP bridge can skip pi until it needs a
204
205
  control socket; no delivery rail launches a pi process.
@@ -282,6 +283,105 @@ emacsclient -s "${PI_EMACS_AGENT_SOCKET:-server}" --eval '(...)'
282
283
 
283
284
  </details>
284
285
 
286
+ ## 2b. Optional native durable (`pi-durable`)
287
+
288
+ **0.31.0 installation contract; first native admission/LIVE accepted on one Linux host.**
289
+ The [DELIVERY matrix](../DELIVERY.md) records the 2026-10-06 LIVE36 candidate
290
+ acceptance; release/artifact and runtime-supply evidence remain separate.
291
+ Durable is a separate native harness contact, not an option automatically enabled
292
+ by `entwurf pi` or by registering ordinary Pi's adapter.
293
+
294
+ There are three different things to install or supply:
295
+
296
+ 1. **Entwurf:** the existing global/project-local npm or source-checkout route in
297
+ §1 stays unchanged. It supplies Entwurf bytes, not the native harness.
298
+ 2. **Durable library:** `@earendil-works/pi-durable` is npm-installable. A library
299
+ dependency does not install the experimental coding app/TUI or birth a citizen.
300
+ 3. **Native app/runtime:** upstream's experimental durable app runs from a Pi
301
+ source checkout through its source resolver. The pinned 1.0.2 ordinary
302
+ `pi-coding-agent` package excludes experimental app output; upgrading the global
303
+ `pi` CLI does not supply it. Entwurf's maintained contact requires the exact
304
+ upstream commit `cd32f7725fdbddbaecdff5b1e68491563394e0ca` plus its narrow
305
+ runtime-contact overlay, operator-provided dependencies and pinned runtime data.
306
+
307
+ The upstream [library installation guide](https://github.com/earendil-works/pi/blob/cd32f7725fdbddbaecdff5b1e68491563394e0ca/packages/durable/README.md#installation)
308
+ and [experimental app guide](https://github.com/earendil-works/pi/blob/cd32f7725fdbddbaecdff5b1e68491563394e0ca/packages/coding-agent/src/experimental/durable/README.md)
309
+ describe different consumers. Running the bare upstream app does not attach
310
+ Entwurf's garden contact. Entwurf does not install a second harness distribution,
311
+ rebuild its controller/model/auth/storage system or proxy a subscription.
312
+
313
+ ### What the operator supplies
314
+
315
+ The runtime lives at ONE fixed place, with no path override:
316
+ `$XDG_DATA_HOME/entwurf/pi-durable/runtime` (an absolute `XDG_DATA_HOME`; otherwise
317
+ `~/.local/share/entwurf/pi-durable/runtime`). It counts as the pin only when:
318
+
319
+ - HEAD is `cd32f7725fdbddbaecdff5b1e68491563394e0ca`, nothing is staged, `git diff`
320
+ equals the packaged overlay `pi/pi-durable/overlay/runtime-contacts.patch` byte for
321
+ byte, and no non-ignored file is untracked;
322
+ - the upstream source resolver
323
+ `packages/coding-agent/src/experimental/source-resolver.ts` is present;
324
+ - the git-ignored model data in `packages/ai/src/providers/data` matches the pinned
325
+ digest: the bytes `@earendil-works/pi-ai@1.0.2` ships in `dist/providers/data`,
326
+ **including its `.manifest.json` dotfile** (recorded in
327
+ `pi/pi-durable/overlay/upstream-pin.json`).
328
+
329
+ The operator supplies it by hand; Entwurf never downloads, installs, repairs,
330
+ moves or deletes the runtime. The sequence below was run on a Linux host
331
+ (2026-10-06); afterwards the runtime verifier `entwurf setup` consults returned
332
+ the pin (exit 0) and the checkout's native contact cells passed against it.
333
+ `ENTWURF_ROOT` is your Entwurf package root or checkout, which ships the patch:
334
+
335
+ ```bash
336
+ case "${XDG_DATA_HOME:-}" in
337
+ /*) R="$XDG_DATA_HOME/entwurf/pi-durable/runtime" ;;
338
+ *) R="$HOME/.local/share/entwurf/pi-durable/runtime" ;;
339
+ esac
340
+ git clone --no-checkout https://github.com/earendil-works/pi.git "$R"
341
+ git -C "$R" checkout --detach cd32f7725fdbddbaecdff5b1e68491563394e0ca
342
+ git -C "$R" apply "$ENTWURF_ROOT/pi/pi-durable/overlay/runtime-contacts.patch" # worktree only; do not stage
343
+ (cd "$R" && npm ci --ignore-scripts) # upstream lockfile; dependency install scripts skipped
344
+ T=$(mktemp -d)
345
+ (cd "$T" && npm pack @earendil-works/pi-ai@1.0.2)
346
+ tar -xzf "$T/earendil-works-pi-ai-1.0.2.tgz" -C "$T" package/dist/providers/data
347
+ cp -a "$T/package/dist/providers/data/." "$R/packages/ai/src/providers/data/" # dotfiles too
348
+ entwurf setup # expect: pi-durable PASS
349
+ ```
350
+
351
+ Why it is a source checkout and not `npm install`: `@earendil-works/pi-coding-agent`
352
+ (1.0.2 and 1.0.4 alike) excludes `dist/experimental` from its `files`, and its only
353
+ experimental export resolves to `src/`, which the tarball does not ship, so the
354
+ durable app's `experimental/durable/runtime.ts` and `tui.ts` exist only in a source
355
+ checkout. `@earendil-works/pi-durable` on npm is the library (no app, TUI or bin).
356
+
357
+ ### What Entwurf does with it
358
+
359
+ - `entwurf setup` reports a `pi-durable` row from that check and writes no durable
360
+ wiring in any case: **PASS** when the runtime is the pin, **SKIP** when nothing is
361
+ at the fixed place, **FAIL** with the named refusal when something is there but is
362
+ not the pin.
363
+ - `entwurf pi-durable [args…]` is the managed launch. It verifies the same runtime
364
+ first (refusal exit 4 when absent, 3 otherwise), then runs the packaged bootstrap
365
+ `pi/pi-durable/bootstrap.mjs` under the runtime's source resolver, owning this
366
+ terminal. The bootstrap imports only the compiled closure shipped beside it
367
+ (`mcp/entwurf-bridge/dist/…`), so an installed package never imports checkout
368
+ TypeScript; a source checkout needs a built bridge (`pnpm run build-bridge`) first.
369
+ `plugins/pi-durable/bootstrap.mjs` is only the checkout path that re-exports it.
370
+
371
+ Existing users who do not enable durable keep the same installation steps. For
372
+ durable, native Pi owns credentials, model settings, SQLite sessions and recovery;
373
+ keep them in place rather than copying credentials or migrating ordinary Pi
374
+ transcripts. Native recovery does not prove exactly-once Entwurf dispatch or inbox
375
+ read recovery. The contact provides garden attachment, visible identity and
376
+ announce-only root-directed self-fetch. First native admission closed
377
+ [#129](https://github.com/junghan0611/entwurf/issues/129) at reviewed main:
378
+ visible fresh/callback, addressed receive and both real Pi ↔ pi-durable LIVE legs
379
+ passed on one Linux host. Installed-bootstrap import-only proof is not a native
380
+ runtime/TUI launch or a live Claude ↔ pi-durable receipt. This section states the
381
+ installation contract; exact release/artifact acceptance is a separate floor.
382
+ [#130](https://github.com/junghan0611/entwurf/issues/130) owns npm-only runtime
383
+ supply, later Pi versions, ACP and UI follow-ups.
384
+
285
385
  ## 3. Optional Claude Code native citizen
286
386
 
287
387
  This rail is CERTIFIED on Linux; on macOS it is NOT CERTIFIED — pending physical
@@ -445,7 +545,7 @@ as a note rather than a fault.
445
545
 
446
546
  **entwurf sets no OMP version floor in code, and will not grow one on schedule.** Detection is
447
547
  presence-only (`command -v "${OMP_BIN:-omp}"`); there is no `entwurf.ompFloor`, no coherence
448
- gate, and no exact pin — unlike Node (`engines.node`), pi (`>=1.0.0 <1.1`) and Claude Code
548
+ gate, and no exact pin — unlike Node (`engines.node`), pi (`>=1.0.2 <1.1`) and Claude Code
449
549
  (`entwurf.claudeCodeFloor`), each of which has an enforcement point. That asymmetry is a
450
550
  decision, not an omission. A floor is the answer to a vendor that fails SILENTLY — Claude Code
451
551
  earned one because an older binary validates the exec manifest, drops `args` at runtime, and
@@ -630,8 +630,8 @@ server.tool("entwurf_register_native", "Register an ALREADY-RUNNING native conve
630
630
  server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling beside you and hand it a first task. WHERE it opens is decided by where THIS " +
631
631
  "agent runs, never by a parameter: inside herdr (HERDR_ENV=1) it opens a NEW UNFOCUSED TAB in your own " +
632
632
  "herdr workspace, pi and claude-code " +
633
- "only; everywhere else it opens in the operator's tmux with all five backends (pi, " +
634
- "claude-code, copilot, omp, codex). No fallback: an incomplete herdr context is refused by name, " +
633
+ "only; everywhere else it opens in the operator's tmux with all six backends (pi, " +
634
+ "claude-code, copilot, omp, codex, pi-durable). No fallback: an incomplete herdr context is refused by name, " +
635
635
  "never by a tmux window you cannot see from herdr. The sibling's FIRST action " +
636
636
  "is a callback to you carrying a nonce, whose sender envelope is its garden id — that is how you learn the " +
637
637
  "address of a thing that did not exist a moment ago. This returns a LAUNCH receipt (the owner's " +
@@ -639,8 +639,8 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling beside you and
639
639
  "the task was delivered. Those coordinates are a VIEW, never an address — a herdr tab/pane id can change under " +
640
640
  "a running sibling. Nothing polls. " +
641
641
  "For EXISTING citizens use entwurf_v2 — this tool only creates, and entwurf_peers only reports. Model is REQUIRED and passed to the chosen " +
642
- "runtime CLI (`provider/model` for pi, an id/alias for Claude Code, a pattern for the rest). On tmux, copilot/omp/codex are refused BEFORE any window " +
643
- "opens when their birth, MCP, receive or visible-identity units are absent, and codex also needs the " +
642
+ "runtime CLI (`provider/model` for pi, an id/alias for Claude Code, `provider/model id` exactly as the catalog names it for pi-durable, a pattern for the rest). On tmux, copilot/omp/codex/pi-durable are refused BEFORE any window " +
643
+ "opens when their birth, MCP, receive or visible-identity units are absent, pi-durable also when its operator-provided pinned runtime is absent or drifted, and codex also needs the " +
644
644
  "operator-owned app-server socket entwurf never starts; in herdr they are refused by name first. " +
645
645
  "Optional placement.tmuxSession is a TMUX-ONLY seat naming ONE EXISTING session and " +
646
646
  "ALWAYS wins; omitted, the seat follows the CALLER, never the backend opened: a CODEX CALLER opens beside " +
@@ -648,8 +648,8 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling beside you and
648
648
  "NOTHING is created. In herdr the field is refused by name. Do not put secrets in the task — model and task argv are visible to same-user " +
649
649
  "processes.", {
650
650
  backend: z
651
- .enum(["pi", "claude-code", "copilot", "omp", "codex"])
652
- .describe("Which fixed runtime to open. Only these five, and only pi/claude-code when this agent runs inside herdr; there is no arbitrary command."),
651
+ .enum(["pi", "claude-code", "copilot", "omp", "codex", "pi-durable"])
652
+ .describe("Which fixed runtime to open. Only these six, and only pi/claude-code when this agent runs inside herdr; there is no arbitrary command."),
653
653
  model: z
654
654
  .string()
655
655
  .min(1)
@@ -663,7 +663,7 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling beside you and
663
663
  // as an unused suppression while the escape goes back to being fixable.
664
664
  // biome-ignore lint/complexity/noUselessEscapeInRegex: emitted to a Rust regex validator, see above
665
665
  .regex(/^[A-Za-z0-9][A-Za-z0-9._/:\[\]-]*$/)
666
- .describe("Required runtime model: canonical provider/model for pi, a Claude Code model id/alias, or a Copilot/OMP/Codex model name."),
666
+ .describe("Required runtime model: canonical provider/model for pi, provider/<exact catalog model id> for pi-durable, a Claude Code model id/alias, or a Copilot/OMP/Codex model name."),
667
667
  task: z
668
668
  .string()
669
669
  .min(1)
@@ -6,6 +6,7 @@
6
6
  "codex": { "wakeMode": "direct-inject", "deliveryLevel": "D6", "nativeIdLabel": "threadId" },
7
7
  "copilot": { "wakeMode": "self-fetch", "deliveryLevel": "D6", "nativeIdLabel": "sessionId" },
8
8
  "omp": { "wakeMode": "self-fetch", "deliveryLevel": "D6", "nativeIdLabel": "sessionId" },
9
+ "pi-durable": { "wakeMode": "self-fetch", "deliveryLevel": "D2", "nativeIdLabel": "sessionId" },
9
10
  "pi": { "wakeMode": "direct-inject", "deliveryLevel": "D6", "nativeIdLabel": "sessionId" }
10
11
  }
11
12
  }
@@ -20,6 +20,7 @@
20
20
  import { createRequire } from "node:module";
21
21
  import { homedir } from "node:os";
22
22
  import { fileURLToPath } from "node:url";
23
+ import { RequestError } from "@agentclientprotocol/sdk";
23
24
  import { enrichMcpServersWithEnvelope } from "./config.js";
24
25
  import { loadEngraving } from "./engraving.js";
25
26
  import { CORTEX_MODEL_PREFIX, curatedClaudeModels, curatedCortexModels, SUPPORTED_ANTHROPIC_MODEL_IDS, SUPPORTED_CORTEX_MODEL_IDS, } from "./models.js";
@@ -34,10 +35,46 @@ import { buildClaudeSessionMeta } from "./tool-surface.js";
34
35
  function shellQuote(value) {
35
36
  return `'${value.replace(/'/g, `'\\''`)}'`;
36
37
  }
38
+ /** The pi diagnostic type under which backend.ts preserves a recognised rejection verbatim. */
39
+ export const ACP_PROMPT_REJECTION_DIAGNOSTIC = "entwurf_acp_prompt_rejection";
40
+ /** The verdict for claude-agent-acp's `incomplete_tool_call` (#127; upstream #1212, 0.85.0+). */
41
+ export const INCOMPLETE_TOOL_CALL_VERDICT = "ACP turn failed because foreground tool results are incomplete. Some tools may already have run; automatic replay is unsafe. Inspect the preserved vendor rejection before continuing.";
37
42
  // ---------------------------------------------------------------------------
38
43
  // claude adapter — the first implementation (the rail's reference backend)
39
44
  // ---------------------------------------------------------------------------
40
45
  const SUPPORTED_CLAUDE_IDS = new Set(SUPPORTED_ANTHROPIC_MODEL_IDS);
46
+ /** JSON-RPC internal error — the code claude-agent-acp rejects an unfinished-tool turn with. */
47
+ const JSONRPC_INTERNAL_ERROR = -32603;
48
+ /** claude-agent-acp's own kind for a turn that hit `end_turn` with foreground tools unanswered
49
+ * (read at v0.85.1 `src/acp-agent.ts:4286-4340`, `errorKindData("incomplete_tool_call")`). */
50
+ const CLAUDE_INCOMPLETE_TOOL_CALL = "incomplete_tool_call";
51
+ /**
52
+ * The claude reader for `readPromptRejection` (#127). Only the root wire SDK's own decode
53
+ * counts: its client rejects a JSON-RPC error response with `new RequestError(code, message,
54
+ * data)`, `data` being the parsed `error.data` (sdk 1.4.0 `dist/jsonrpc.js:842-844`). A
55
+ * look-alike object, another code, or another `errorKind` is not this failure and keeps the
56
+ * verbatim-first path.
57
+ */
58
+ function readClaudePromptRejection(err) {
59
+ if (!(err instanceof RequestError) || err.code !== JSONRPC_INTERNAL_ERROR)
60
+ return undefined;
61
+ const data = err.data;
62
+ if (typeof data !== "object" || data === null || !("errorKind" in data))
63
+ return undefined;
64
+ if (data.errorKind !== CLAUDE_INCOMPLETE_TOOL_CALL)
65
+ return undefined;
66
+ return { errorKind: CLAUDE_INCOMPLETE_TOOL_CALL, verdict: INCOMPLETE_TOOL_CALL_VERDICT, dataJson: jsonText(data) };
67
+ }
68
+ /** Evidence text for a wire value. A value that came off the wire serializes; one that cannot
69
+ * (a hand-built fake) is named as such, so recording evidence never breaks the turn's seal. */
70
+ function jsonText(value) {
71
+ try {
72
+ return JSON.stringify(value) ?? String(value);
73
+ }
74
+ catch (err) {
75
+ return `[unserializable: ${err instanceof Error ? err.name : typeof err}]`;
76
+ }
77
+ }
41
78
  /**
42
79
  * Resolve the claude launch — an ENTWURF-OWNED launcher, or the env override for debug.
43
80
  * This is the single source for the claude launch spec; backend.ts holds no private copy.
@@ -139,6 +176,9 @@ export const claudeAdapter = {
139
176
  // The token partition is NOT declared here: ACP's only token carrier is a
140
177
  // per-turn round-trip aggregate, which is not what pi's four fields mean.
141
178
  sealsTurnAccounting: true,
179
+ // #127: claude-agent-acp 0.85.0+ rejects an `end_turn` with unanswered foreground tools as
180
+ // -32603 `incomplete_tool_call` (upstream #1212). Its raw text reads as transient to pi.
181
+ readPromptRejection: readClaudePromptRejection,
142
182
  };
143
183
  // ---------------------------------------------------------------------------
144
184
  // Registry — modelId → adapter
@@ -285,6 +325,9 @@ export const cortexAdapter = {
285
325
  // backend.ts seals nothing for a backend without this flag, so cortex's
286
326
  // emitted usage is byte-identical to what it was before #93. Declaring this
287
327
  // with a guess would mint exactly the silent misaccounting #93 exists to end.
328
+ // NO readPromptRejection — also deliberate. Nobody has read which failures cortex's ACP
329
+ // server rejects a prompt with, so every cortex failure keeps the verbatim-first
330
+ // errorMessage. Borrowing claude's `incomplete_tool_call` reader would be a guess.
288
331
  };
289
332
  const ADAPTERS = [claudeAdapter, cortexAdapter];
290
333
  /**
@@ -105,7 +105,13 @@ export function receiverMarkerMatchesIdentity(marker, identity) {
105
105
  // previous garden in-process on its `/new` edge (the unarm the claude hook lacked),
106
106
  // so the cell this join closes has a different owner there. Admitting it needs its
107
107
  // own measurement, not this file's optimism.
108
- export const SENDER_JOINED_RECEIVER_OWNER_KINDS = ["claude-code-cli"];
108
+ //
109
+ // `pi-durable-host` is admitted because the join holds by construction: the durable contact
110
+ // writes its sender marker under the host's own pid and arms its watch in that same process
111
+ // (`meta-bridge-pi-durable.ts`), so the receiver marker's `ownerPid` IS the sender-marker key.
112
+ // Inside the scope a host whose sender marker is gone or names another garden is a retired
113
+ // watch, which is the refusal this admission exists to make reachable.
114
+ export const SENDER_JOINED_RECEIVER_OWNER_KINDS = ["claude-code-cli", "pi-durable-host"];
109
115
  /** Does this watch owner share its pid with the backend's sender marker? */
110
116
  export function receiverOwnerKindJoinsSender(ownerKind) {
111
117
  return SENDER_JOINED_RECEIVER_OWNER_KINDS.includes(ownerKind);
@@ -23,11 +23,12 @@
23
23
  */
24
24
  import { randomBytes } from "node:crypto";
25
25
  /** The backends this rail can open. Fixed set, not a profile — a further one is a decision,
26
- * not a config entry. `copilot` was added by #82 RAIL 9, `omp` by #87 Bundle C, and `codex`
27
- * by #95 after its system birth and app-server rails were measured. The set is joined to the
26
+ * not a config entry. `copilot` was added by #82 RAIL 9, `omp` by #87 Bundle C, `codex`
27
+ * by #95 after its system birth and app-server rails were measured, and `pi-durable` by #129
28
+ * (the operator-provided durable app through the managed `entwurf pi-durable`). The set is joined to the
28
29
  * citizen backends by `check-harness-admission-parity`: a harness that mints records but is
29
30
  * missing HERE is not an unwired convenience, it is a release blocker. */
30
- export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot", "omp", "codex"];
31
+ export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot", "omp", "codex", "pi-durable"];
31
32
  /**
32
33
  * The callback tool NAME differs per backend and that is not cosmetic: every session reaches the
33
34
  * capability under whatever name its harness composes for the `entwurf-bridge` MCP server, and
@@ -64,6 +65,11 @@ export const FRESH_CALL_CALLBACK_TOOL = {
64
65
  // Codex's tool process is the operator app-server, not the pane: the no-arg
65
66
  // verb refuses there by name. First action stays the delivery verb with args.
66
67
  codex: "mcp__entwurf_bridge__entwurf_v2",
68
+ // The durable contact registers the bridge verbs as its OWN tools under their bare names, and the
69
+ // app offers a tool to the model by its registered name (`durable/harness/registry.ts:99`
70
+ // @cd32f77). Measured for `entwurf_v2` on the S+native-H wire (receipt 4012c066); the callback
71
+ // spelling follows the same rule and is owed its first LIVE observation.
72
+ "pi-durable": "entwurf_callback",
67
73
  };
68
74
  /** Where a sibling SENDS the task result. Always the delivery verb, never the birth callback. */
69
75
  export const FRESH_CALL_DELIVERY_TOOL = {
@@ -72,6 +78,7 @@ export const FRESH_CALL_DELIVERY_TOOL = {
72
78
  copilot: "entwurf-bridge-entwurf_v2",
73
79
  omp: "mcp__entwurf_bridge_entwurf_v",
74
80
  codex: "mcp__entwurf_bridge__entwurf_v2",
81
+ "pi-durable": "entwurf_v2",
75
82
  };
76
83
  /**
77
84
  * The same dialects, for the tool the framing OFFERS rather than requires.
@@ -93,6 +100,7 @@ export const FRESH_CALL_PEERS_TOOL = {
93
100
  copilot: "entwurf-bridge-entwurf_peers",
94
101
  omp: "mcp__entwurf_bridge_entwurf_peers",
95
102
  codex: "mcp__entwurf_bridge__entwurf_peers",
103
+ "pi-durable": "entwurf_peers",
96
104
  };
97
105
  /**
98
106
  * ONE BACKEND NEEDS TO BE TOLD HOW TO REACH ITS OWN CALLBACK TOOL `[측정 2026-09-18, n=5 + n=3]`.
@@ -132,6 +140,7 @@ export const FRESH_CALL_TOOL_LOAD_HINT = {
132
140
  copilot: [],
133
141
  omp: [],
134
142
  codex: [],
143
+ "pi-durable": [],
135
144
  };
136
145
  /**
137
146
  * The omp bootstrap flag, spelled WITHOUT dashes — the vendor's flag map is keyed by bare
@@ -311,6 +320,28 @@ export function composeBackendArgs(backend, composition, model, resolveCodexSock
311
320
  "--dangerously-bypass-approvals-and-sandbox",
312
321
  composition.prompt,
313
322
  ];
323
+ case "pi-durable": {
324
+ // The managed verb (`entwurf pi-durable`) carries the explicit model as pi's own
325
+ // `--provider`/`--model` pair, split at the FIRST `/` (the preflight already refused a model
326
+ // with no provider), the explicit width, and the WHOLE framing as the closed `{v,task}`
327
+ // payload — the durable bootstrap submits it to the root once, after the birth. The
328
+ // caller's address rides the env pair, never this payload.
329
+ const at = model.indexOf("/");
330
+ if (at <= 0 || at === model.length - 1) {
331
+ throw new Error(`fresh-call composition: pi-durable model ${JSON.stringify(model)} is not <provider>/<model id>`);
332
+ }
333
+ return [
334
+ "pi-durable",
335
+ "--provider",
336
+ model.slice(0, at),
337
+ "--model",
338
+ model.slice(at + 1),
339
+ "--width",
340
+ "task-wide",
341
+ `--${OMP_BOOTSTRAP_FLAG}`,
342
+ JSON.stringify({ v: OMP_BOOTSTRAP_VERSION, task: composition.prompt }),
343
+ ];
344
+ }
314
345
  }
315
346
  }
316
347
  /**
@@ -57,7 +57,13 @@ import { resolveNativePushAdapter } from "./native-push/adapter.js";
57
57
  * exactly the #46 defect on agy, and copilot joined the list only once its own hook wrote one
58
58
  * (#82 RAIL 5b). Membership says a marker may EXIST, never that a reply can land: the reply
59
59
  * rail is chosen from `nativePushSupported` at the bridge, not from this list. */
60
- export const META_SENDER_BACKENDS = ["claude-code", "antigravity", "copilot", "omp"];
60
+ export const META_SENDER_BACKENDS = [
61
+ "claude-code",
62
+ "antigravity",
63
+ "copilot",
64
+ "omp",
65
+ "pi-durable",
66
+ ];
61
67
  /**
62
68
  * Two live native sessions claim this MCP process as their own. We can SEE both identities but
63
69
  * cannot say which one is calling, so we send under neither. Choosing by lookup order, backend
@@ -76,7 +76,7 @@ export class MetaRecordError extends Error {
76
76
  * every write, including its own. `./run.sh doctor-meta-bridge` already flags a
77
77
  * stale deployed writer — run it after the change.
78
78
  */
79
- export const META_BACKENDS = ["claude-code", "antigravity", "codex", "copilot", "omp"];
79
+ export const META_BACKENDS = ["claude-code", "antigravity", "codex", "copilot", "omp", "pi-durable"];
80
80
  export const META_BACKEND_DESCRIPTORS = {
81
81
  "claude-code": {
82
82
  backend: "claude-code",
@@ -189,6 +189,24 @@ export const META_BACKEND_DESCRIPTORS = {
189
189
  deliveryLevel: "D6",
190
190
  nativeIdLabel: "sessionId",
191
191
  },
192
+ // The long-running durable app (#129). Graded only as far as its RECORDED receipts reach.
193
+ // sessionId the durable session directory name `<13-digit ms>-<uuid>` that
194
+ // `selectSession` creates and `--continue` reopens
195
+ // (`experimental/durable/sessions.ts:18-55` @cd32f77), surfaced as
196
+ // `view.current().session.id`. Not `pi.provider.sessionId`, which a
197
+ // fork changes.
198
+ // D2 receiver armed: the contact mints the record, names the host as sender, and arms
199
+ // a root doorbell (the receiver marker is owned by the host pid as `pi-durable-host`,
200
+ // which the owner join admits). The addressed-enqueue → idle-wake → root-notice → drain
201
+ // chain has ONE recorded receipt, on the scripted-model + native-harness lab cell
202
+ // (`check-pi-durable-receive`, S+H, V not run) — lab evidence, not a vendor-turn D4–D6
203
+ // acceptance, and no sibling-isolation (D3) or LIVE fresh receipt exists yet.
204
+ "pi-durable": {
205
+ backend: "pi-durable",
206
+ wakeMode: "self-fetch",
207
+ deliveryLevel: "D2",
208
+ nativeIdLabel: "sessionId",
209
+ },
192
210
  };
193
211
  // ---------------------------------------------------------------------------
194
212
  // Validation helpers (crash, don't warn)
@@ -261,7 +279,15 @@ function isoNow(now) {
261
279
  /** The one live identity schema number. */
262
280
  export const META_SCHEMA_VERSION_V3 = 3;
263
281
  /** Every backend admitted by the one V3 record-citizen schema. */
264
- export const META_CITIZEN_BACKENDS = ["claude-code", "antigravity", "codex", "copilot", "omp", "pi"];
282
+ export const META_CITIZEN_BACKENDS = [
283
+ "claude-code",
284
+ "antigravity",
285
+ "codex",
286
+ "copilot",
287
+ "omp",
288
+ "pi-durable",
289
+ "pi",
290
+ ];
265
291
  export function requireCitizenBackend(value) {
266
292
  if (typeof value !== "string" || !META_CITIZEN_BACKENDS.includes(value)) {
267
293
  throw new MetaRecordError(`meta-record "backend" must be one of ${META_CITIZEN_BACKENDS.join(" | ")} (got ${describe(value)}).`);