@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.
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +229 -0
- package/DELIVERY.md +1 -0
- package/README.md +49 -4
- package/VERIFY.md +23 -4
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +111 -18
- package/docs/setup-clean-host.md +104 -4
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +7 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +43 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +34 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +28 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +30 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +92 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +288 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +744 -0
- package/mcp/entwurf-bridge/src/index.ts +7 -7
- package/mcp/entwurf-bridge/tsconfig.build.json +8 -1
- package/package.json +15 -12
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/pi-durable/bootstrap.mjs +281 -0
- package/pi/pi-durable/overlay/runtime-contacts.patch +91 -0
- package/pi/pi-durable/overlay/upstream-pin.json +16 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +68 -0
- package/pi-extensions/lib/acp/backend.ts +41 -5
- package/pi-extensions/lib/entwurf-deliverability.ts +7 -1
- package/pi-extensions/lib/fresh-call-composition.ts +42 -7
- package/pi-extensions/lib/meta-sender-identity.ts +7 -1
- package/pi-extensions/lib/meta-session.ts +28 -2
- package/pi-extensions/lib/mux-fresh-call.ts +34 -2
- package/pi-extensions/lib/pi-durable-fresh-preflight.ts +113 -0
- package/pi-extensions/lib/pi-durable-runtime.ts +384 -0
- package/pi-extensions/meta-bridge-pi-durable.ts +1020 -0
- package/run.sh +214 -36
- package/scripts/check-fresh-call-dispatch.ts +18 -0
- package/scripts/check-gate-qualification.ts +4 -2
- package/scripts/check-herdr-plugin-build.ts +159 -57
- package/scripts/check-pi-durable-contact.ts +834 -0
- package/scripts/check-pi-durable-receive.ts +1116 -0
- package/scripts/check-pi-durable-send.ts +1661 -0
- package/scripts/fixtures/herdr-supply.json +8 -8
- package/scripts/lib/pi-durable-fresh-live-receipts.ts +139 -0
- package/scripts/lib/pi-durable-r1.ts +100 -0
- package/scripts/lib/pi-durable-reap.ts +27 -0
- package/scripts/lib/pi-durable-receive.ts +137 -0
- package/scripts/mutants/acp-prompt-lifecycle.json +2 -2
- package/scripts/mutants/acp-vendor-rejection.json +62 -0
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/copilot-birth.json +2 -2
- package/scripts/mutants/herdr-plugin-build.json +11 -0
- package/scripts/mutants/herdr-supply.json +3 -3
- package/scripts/mutants/meta-hook-session-switch.json +4 -2
- package/scripts/mutants/mux-fresh-call.json +69 -3
- package/scripts/mutants/omp-birth.json +2 -2
- package/scripts/mutants/omp-fresh.json +6 -4
- package/scripts/mutants/pack-install.json +3 -3
- package/scripts/mutants/pi-durable.json +390 -0
- package/scripts/mutants/pi-floor.json +2 -2
- package/scripts/pi-durable-contact-host.mjs +105 -0
- package/scripts/pi-durable-receive-host.mjs +127 -0
- package/scripts/pi-durable-send-host.mjs +213 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-herdr-fresh-call-live.ts +21 -7
- package/scripts/smoke-pi-durable-fresh-live.ts +624 -0
- package/scripts/smoke-setup-verdict.sh +21 -2
- 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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
29
|
-
* by #95 after its system birth and app-server rails were measured
|
|
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
|
|
146
|
-
* handed
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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[] = [
|
|
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 = [
|
|
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
|
|
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
|
-
*
|
|
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
|
+
};
|