@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
@@ -21,6 +21,7 @@
21
21
  import { createRequire } from "node:module";
22
22
  import { homedir } from "node:os";
23
23
  import { fileURLToPath } from "node:url";
24
+ import { RequestError } from "@agentclientprotocol/sdk";
24
25
 
25
26
  import type { AcpConnectionLike } from "./acp-client.js";
26
27
  import { enrichMcpServersWithEnvelope, type ResolvedAcpConfig } from "./config.js";
@@ -235,14 +236,73 @@ export interface AcpBackendAdapter {
235
236
  * entwurf actually reads from a NOTIFICATION cannot desync from a response
236
237
  * shape it never inspects. */
237
238
  sealsTurnAccounting?: true;
239
+
240
+ /** OPTIONAL capability (#127): recognise a typed vendor rejection of `session/prompt`
241
+ * whose raw text must not become pi's `errorMessage`. pi decides a whole-prompt replay
242
+ * from `errorMessage` alone, and a vendor's "Internal error" — or any digit run in the
243
+ * ids it carries — reads as transient, so a turn whose tools already ran would be resent
244
+ * from a cold session. A recognised rejection returns a fixed, authored verdict for
245
+ * `errorMessage`; backend.ts keeps the raw rejection as a pi diagnostic instead.
246
+ *
247
+ * ABSENT means every failure of this backend keeps the verbatim-first `errorMessage`.
248
+ * The vocabulary is the vendor's, so only the adapter that speaks it may declare it. */
249
+ readPromptRejection?(err: unknown): AcpPromptRejection | undefined;
250
+ }
251
+
252
+ /** A vendor rejection an adapter recognised (`readPromptRejection`). */
253
+ export interface AcpPromptRejection {
254
+ /** The vendor's own failure kind, as it named it on the wire. */
255
+ errorKind: string;
256
+ /** Entwurf-authored and fixed — no vendor text, ids or numbers: this is what pi classifies. */
257
+ verdict: string;
258
+ /** The JSON-RPC `data`, re-serialized as evidence (it arrived as parsed JSON). */
259
+ dataJson: string;
238
260
  }
239
261
 
262
+ /** The pi diagnostic type under which backend.ts preserves a recognised rejection verbatim. */
263
+ export const ACP_PROMPT_REJECTION_DIAGNOSTIC = "entwurf_acp_prompt_rejection";
264
+
265
+ /** The verdict for claude-agent-acp's `incomplete_tool_call` (#127; upstream #1212, 0.85.0+). */
266
+ export const INCOMPLETE_TOOL_CALL_VERDICT =
267
+ "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.";
268
+
240
269
  // ---------------------------------------------------------------------------
241
270
  // claude adapter — the first implementation (the rail's reference backend)
242
271
  // ---------------------------------------------------------------------------
243
272
 
244
273
  const SUPPORTED_CLAUDE_IDS: ReadonlySet<string> = new Set(SUPPORTED_ANTHROPIC_MODEL_IDS);
245
274
 
275
+ /** JSON-RPC internal error — the code claude-agent-acp rejects an unfinished-tool turn with. */
276
+ const JSONRPC_INTERNAL_ERROR = -32603;
277
+ /** claude-agent-acp's own kind for a turn that hit `end_turn` with foreground tools unanswered
278
+ * (read at v0.85.1 `src/acp-agent.ts:4286-4340`, `errorKindData("incomplete_tool_call")`). */
279
+ const CLAUDE_INCOMPLETE_TOOL_CALL = "incomplete_tool_call";
280
+
281
+ /**
282
+ * The claude reader for `readPromptRejection` (#127). Only the root wire SDK's own decode
283
+ * counts: its client rejects a JSON-RPC error response with `new RequestError(code, message,
284
+ * data)`, `data` being the parsed `error.data` (sdk 1.4.0 `dist/jsonrpc.js:842-844`). A
285
+ * look-alike object, another code, or another `errorKind` is not this failure and keeps the
286
+ * verbatim-first path.
287
+ */
288
+ function readClaudePromptRejection(err: unknown): AcpPromptRejection | undefined {
289
+ if (!(err instanceof RequestError) || err.code !== JSONRPC_INTERNAL_ERROR) return undefined;
290
+ const data = err.data;
291
+ if (typeof data !== "object" || data === null || !("errorKind" in data)) return undefined;
292
+ if (data.errorKind !== CLAUDE_INCOMPLETE_TOOL_CALL) return undefined;
293
+ return { errorKind: CLAUDE_INCOMPLETE_TOOL_CALL, verdict: INCOMPLETE_TOOL_CALL_VERDICT, dataJson: jsonText(data) };
294
+ }
295
+
296
+ /** Evidence text for a wire value. A value that came off the wire serializes; one that cannot
297
+ * (a hand-built fake) is named as such, so recording evidence never breaks the turn's seal. */
298
+ function jsonText(value: unknown): string {
299
+ try {
300
+ return JSON.stringify(value) ?? String(value);
301
+ } catch (err) {
302
+ return `[unserializable: ${err instanceof Error ? err.name : typeof err}]`;
303
+ }
304
+ }
305
+
246
306
  /**
247
307
  * Resolve the claude launch — an ENTWURF-OWNED launcher, or the env override for debug.
248
308
  * This is the single source for the claude launch spec; backend.ts holds no private copy.
@@ -359,6 +419,10 @@ export const claudeAdapter: AcpBackendAdapter = {
359
419
  // The token partition is NOT declared here: ACP's only token carrier is a
360
420
  // per-turn round-trip aggregate, which is not what pi's four fields mean.
361
421
  sealsTurnAccounting: true,
422
+
423
+ // #127: claude-agent-acp 0.85.0+ rejects an `end_turn` with unanswered foreground tools as
424
+ // -32603 `incomplete_tool_call` (upstream #1212). Its raw text reads as transient to pi.
425
+ readPromptRejection: readClaudePromptRejection,
362
426
  };
363
427
 
364
428
  // ---------------------------------------------------------------------------
@@ -527,6 +591,10 @@ export const cortexAdapter: AcpBackendAdapter = {
527
591
  // backend.ts seals nothing for a backend without this flag, so cortex's
528
592
  // emitted usage is byte-identical to what it was before #93. Declaring this
529
593
  // with a guess would mint exactly the silent misaccounting #93 exists to end.
594
+
595
+ // NO readPromptRejection — also deliberate. Nobody has read which failures cortex's ACP
596
+ // server rejects a prompt with, so every cortex failure keeps the verbatim-first
597
+ // errorMessage. Borrowing claude's `incomplete_tool_call` reader would be a guess.
530
598
  };
531
599
 
532
600
  const ADAPTERS: readonly AcpBackendAdapter[] = [claudeAdapter, cortexAdapter];
@@ -45,7 +45,12 @@ import { readFileSync } from "node:fs";
45
45
  import { Readable, Writable } from "node:stream";
46
46
  import { ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
47
47
  import type { Api, AssistantMessage, Model, SimpleStreamOptions, TranscriptContext } from "@earendil-works/pi-ai";
48
- import { createAssistantMessageEventStream, getCurrentTools } from "@earendil-works/pi-ai";
48
+ import {
49
+ appendAssistantMessageDiagnostic,
50
+ createAssistantMessageDiagnostic,
51
+ createAssistantMessageEventStream,
52
+ getCurrentTools,
53
+ } from "@earendil-works/pi-ai";
49
54
  import {
50
55
  type AcpClientHandlers,
51
56
  type AcpConnectionLike,
@@ -53,7 +58,11 @@ import {
53
58
  connectAcpClient,
54
59
  } from "./acp-client.js";
55
60
  import { prependNewPromptAugment } from "./augment.js";
56
- import { type AcpBackendAdapter, resolveAcpBackendAdapter } from "./backend-adapter.js";
61
+ import {
62
+ ACP_PROMPT_REJECTION_DIAGNOSTIC,
63
+ type AcpBackendAdapter,
64
+ resolveAcpBackendAdapter,
65
+ } from "./backend-adapter.js";
57
66
  import {
58
67
  enrichMcpServersWithEnvelope,
59
68
  mcpServerNames,
@@ -1435,13 +1444,18 @@ export function streamAcpTurn(
1435
1444
  stderrTail?: string[],
1436
1445
  lifecycle?: string,
1437
1446
  launchObservation?: AcpLaunchObservation,
1447
+ /** Passed only from the two prompt-phase catches, where the turn's adapter is resolved. */
1448
+ adapter?: AcpBackendAdapter,
1438
1449
  ): void {
1439
1450
  finalizeAcpStreamState(state);
1440
1451
  state.output.stopReason = aborted ? "aborted" : "error";
1441
1452
  const base = err instanceof Error ? err.message : String(err);
1442
1453
  // The FIRST failure stays first and verbatim (it is what the backend
1443
1454
  // actually said); the lifecycle line is added, never substituted, so a
1444
- // reader can still match the transport's own text.
1455
+ // reader can still match the transport's own text. One typed exception
1456
+ // below: a vendor rejection the adapter recognises keeps that verbatim text
1457
+ // as a pi diagnostic instead, because pi reads errorMessage to decide a
1458
+ // whole-prompt replay (#127).
1445
1459
  const withLifecycle = lifecycle ? `${base}\n${lifecycle}` : base;
1446
1460
  // Its OWN line, above the vendor tail: an entwurf-owned observation must not
1447
1461
  // have to be recovered by reading vendor prose.
@@ -1453,7 +1467,28 @@ export function streamAcpTurn(
1453
1467
  // overflow gets an actionable hint appended, so "API Error" stops hiding
1454
1468
  // the turn-scoped full-transcript-replay cause.
1455
1469
  const hint = aborted ? undefined : actionableAcpBackendHint(full);
1456
- state.output.errorMessage = hint ? `${full}\n\n${hint}` : full;
1470
+ // #127: pi's retry and overflow classifiers read `errorMessage` ALONE, and a
1471
+ // vendor's "Internal error" or any digit run in its ids/stderr reads as transient
1472
+ // — a replay of a turn whose tools already ran. A rejection the adapter names gets
1473
+ // its fixed verdict there; every raw part rides one diagnostic, appended after any
1474
+ // existing ones. An abort keeps the abort path.
1475
+ const rejection = aborted ? undefined : adapter?.readPromptRejection?.(err);
1476
+ if (rejection) {
1477
+ appendAssistantMessageDiagnostic(
1478
+ state.output,
1479
+ createAssistantMessageDiagnostic(ACP_PROMPT_REJECTION_DIAGNOSTIC, err, {
1480
+ errorKind: rejection.errorKind,
1481
+ dataJson: rejection.dataJson,
1482
+ ...(lifecycle ? { lifecycle } : {}),
1483
+ ...(observed ? { launchSignal: observed } : {}),
1484
+ ...(tail ? { stderrTail: tail } : {}),
1485
+ ...(hint ? { hint } : {}),
1486
+ }),
1487
+ );
1488
+ state.output.errorMessage = rejection.verdict;
1489
+ } else {
1490
+ state.output.errorMessage = hint ? `${full}\n\n${hint}` : full;
1491
+ }
1457
1492
  stream.push({ type: "error", reason: aborted ? "aborted" : "error", error: state.output });
1458
1493
  stream.end();
1459
1494
  }
@@ -1915,6 +1950,7 @@ export function streamAcpTurn(
1915
1950
  session?.stderrTail ?? stderrTail,
1916
1951
  lifecycle,
1917
1952
  session?.launchObservation ?? launchObservation,
1953
+ adapter,
1918
1954
  );
1919
1955
  // error/abort → drop the (uncertain) session and close its child; an
1920
1956
  // uncertain connection must never be reused (GPT ④).
@@ -1982,7 +2018,7 @@ export function streamAcpTurn(
1982
2018
  // Without the session-scoped tail a mid-turn child death on a resident
1983
2019
  // session surfaced as a bare "ACP connection closed" with nothing to read
1984
2020
  // it by.
1985
- finishError(err, aborted, session.stderrTail, lifecycle, session.launchObservation);
2021
+ finishError(err, aborted, session.stderrTail, lifecycle, session.launchObservation, adapter);
1986
2022
  // error/abort on a reused session → drop it and close the child (GPT ④).
1987
2023
  session.retiring = true;
1988
2024
  retainedChildren.delete(session.child);
@@ -142,7 +142,13 @@ export function receiverMarkerMatchesIdentity(
142
142
  // previous garden in-process on its `/new` edge (the unarm the claude hook lacked),
143
143
  // so the cell this join closes has a different owner there. Admitting it needs its
144
144
  // own measurement, not this file's optimism.
145
- export const SENDER_JOINED_RECEIVER_OWNER_KINDS: readonly string[] = ["claude-code-cli"];
145
+ //
146
+ // `pi-durable-host` is admitted because the join holds by construction: the durable contact
147
+ // writes its sender marker under the host's own pid and arms its watch in that same process
148
+ // (`meta-bridge-pi-durable.ts`), so the receiver marker's `ownerPid` IS the sender-marker key.
149
+ // Inside the scope a host whose sender marker is gone or names another garden is a retired
150
+ // watch, which is the refusal this admission exists to make reachable.
151
+ export const SENDER_JOINED_RECEIVER_OWNER_KINDS: readonly string[] = ["claude-code-cli", "pi-durable-host"];
146
152
 
147
153
  /** Does this watch owner share its pid with the backend's sender marker? */
148
154
  export function receiverOwnerKindJoinsSender(ownerKind: string): boolean {
@@ -25,11 +25,12 @@
25
25
  import { randomBytes } from "node:crypto";
26
26
 
27
27
  /** The backends this rail can open. Fixed set, not a profile — a further one is a decision,
28
- * not a config entry. `copilot` was added by #82 RAIL 9, `omp` by #87 Bundle C, and `codex`
29
- * by #95 after its system birth and app-server rails were measured. The set is joined to the
28
+ * not a config entry. `copilot` was added by #82 RAIL 9, `omp` by #87 Bundle C, `codex`
29
+ * by #95 after its system birth and app-server rails were measured, and `pi-durable` by #129
30
+ * (the operator-provided durable app through the managed `entwurf pi-durable`). The set is joined to the
30
31
  * citizen backends by `check-harness-admission-parity`: a harness that mints records but is
31
32
  * missing HERE is not an unwired convenience, it is a release blocker. */
32
- export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot", "omp", "codex"] as const;
33
+ export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot", "omp", "codex", "pi-durable"] as const;
33
34
  export type FreshCallBackend = (typeof FRESH_CALL_BACKENDS)[number];
34
35
 
35
36
  /**
@@ -68,6 +69,11 @@ export const FRESH_CALL_CALLBACK_TOOL: Record<FreshCallBackend, string> = {
68
69
  // Codex's tool process is the operator app-server, not the pane: the no-arg
69
70
  // verb refuses there by name. First action stays the delivery verb with args.
70
71
  codex: "mcp__entwurf_bridge__entwurf_v2",
72
+ // The durable contact registers the bridge verbs as its OWN tools under their bare names, and the
73
+ // app offers a tool to the model by its registered name (`durable/harness/registry.ts:99`
74
+ // @cd32f77). Measured for `entwurf_v2` on the S+native-H wire (receipt 4012c066); the callback
75
+ // spelling follows the same rule and is owed its first LIVE observation.
76
+ "pi-durable": "entwurf_callback",
71
77
  };
72
78
 
73
79
  /** Where a sibling SENDS the task result. Always the delivery verb, never the birth callback. */
@@ -77,6 +83,7 @@ export const FRESH_CALL_DELIVERY_TOOL: Record<FreshCallBackend, string> = {
77
83
  copilot: "entwurf-bridge-entwurf_v2",
78
84
  omp: "mcp__entwurf_bridge_entwurf_v",
79
85
  codex: "mcp__entwurf_bridge__entwurf_v2",
86
+ "pi-durable": "entwurf_v2",
80
87
  };
81
88
 
82
89
  /**
@@ -99,6 +106,7 @@ export const FRESH_CALL_PEERS_TOOL: Record<FreshCallBackend, string> = {
99
106
  copilot: "entwurf-bridge-entwurf_peers",
100
107
  omp: "mcp__entwurf_bridge_entwurf_peers",
101
108
  codex: "mcp__entwurf_bridge__entwurf_peers",
109
+ "pi-durable": "entwurf_peers",
102
110
  };
103
111
 
104
112
  /**
@@ -139,13 +147,16 @@ export const FRESH_CALL_TOOL_LOAD_HINT: Record<FreshCallBackend, readonly string
139
147
  copilot: [],
140
148
  omp: [],
141
149
  codex: [],
150
+ "pi-durable": [],
142
151
  };
143
152
 
144
153
  /**
145
- * What a launch has to say, in the two shapes the five backends need. Four of them are
146
- * handed a first-turn PROMPT; omp is handed a bootstrap PAYLOAD its own installed extension
147
- * unpacks. Both are always built, because building one is cheap and a backend switch must
148
- * never be able to reach a field that was not composed.
154
+ * What a launch has to say, in the two shapes the six backends need. pi, claude-code, copilot
155
+ * and codex are handed the first-turn PROMPT as an argument; pi-durable is handed that same
156
+ * PROMPT inside the closed `{v,task}` payload on the bootstrap flag, which its packaged bootstrap
157
+ * decodes; omp is handed its own bootstrap PAYLOAD, which its installed extension unpacks. Both
158
+ * are always built, because building one is cheap and a backend switch must never be able to
159
+ * reach a field that was not composed.
149
160
  */
150
161
  export interface FreshCallComposition {
151
162
  prompt: string;
@@ -339,6 +350,30 @@ export function composeBackendArgs(
339
350
  "--dangerously-bypass-approvals-and-sandbox",
340
351
  composition.prompt,
341
352
  ];
353
+ case "pi-durable": {
354
+ // The managed verb (`entwurf pi-durable`) carries the explicit model as pi's own
355
+ // `--provider`/`--model` pair, split at the FIRST `/` (the preflight already refused a model
356
+ // with no provider), the explicit width, and the WHOLE framing as the closed `{v,task}`
357
+ // payload — the durable bootstrap submits it to the root once, after the birth. The
358
+ // caller's address rides the env pair, never this payload.
359
+ const at = model.indexOf("/");
360
+ if (at <= 0 || at === model.length - 1) {
361
+ throw new Error(
362
+ `fresh-call composition: pi-durable model ${JSON.stringify(model)} is not <provider>/<model id>`,
363
+ );
364
+ }
365
+ return [
366
+ "pi-durable",
367
+ "--provider",
368
+ model.slice(0, at),
369
+ "--model",
370
+ model.slice(at + 1),
371
+ "--width",
372
+ "task-wide",
373
+ `--${OMP_BOOTSTRAP_FLAG}`,
374
+ JSON.stringify({ v: OMP_BOOTSTRAP_VERSION, task: composition.prompt }),
375
+ ];
376
+ }
342
377
  }
343
378
  }
344
379
 
@@ -73,7 +73,13 @@ import { type NativePushAdapter, resolveNativePushAdapter } from "./native-push/
73
73
  * exactly the #46 defect on agy, and copilot joined the list only once its own hook wrote one
74
74
  * (#82 RAIL 5b). Membership says a marker may EXIST, never that a reply can land: the reply
75
75
  * rail is chosen from `nativePushSupported` at the bridge, not from this list. */
76
- export const META_SENDER_BACKENDS: readonly MetaBackend[] = ["claude-code", "antigravity", "copilot", "omp"];
76
+ export const META_SENDER_BACKENDS: readonly MetaBackend[] = [
77
+ "claude-code",
78
+ "antigravity",
79
+ "copilot",
80
+ "omp",
81
+ "pi-durable",
82
+ ];
77
83
 
78
84
  /** A marker that passed BOTH guards, together with the record that vouches for it. */
79
85
  export interface TrustedMetaSender {
@@ -81,7 +81,7 @@ export class MetaRecordError extends Error {
81
81
  * every write, including its own. `./run.sh doctor-meta-bridge` already flags a
82
82
  * stale deployed writer — run it after the change.
83
83
  */
84
- export const META_BACKENDS = ["claude-code", "antigravity", "codex", "copilot", "omp"] as const;
84
+ export const META_BACKENDS = ["claude-code", "antigravity", "codex", "copilot", "omp", "pi-durable"] as const;
85
85
  export type MetaBackend = (typeof META_BACKENDS)[number];
86
86
 
87
87
  /**
@@ -222,6 +222,24 @@ export const META_BACKEND_DESCRIPTORS: Record<MetaBackend, MetaBackendDescriptor
222
222
  deliveryLevel: "D6",
223
223
  nativeIdLabel: "sessionId",
224
224
  },
225
+ // The long-running durable app (#129). Graded only as far as its RECORDED receipts reach.
226
+ // sessionId the durable session directory name `<13-digit ms>-<uuid>` that
227
+ // `selectSession` creates and `--continue` reopens
228
+ // (`experimental/durable/sessions.ts:18-55` @cd32f77), surfaced as
229
+ // `view.current().session.id`. Not `pi.provider.sessionId`, which a
230
+ // fork changes.
231
+ // D2 receiver armed: the contact mints the record, names the host as sender, and arms
232
+ // a root doorbell (the receiver marker is owned by the host pid as `pi-durable-host`,
233
+ // which the owner join admits). The addressed-enqueue → idle-wake → root-notice → drain
234
+ // chain has ONE recorded receipt, on the scripted-model + native-harness lab cell
235
+ // (`check-pi-durable-receive`, S+H, V not run) — lab evidence, not a vendor-turn D4–D6
236
+ // acceptance, and no sibling-isolation (D3) or LIVE fresh receipt exists yet.
237
+ "pi-durable": {
238
+ backend: "pi-durable",
239
+ wakeMode: "self-fetch",
240
+ deliveryLevel: "D2",
241
+ nativeIdLabel: "sessionId",
242
+ },
225
243
  };
226
244
 
227
245
  // ---------------------------------------------------------------------------
@@ -305,7 +323,15 @@ function isoNow(now: Date): string {
305
323
  export const META_SCHEMA_VERSION_V3 = 3 as const;
306
324
 
307
325
  /** Every backend admitted by the one V3 record-citizen schema. */
308
- export const META_CITIZEN_BACKENDS = ["claude-code", "antigravity", "codex", "copilot", "omp", "pi"] as const;
326
+ export const META_CITIZEN_BACKENDS = [
327
+ "claude-code",
328
+ "antigravity",
329
+ "codex",
330
+ "copilot",
331
+ "omp",
332
+ "pi-durable",
333
+ "pi",
334
+ ] as const;
309
335
  export type MetaCitizenBackend = (typeof META_CITIZEN_BACKENDS)[number];
310
336
 
311
337
  /**
@@ -143,6 +143,12 @@ import {
143
143
  } from "./mux-placement.ts";
144
144
  import { resolveCodexDefaultSocketPath } from "./native-push/codex-ws-client.ts";
145
145
  import { OMP_PREFLIGHT_HINT, type OmpPreflightRejectReason, ompFreshPreflight } from "./omp-fresh-preflight.ts";
146
+ import {
147
+ PI_DURABLE_PREFLIGHT_HINT,
148
+ type PiDurablePreflightRejectReason,
149
+ piDurableFreshPreflight,
150
+ } from "./pi-durable-fresh-preflight.ts";
151
+ import { checkPiDurableRuntime, PI_DURABLE_RUNTIME_HINT, piDurablePackageLayout } from "./pi-durable-runtime.ts";
146
152
  import { classifyTmuxSessionName, resolveTmuxSessionId, type TmuxSessionRejectReason } from "./resolve-tmux-session.ts";
147
153
 
148
154
  /**
@@ -238,6 +244,10 @@ export const FRESH_CALL_RUNTIME: Record<FreshCallBackend, string> = {
238
244
  copilot: "entwurf",
239
245
  omp: "omp",
240
246
  codex: "codex",
247
+ // #129: like copilot, a MANAGED verb and never the bare app. The durable app is source-only
248
+ // and runs under the operator-provided runtime's own resolver; `entwurf pi-durable` is the one
249
+ // fixed invocation that verifies that runtime against the pin and execs the packaged bootstrap.
250
+ "pi-durable": "entwurf",
241
251
  };
242
252
 
243
253
  /** A launch that was refused, or a placement that could not be established. Every value is a
@@ -251,6 +261,7 @@ export type FreshCallRejectReason =
251
261
  | TmuxSessionRejectReason
252
262
  | CopilotPreflightRejectReason
253
263
  | OmpPreflightRejectReason
264
+ | PiDurablePreflightRejectReason
254
265
  | CodexPreflightRejectReason
255
266
  | CodexCallerPreflightRejectReason
256
267
  | CodexCallerSeatRejectReason
@@ -350,9 +361,9 @@ export type FreshCallResult = { ok: true; receipt: FreshCallReceipt } | { ok: fa
350
361
  * for. That is sufficient and not a compromise: every reader of the carrier trims and tests
351
362
  * truthiness (`index.ts:212-217`), so empty and absent are the same answer by construction.
352
363
  *
353
- * It is applied to all five backends because the leak is a property of tmux, not of a vendor. A
364
+ * It is applied to all six backends because the leak is a property of tmux, not of a vendor. A
354
365
  * scrub only on the backend whose measurement surfaced it would encode the claim that the other
355
- * four are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
366
+ * five are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
356
367
  * authoritative when the process that owns it exported it ITSELF, and a fresh `pi` sibling does
357
368
  * exactly that after this argv has run. This is a fixed seam and deliberately NOT a general env
358
369
  * carrier — an arbitrary `-e` passthrough would hand callers the environment-shaping power this
@@ -506,6 +517,25 @@ export function freshCall(
506
517
  const missing = ompFreshPreflight(env);
507
518
  if (missing) return { ok: false, reason: missing };
508
519
  }
520
+ if (params.backend === "pi-durable") {
521
+ // The framed first input is computed here only to MEASURE it against the bootstrap payload
522
+ // cap; the same pure function composes it again below. The runtime is located and verified
523
+ // with THIS process's env, before anything is placed. That is the bridge's env, not
524
+ // necessarily the window's: a new-window pane inherits the tmux SERVER's environment
525
+ // (measured, see SCRUBBED_INHERITED_ENV), and the window runs whatever `entwurf` PATH names.
526
+ // The managed verb re-verifies with its own env before it execs anything, so the window never
527
+ // runs an unverified runtime — but when HOME/XDG_DATA_HOME or the package closure differ, this
528
+ // verdict and the window's can disagree, and the window is left showing its own refusal.
529
+ const layout = piDurablePackageLayout();
530
+ const runtime = layout === null ? null : checkPiDurableRuntime(env, layout);
531
+ const missing = piDurableFreshPreflight({
532
+ model,
533
+ firstInput: buildFreshCallPrompt({ backend: params.backend, task, callerGardenId, nonce }),
534
+ layout,
535
+ runtime: runtime === null || runtime.ok ? null : runtime.reason,
536
+ });
537
+ if (missing) return { ok: false, reason: missing };
538
+ }
509
539
  const inspected = inspectPlacement(env);
510
540
  if (!inspected.ok) return { ok: false, reason: inspected.reason };
511
541
  const placement = inspected.placement;
@@ -626,6 +656,8 @@ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
626
656
  // the sentence an operator reads cannot drift away from the predicate that produced it.
627
657
  ...COPILOT_PREFLIGHT_HINT,
628
658
  ...OMP_PREFLIGHT_HINT,
659
+ ...PI_DURABLE_RUNTIME_HINT,
660
+ ...PI_DURABLE_PREFLIGHT_HINT,
629
661
  ...CODEX_PREFLIGHT_HINT,
630
662
  ...CODEX_CALLER_PREFLIGHT_HINT,
631
663
  ...CODEX_CALLER_SEAT_HINT,
@@ -0,0 +1,113 @@
1
+ /**
2
+ * pi-durable-fresh-preflight — the pre-mutation check a pi-durable fresh call needs (#129 W, L6;
3
+ * `docs/adding-a-harness.md` step 9 clauses 2, 3 and 5).
4
+ *
5
+ * Same question as `omp-fresh-preflight.ts`, at the same moment: before `mux-fresh-call` mutates the
6
+ * operator's tmux session, is everything the fresh contract needs actually in place? A refusal HERE,
7
+ * before placement, leaves no window behind. A pass here does not promise the window's own pass: the
8
+ * managed verb re-verifies inside the window, whose env is the tmux SERVER's and whose `entwurf` is
9
+ * whatever PATH names, so under env or package-closure skew it can still refuse after the window
10
+ * exists — and that refusal is left showing in it.
11
+ *
12
+ * ── The inputs, first ──
13
+ *
14
+ * model the fresh-call model grammar already admits `/`, and a durable launch needs the
15
+ * provider spelled out (the bootstrap refuses a model resolved by inference), so
16
+ * the model here is `<provider>/<model id>` split at the FIRST `/` — a catalog id
17
+ * that itself contains `/` keeps it. A model with no provider part is refused.
18
+ * first input the whole framing (callback first, then the task) rides the bootstrap's closed
19
+ * `{v:2,task}` payload, whose decoder caps `task` at 16000 characters. A framed
20
+ * task over that cap would open a window whose bootstrap refuses its own argv.
21
+ *
22
+ * ── Then the runtime, then the four units ──
23
+ *
24
+ * runtime operator-provided at the fixed locator and verified against the pin
25
+ * (`pi-durable-runtime.ts`); passed in as that check's verdict.
26
+ * birth the compiled contact in this package's closure — it mints the record.
27
+ * MCP hand the compiled entwurf-bridge entry in the same closure — the contact spawns it.
28
+ * receive the compiled owner join in the same closure admits `pi-durable-host`; without
29
+ * it every dispatch to the citizen is the honest `mailbox-undeliverable`.
30
+ * visible identity the packaged bootstrap — the one that hands the TUI the view whose conversation
31
+ * labels carry the garden id.
32
+ *
33
+ * ── What this deliberately does NOT claim ──
34
+ *
35
+ * Filesystem truth about ONE package closure and ONE runtime directory. It does not prove the durable
36
+ * app opens, the bridge connects, the doorbell arms or a garden id renders — that is runtime truth for
37
+ * the clause 7 LIVE receipt. No spawn, no network, no mutation; every path arrives as an argument, so
38
+ * this leaf imports nothing but types.
39
+ */
40
+
41
+ import { existsSync, readFileSync } from "node:fs";
42
+ import type { PiDurablePackageLayout, PiDurableRuntimeRejectReason } from "./pi-durable-runtime.ts";
43
+
44
+ /** The bootstrap decoder's task cap (`meta-bridge-omp.ts` OMP_BOOTSTRAP_TASK_MAX_CHARS); the beside
45
+ * test holds the two equal. */
46
+ export const PI_DURABLE_FIRST_INPUT_MAX_CHARS = 16000;
47
+
48
+ export type PiDurablePreflightRejectReason =
49
+ | "pi-durable-model-not-provider-qualified"
50
+ | "pi-durable-first-input-too-long"
51
+ | PiDurableRuntimeRejectReason
52
+ | "pi-durable-birth-unit-missing"
53
+ | "pi-durable-mcp-hand-missing"
54
+ | "pi-durable-receive-unit-missing"
55
+ | "pi-durable-visible-identity-missing";
56
+
57
+ /** `<provider>/<model id>` at the FIRST `/`, or `null` when either side is empty. */
58
+ export function splitPiDurableModel(model: string): { provider: string; model: string } | null {
59
+ const at = model.indexOf("/");
60
+ if (at <= 0 || at === model.length - 1) return null;
61
+ return { provider: model.slice(0, at), model: model.slice(at + 1) };
62
+ }
63
+
64
+ /** The compiled owner join, read from the emitted bytes the bridge actually loads. */
65
+ function ownerJoinAdmitsDurableHost(file: string): boolean {
66
+ try {
67
+ const join = /SENDER_JOINED_RECEIVER_OWNER_KINDS\s*=\s*(\[[^\]]*\])/.exec(readFileSync(file, "utf8"))?.[1];
68
+ return join?.includes('"pi-durable-host"') ?? false;
69
+ } catch {
70
+ // Bounded environment probe: an unreadable file is a missing unit.
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /** The FIRST missing thing, in repair order, or `null` when a fresh launch may proceed. */
76
+ export function piDurableFreshPreflight(params: {
77
+ readonly model: string;
78
+ readonly firstInput: string;
79
+ readonly layout: PiDurablePackageLayout | null;
80
+ /** The verdict of `checkPiDurableRuntime` for the caller's env: `null` = verified. */
81
+ readonly runtime: PiDurableRuntimeRejectReason | null;
82
+ }): PiDurablePreflightRejectReason | null {
83
+ if (splitPiDurableModel(params.model) === null) return "pi-durable-model-not-provider-qualified";
84
+ if (params.firstInput.length > PI_DURABLE_FIRST_INPUT_MAX_CHARS) return "pi-durable-first-input-too-long";
85
+ if (params.layout === null) return "pi-durable-package-incomplete";
86
+ if (params.runtime !== null) return params.runtime;
87
+ const layout = params.layout;
88
+ if (!existsSync(layout.adapter)) return "pi-durable-birth-unit-missing";
89
+ if (!existsSync(layout.bridgeEntry)) return "pi-durable-mcp-hand-missing";
90
+ if (!ownerJoinAdmitsDurableHost(layout.deliverability)) return "pi-durable-receive-unit-missing";
91
+ if (!existsSync(layout.bootstrap)) return "pi-durable-visible-identity-missing";
92
+ return null;
93
+ }
94
+
95
+ /** Repair text beside the predicate that decides it. The runtime reasons keep theirs on the runtime
96
+ * leaf; `mux-fresh-call` spreads both tables. */
97
+ export const PI_DURABLE_PREFLIGHT_HINT: Record<
98
+ Exclude<PiDurablePreflightRejectReason, PiDurableRuntimeRejectReason>,
99
+ string
100
+ > = {
101
+ "pi-durable-model-not-provider-qualified":
102
+ "a pi-durable model is `<provider>/<model id>` — the durable bootstrap resolves no provider by inference",
103
+ "pi-durable-first-input-too-long":
104
+ "the framed first input exceeds the 16000-character bootstrap payload cap — shorten the task",
105
+ "pi-durable-birth-unit-missing":
106
+ "this Entwurf package has no compiled pi-durable contact (mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js), so the sibling would mint no record — reinstall or rebuild Entwurf",
107
+ "pi-durable-mcp-hand-missing":
108
+ "this Entwurf package has no compiled entwurf-bridge entry, so the sibling's callback tool would not exist — reinstall or rebuild Entwurf",
109
+ "pi-durable-receive-unit-missing":
110
+ "this Entwurf package's compiled owner join does not admit pi-durable-host, so nothing could ever be delivered to the sibling — reinstall or rebuild Entwurf",
111
+ "pi-durable-visible-identity-missing":
112
+ "this Entwurf package has no packaged pi-durable bootstrap (pi/pi-durable/bootstrap.mjs), so no TUI would carry the garden id — reinstall Entwurf",
113
+ };