@junghanacs/entwurf 0.30.1 → 0.32.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 +4 -2
- package/CHANGELOG.md +194 -0
- package/DELIVERY.md +1 -0
- package/README.md +64 -14
- package/VERIFY.md +36 -1
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +1 -1
- package/docs/setup-clean-host.md +121 -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/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 +28 -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 +236 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +746 -0
- package/mcp/entwurf-bridge/src/index.ts +7 -7
- package/mcp/entwurf-bridge/tsconfig.build.json +8 -1
- package/package.json +19 -19
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/pi-durable/bootstrap.mjs +404 -0
- package/pi/pi-durable/carrier/LICENSE +21 -0
- package/pi/pi-durable/carrier/harness-setup.js +123 -0
- package/pi/pi-durable/carrier/prompt.js +74 -0
- package/pi/pi-durable/carrier/runtime.js +402 -0
- package/pi/pi-durable/carrier/sessions.js +56 -0
- package/pi/pi-durable/carrier/subagent.js +55 -0
- package/pi/pi-durable/carrier/tui.js +655 -0
- package/pi/pi-durable/carrier-relocation.mjs +160 -0
- package/pi/pi-durable/carrier-resolver.mjs +38 -0
- package/pi/pi-durable/overlay/runtime-contacts.patch +91 -0
- package/pi/pi-durable/overlay/upstream-pin.json +108 -0
- 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 +32 -2
- package/pi-extensions/lib/pi-durable-fresh-preflight.ts +113 -0
- package/pi-extensions/lib/pi-durable-runtime.ts +318 -0
- package/pi-extensions/meta-bridge-pi-durable.ts +1024 -0
- package/run.sh +624 -184
- package/scripts/check-entwurf-bridge-pi-free.ts +7 -5
- package/scripts/check-fresh-call-dispatch.ts +18 -0
- package/scripts/check-gate-qualification.ts +87 -34
- package/scripts/check-herdr-activation.ts +5 -4
- package/scripts/check-pi-durable-contact.ts +767 -0
- package/scripts/check-pi-durable-receive.ts +1079 -0
- package/scripts/check-pi-durable-send.ts +1821 -0
- package/scripts/emit-pi-durable-carrier.ts +355 -0
- package/scripts/lib/mutation-qualify.ts +21 -45
- package/scripts/lib/pi-durable-fresh-live-receipts.ts +139 -0
- package/scripts/lib/pi-durable-operator-guard.ts +89 -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/lib/qualification-receipt.ts +7 -11
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/copilot-birth.json +2 -2
- package/scripts/mutants/gate-qualification.json +13 -0
- 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 +723 -0
- package/scripts/mutants/pi-floor.json +24 -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 +284 -0
- package/scripts/smoke-herdr-raw-install-live.sh +1 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +624 -0
- package/scripts/smoke-setup-verdict.sh +22 -3
- package/scripts/smoke-user-scope-citizen.sh +1 -1
- package/scripts/tsconfig.json +1 -0
|
@@ -7,15 +7,37 @@
|
|
|
7
7
|
"title": "one row of the pnpm workspace closure is left on the previous floor while every other surface moved — the shape a version bump actually fails in. Nothing else reads that file, so the stranded row surfaces only at install time, in a different command, far from the change that stranded it",
|
|
8
8
|
"subject": "pnpm-workspace.yaml",
|
|
9
9
|
"find": [
|
|
10
|
-
" - '@earendil-works/pi-telemetry@0.85.0 || 0.85.1 || 0.86.0 || 0.87.0 || 0.87.1 || 0.99.1 || 0.99.2 || 1.0.0'"
|
|
10
|
+
" - '@earendil-works/pi-telemetry@0.85.0 || 0.85.1 || 0.86.0 || 0.87.0 || 0.87.1 || 0.99.1 || 0.99.2 || 1.0.0 || 1.0.2 || 1.0.4'"
|
|
11
11
|
],
|
|
12
12
|
"replace": [
|
|
13
|
-
" - '@earendil-works/pi-telemetry@0.85.0 || 0.85.1 || 0.86.0 || 0.87.0 || 0.87.1 || 0.99.1 || 0.99.2'"
|
|
13
|
+
" - '@earendil-works/pi-telemetry@0.85.0 || 0.85.1 || 0.86.0 || 0.87.0 || 0.87.1 || 0.99.1 || 0.99.2 || 1.0.0 || 1.0.2'"
|
|
14
14
|
],
|
|
15
15
|
"gate": ["bash", "run.sh", "check-dep-versions"],
|
|
16
16
|
"timeoutSeconds": 300,
|
|
17
17
|
"signature": "[QK:PI-FLOOR-SINGLE-SOURCE]",
|
|
18
18
|
"signatureSource": "run.sh"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"claim": "PI-SDK-PRODUCTION-SET",
|
|
22
|
+
"title": "the durable library's production pin falls one patch behind the SDK set the pin declares, so the carrier would bind a pi-durable nobody verified it against",
|
|
23
|
+
"subject": "package.json",
|
|
24
|
+
"find": ["\"@earendil-works/pi-durable\": \"1.0.4\","],
|
|
25
|
+
"replace": ["\"@earendil-works/pi-durable\": \"1.0.3\","],
|
|
26
|
+
"gate": ["bash", "run.sh", "check-dep-versions"],
|
|
27
|
+
"timeoutSeconds": 300,
|
|
28
|
+
"signature": "[QK:PI-SDK-PRODUCTION-SET]",
|
|
29
|
+
"signatureSource": "run.sh"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"claim": "PI-SDK-NO-DUAL-DECLARATION",
|
|
33
|
+
"title": "an @earendil-works package returns as a dev dependency beside its production pin, one name in two dependency classes",
|
|
34
|
+
"subject": "package.json",
|
|
35
|
+
"find": ["\"@biomejs/biome\": \"^2.4.13\","],
|
|
36
|
+
"replace": ["\"@biomejs/biome\": \"^2.4.13\",\n\t\t\"@earendil-works/pi-ai\": \"1.0.4\","],
|
|
37
|
+
"gate": ["bash", "run.sh", "check-dep-versions"],
|
|
38
|
+
"timeoutSeconds": 300,
|
|
39
|
+
"signature": "[QK:PI-SDK-NO-DUAL-DECLARATION]",
|
|
40
|
+
"signatureSource": "run.sh"
|
|
19
41
|
}
|
|
20
42
|
]
|
|
21
43
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// G-contact host half (#129 L-identity). Runs AS the durable host, under this checkout's pi-durable
|
|
2
|
+
// carrier resolver (#130), and is spawned only by scripts/check-pi-durable-contact.ts:
|
|
3
|
+
//
|
|
4
|
+
// node --import <checkout>/pi/pi-durable/carrier-resolver.mjs \
|
|
5
|
+
// scripts/pi-durable-contact-host.mjs <bridge entry> new|continue \
|
|
6
|
+
// [<sessions dir> <senders dir> <mailbox dir> <receivers dir>]
|
|
7
|
+
//
|
|
8
|
+
// The four optional directories are explicit garden roots handed to the contact, all or none;
|
|
9
|
+
// without them the contact uses the shared roots its environment selects.
|
|
10
|
+
//
|
|
11
|
+
// With ENTWURF_PI_DURABLE_CONTACT_FAULT=malformed-report (set only by the gate's control) the report
|
|
12
|
+
// line is deliberately not JSON.
|
|
13
|
+
//
|
|
14
|
+
// It opens the durable app through the production bootstrap composition (`openDurableCitizen`),
|
|
15
|
+
// then executes the contact's registered tools DIRECTLY — adapter-direct, harness-bypassed: no
|
|
16
|
+
// model turn and no ToolTask, so this proves the adapter's bridge contact, not the harness
|
|
17
|
+
// selecting or validating the tool. The report carries the contact's own `receiver()` state, never
|
|
18
|
+
// the path the attachment returned. It prints one JSON report line, holds the host and its
|
|
19
|
+
// bridge child alive until the orchestrator writes `close` (so /proc can be read from outside),
|
|
20
|
+
// closes, prints `{"closed":true}` and exits.
|
|
21
|
+
//
|
|
22
|
+
// The bridge's environment is observed at the SDK connector boundary only: the contact's existing
|
|
23
|
+
// `connect` injection wraps the COMPILED production `connectStdioBridge`, records the spawn spec's
|
|
24
|
+
// command/args/cwd, whether each of the four poisoned identity carrier NAMES is present, and the
|
|
25
|
+
// four root carrier VALUES — nothing else of the env — and forwards the spec unchanged to that real
|
|
26
|
+
// connector. It is the spec the adapter hands the SDK, not the kernel child environment; no
|
|
27
|
+
// /proc/<pid>/environ is read anywhere.
|
|
28
|
+
import { createInterface } from "node:readline";
|
|
29
|
+
import { connectStdioBridge } from "../mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js";
|
|
30
|
+
import { openDurableCitizen } from "../plugins/pi-durable/bootstrap.mjs";
|
|
31
|
+
|
|
32
|
+
const IDENTITY_CARRIERS = ["PI_SESSION_ID", "PI_AGENT_ID", "ENTWURF_META_SENDER_MARKER", "ENTWURF_BRIDGE_NATIVE_HOST"];
|
|
33
|
+
const ROOT_CARRIERS = [
|
|
34
|
+
"ENTWURF_META_SESSIONS_DIR",
|
|
35
|
+
"ENTWURF_META_SENDERS_DIR",
|
|
36
|
+
"ENTWURF_META_MAILBOX_DIR",
|
|
37
|
+
"ENTWURF_META_RECEIVERS_DIR",
|
|
38
|
+
];
|
|
39
|
+
const bridgeSpecs = [];
|
|
40
|
+
const connect = async (spec) => {
|
|
41
|
+
bridgeSpecs.push({
|
|
42
|
+
command: spec.command,
|
|
43
|
+
args: [...spec.args],
|
|
44
|
+
cwd: spec.cwd,
|
|
45
|
+
identityCarrierPresent: Object.fromEntries(IDENTITY_CARRIERS.map((name) => [name, Object.hasOwn(spec.env, name)])),
|
|
46
|
+
rootCarriers: Object.fromEntries(ROOT_CARRIERS.map((name) => [name, spec.env[name] ?? null])),
|
|
47
|
+
});
|
|
48
|
+
return connectStdioBridge(spec);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const [bridgeEntry, mode, ...rootArgs] = process.argv.slice(2);
|
|
52
|
+
if (bridgeEntry === undefined || (mode !== "new" && mode !== "continue") || ![0, 4].includes(rootArgs.length)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"usage: pi-durable-contact-host.mjs <bridge entry> new|continue [<sessions dir> <senders dir> <mailbox dir> <receivers dir>]",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const [sessionsDir, sendersDir, mailboxDir, receiversDir] = rootArgs;
|
|
58
|
+
|
|
59
|
+
const citizen = await openDurableCitizen({
|
|
60
|
+
bridgeEntry,
|
|
61
|
+
continueSession: mode === "continue",
|
|
62
|
+
cwd: process.cwd(),
|
|
63
|
+
contact: rootArgs.length === 0 ? { connect } : { sessionsDir, sendersDir, mailboxDir, receiversDir, connect },
|
|
64
|
+
});
|
|
65
|
+
let code = 0;
|
|
66
|
+
try {
|
|
67
|
+
const run = async (name) => {
|
|
68
|
+
const tool = citizen.contact.extension.tools.find((t) => t.name === name);
|
|
69
|
+
if (tool === undefined) throw new Error(`the contact registers no ${name}`);
|
|
70
|
+
const result = await tool.execute({}, undefined, {});
|
|
71
|
+
return result.content.map((block) => block.text).join("\n");
|
|
72
|
+
};
|
|
73
|
+
const view = citizen.durable.view.current();
|
|
74
|
+
const report = {
|
|
75
|
+
hostPid: process.pid,
|
|
76
|
+
session: view.session,
|
|
77
|
+
attachment: citizen.attachment,
|
|
78
|
+
receiver: citizen.contact.receiver(),
|
|
79
|
+
toolNames: citizen.contact.extension.tools.map((t) => t.name),
|
|
80
|
+
bridgeSpecs,
|
|
81
|
+
self: await run("entwurf_self"),
|
|
82
|
+
peers: await run("entwurf_peers"),
|
|
83
|
+
notices: view.notices.map((n) => `${n.level}: ${n.message}`),
|
|
84
|
+
};
|
|
85
|
+
// The gate's malformed-report control: the report line is deliberately not JSON.
|
|
86
|
+
if (process.env.ENTWURF_PI_DURABLE_CONTACT_FAULT === "malformed-report") {
|
|
87
|
+
process.stdout.write(`{"malformed report line\n`);
|
|
88
|
+
} else {
|
|
89
|
+
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
90
|
+
}
|
|
91
|
+
const lines = createInterface({ input: process.stdin });
|
|
92
|
+
for await (const line of lines) if (line.trim() === "close") break;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
code = 1;
|
|
95
|
+
// The failure report keeps its cause first and carries, beside it, only the host pid and the same
|
|
96
|
+
// connector-boundary record the success report has — so the gate can judge the scrub/root QKs before
|
|
97
|
+
// it throws this error. It is never a success: no attachment, self, peers or session is reported.
|
|
98
|
+
process.stdout.write(
|
|
99
|
+
`${JSON.stringify({ error: error instanceof Error ? error.message : String(error), hostPid: process.pid, bridgeSpecs })}\n`,
|
|
100
|
+
);
|
|
101
|
+
} finally {
|
|
102
|
+
await citizen.close();
|
|
103
|
+
}
|
|
104
|
+
process.stdout.write(`${JSON.stringify({ closed: true })}\n`);
|
|
105
|
+
process.exit(code);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// G-receive host half (#129 L-receive). Runs AS the durable host, under this checkout's pi-durable
|
|
2
|
+
// carrier resolver (#130), and is spawned only by scripts/check-pi-durable-receive.ts:
|
|
3
|
+
//
|
|
4
|
+
// node --import <checkout>/pi/pi-durable/carrier-resolver.mjs \
|
|
5
|
+
// scripts/pi-durable-receive-host.mjs <bridge entry>
|
|
6
|
+
//
|
|
7
|
+
// It opens the durable app through the production bootstrap composition — which arms the contact's
|
|
8
|
+
// doorbell on the root conversation — reports `born`, and then gives the app NO input: no controller
|
|
9
|
+
// call, no tool call, no bridge call, no native state of its own. Whatever runs afterwards was
|
|
10
|
+
// started by the doorbell's root admission. The driver only watches the native view and the
|
|
11
|
+
// contact's own `receiver()` state (never the path the attachment returned, which a later retire
|
|
12
|
+
// can make stale), and reports once:
|
|
13
|
+
//
|
|
14
|
+
// - the receiver has made an admission, the view holds an assistant entry after the newest user
|
|
15
|
+
// entry that calls no tool, and the conversation is no longer busy; or
|
|
16
|
+
// - the receiver is no longer armed; or
|
|
17
|
+
// - the app raised an error notice.
|
|
18
|
+
//
|
|
19
|
+
// Then it holds the host alive until the gate writes `close`, closes, prints `{"closed":true}` and
|
|
20
|
+
// exits.
|
|
21
|
+
import { createInterface } from "node:readline";
|
|
22
|
+
import { openDurableCitizen } from "../plugins/pi-durable/bootstrap.mjs";
|
|
23
|
+
|
|
24
|
+
const USAGE = "usage: pi-durable-receive-host.mjs <bridge entry>";
|
|
25
|
+
const [bridgeEntry, ...extra] = process.argv.slice(2);
|
|
26
|
+
if (bridgeEntry === undefined || extra.length > 0) throw new Error(USAGE);
|
|
27
|
+
|
|
28
|
+
const stdinLines = createInterface({ input: process.stdin })[Symbol.asyncIterator]();
|
|
29
|
+
/** Wait for one exact line from the gate; the gate closing stdin first is a failure. */
|
|
30
|
+
async function waitFor(word) {
|
|
31
|
+
for (;;) {
|
|
32
|
+
const next = await stdinLines.next();
|
|
33
|
+
if (next.done) throw new Error(`stdin closed before "${word}"`);
|
|
34
|
+
if (next.value.trim() === word) return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One entry of the native view, reduced to what the gate compares (the send driver's reduction). */
|
|
39
|
+
function summarize(entry) {
|
|
40
|
+
const message = entry.model?.[0];
|
|
41
|
+
const blocks = Array.isArray(message?.content) ? message.content : [];
|
|
42
|
+
const text = (typeof message?.content === "string" ? [message.content] : [])
|
|
43
|
+
.concat(blocks.flatMap((b) => (b.type === "text" ? [b.text] : [])))
|
|
44
|
+
.join("\n");
|
|
45
|
+
return {
|
|
46
|
+
id: entry.id,
|
|
47
|
+
kind: entry.kind,
|
|
48
|
+
role: message?.role,
|
|
49
|
+
toolCalls: blocks
|
|
50
|
+
.filter((b) => b.type === "toolCall")
|
|
51
|
+
.map((b) => ({ id: b.id, name: b.name, arguments: b.arguments })),
|
|
52
|
+
...(message?.role === "toolResult"
|
|
53
|
+
? {
|
|
54
|
+
toolCallId: message.toolCallId,
|
|
55
|
+
toolName: message.toolName,
|
|
56
|
+
isError: message.isError === true,
|
|
57
|
+
diagnostics: Array.isArray(entry.data?.diagnostics) ? entry.data.diagnostics : [],
|
|
58
|
+
}
|
|
59
|
+
: {}),
|
|
60
|
+
text,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const citizen = await openDurableCitizen({ bridgeEntry, cwd: process.cwd() });
|
|
65
|
+
|
|
66
|
+
function reportOf(view, why) {
|
|
67
|
+
return {
|
|
68
|
+
settled: why,
|
|
69
|
+
hostPid: process.pid,
|
|
70
|
+
session: view.session,
|
|
71
|
+
conversationId: view.conversation.conversation.id,
|
|
72
|
+
receiver: citizen.contact.receiver(),
|
|
73
|
+
entries: view.conversation.entries.filter((e) => e.kind.startsWith("pi.")).map(summarize),
|
|
74
|
+
busy: view.conversation.docs["pi.live"]?.run !== undefined,
|
|
75
|
+
notices: view.notices.map((n) => `${n.level}: ${n.message}`),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let code = 0;
|
|
80
|
+
try {
|
|
81
|
+
const { durable } = citizen;
|
|
82
|
+
const born = durable.view.current();
|
|
83
|
+
const noticesBefore = born.notices.length;
|
|
84
|
+
process.stdout.write(
|
|
85
|
+
`${JSON.stringify({
|
|
86
|
+
born: true,
|
|
87
|
+
hostPid: process.pid,
|
|
88
|
+
session: born.session,
|
|
89
|
+
conversationId: born.conversation.conversation.id,
|
|
90
|
+
attachment: citizen.attachment,
|
|
91
|
+
receiver: citizen.contact.receiver(),
|
|
92
|
+
})}\n`,
|
|
93
|
+
);
|
|
94
|
+
const why = await new Promise((resolve) => {
|
|
95
|
+
let done = false;
|
|
96
|
+
const check = () => {
|
|
97
|
+
if (done) return;
|
|
98
|
+
const view = durable.view.current();
|
|
99
|
+
const receiver = citizen.contact.receiver();
|
|
100
|
+
const entries = view.conversation.entries;
|
|
101
|
+
const lastUser = entries.findLastIndex((e) => e.kind === "pi.user");
|
|
102
|
+
const lastAnswer =
|
|
103
|
+
lastUser < 0 ? undefined : entries.slice(lastUser + 1).findLast((e) => e.kind === "pi.assistant");
|
|
104
|
+
const answered = lastAnswer !== undefined && summarize(lastAnswer).toolCalls.length === 0;
|
|
105
|
+
const busy = view.conversation.docs["pi.live"]?.run !== undefined;
|
|
106
|
+
let outcome;
|
|
107
|
+
if (receiver.status !== "armed") outcome = "receiver-not-armed";
|
|
108
|
+
else if (view.notices.slice(noticesBefore).some((n) => n.level === "error")) outcome = "error-notice";
|
|
109
|
+
else if (receiver.admissions.length > 0 && answered && !busy) outcome = "answered";
|
|
110
|
+
if (outcome === undefined) return;
|
|
111
|
+
done = true;
|
|
112
|
+
unsubscribe();
|
|
113
|
+
resolve(outcome);
|
|
114
|
+
};
|
|
115
|
+
const unsubscribe = durable.view.subscribe(check);
|
|
116
|
+
check();
|
|
117
|
+
});
|
|
118
|
+
process.stdout.write(`${JSON.stringify(reportOf(durable.view.current(), why))}\n`);
|
|
119
|
+
await waitFor("close");
|
|
120
|
+
} catch (error) {
|
|
121
|
+
code = 1;
|
|
122
|
+
process.stdout.write(`${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
123
|
+
} finally {
|
|
124
|
+
await citizen.close();
|
|
125
|
+
}
|
|
126
|
+
process.stdout.write(`${JSON.stringify({ closed: true })}\n`);
|
|
127
|
+
process.exit(code);
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// G-send host half (#129 L-send first cell). Runs AS the durable host, under this checkout's
|
|
2
|
+
// pi-durable carrier resolver (#130), and is spawned only by scripts/check-pi-durable-send.ts:
|
|
3
|
+
//
|
|
4
|
+
// node --import <checkout>/pi/pi-durable/carrier-resolver.mjs \
|
|
5
|
+
// scripts/pi-durable-send-host.mjs <bridge entry> <prompt>
|
|
6
|
+
//
|
|
7
|
+
// It opens the durable app through the production bootstrap composition, then gives the app ONE
|
|
8
|
+
// user input through its native controller — the call its TUI makes when the operator presses
|
|
9
|
+
// enter — and watches only the native view. Everything after that input is the app's own: the
|
|
10
|
+
// model request, the GenerationTask, the ToolTask, the adapter's bridge call. This driver never
|
|
11
|
+
// calls a tool or the bridge itself.
|
|
12
|
+
//
|
|
13
|
+
// `controller.submit` settling is NOT the turn completing: the controller queues the command and
|
|
14
|
+
// turns its failures into notices (runtime.ts:229-252). The driver waits on the view instead —
|
|
15
|
+
// the run document is present exactly while the conversation is busy — and reports what the view
|
|
16
|
+
// holds. It prints one JSON report line, holds the host alive until the gate writes `close`,
|
|
17
|
+
// closes, prints `{"closed":true}` and exits. With ENTWURF_PI_DURABLE_SEND_FAULT=malformed-report
|
|
18
|
+
// (the gate's control) it gives the app no input and sends a non-JSON report line instead.
|
|
19
|
+
//
|
|
20
|
+
// --native-module <file> (#130 P2): the whole packaged `main` runs instead — its argument parse, the
|
|
21
|
+
// operator module's initialization, the TUI load and the open, in main's own order — with ONE seam:
|
|
22
|
+
// the TUI runner is this driver, which gives the app the one input through the controller it is
|
|
23
|
+
// handed and reports the view as above. Nothing else of main is replaced.
|
|
24
|
+
import { createInterface } from "node:readline";
|
|
25
|
+
import { main, openDurableCitizen } from "../plugins/pi-durable/bootstrap.mjs";
|
|
26
|
+
|
|
27
|
+
const USAGE =
|
|
28
|
+
"usage: pi-durable-send-host.mjs <bridge entry> <prompt> [--await-go] [--report-intent <callId> | --recover <callId> | --native-module <file>]";
|
|
29
|
+
const [bridgeEntry, prompt, ...flags] = process.argv.slice(2);
|
|
30
|
+
if (bridgeEntry === undefined || prompt === undefined) throw new Error(USAGE);
|
|
31
|
+
// --await-go: report `born` once the citizen exists, and give the app its one input only after the
|
|
32
|
+
// gate writes `go` — test-driver sequencing so the gate can read the bridge's counters first. It is
|
|
33
|
+
// not a second input and not another controller.
|
|
34
|
+
// --report-intent <callId>: after the input, watch the native view and report ONCE the frame in
|
|
35
|
+
// which the task graph shows a `pi.tool` node running in phase `execute` and the live slot with the
|
|
36
|
+
// same taskId is that call, running. The phase is the task's committed checkpoint (task-graph.ts
|
|
37
|
+
// 210-221), written in the same commit as the slot (tool.ts 84-90). Then do nothing: the gate kills
|
|
38
|
+
// this process.
|
|
39
|
+
// --recover <callId>: continue the newest session, give the app NO input, and report once the view
|
|
40
|
+
// holds that call's settled result and the conversation is idle.
|
|
41
|
+
let awaitGo = false;
|
|
42
|
+
let reportIntent;
|
|
43
|
+
let recoverCall;
|
|
44
|
+
let nativeModule;
|
|
45
|
+
for (let i = 0; i < flags.length; i++) {
|
|
46
|
+
if (flags[i] === "--await-go") awaitGo = true;
|
|
47
|
+
else if (flags[i] === "--report-intent" && flags[i + 1] !== undefined) reportIntent = flags[++i];
|
|
48
|
+
else if (flags[i] === "--recover" && flags[i + 1] !== undefined) recoverCall = flags[++i];
|
|
49
|
+
else if (flags[i] === "--native-module" && flags[i + 1] !== undefined) nativeModule = flags[++i];
|
|
50
|
+
else throw new Error(USAGE);
|
|
51
|
+
}
|
|
52
|
+
if (reportIntent !== undefined && recoverCall !== undefined) throw new Error(USAGE);
|
|
53
|
+
if (nativeModule !== undefined && (awaitGo || reportIntent !== undefined || recoverCall !== undefined)) {
|
|
54
|
+
throw new Error(USAGE);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const stdinLines = createInterface({ input: process.stdin })[Symbol.asyncIterator]();
|
|
58
|
+
/** Wait for one exact line from the gate; the gate closing stdin first is a failure. */
|
|
59
|
+
async function waitFor(word) {
|
|
60
|
+
for (;;) {
|
|
61
|
+
const next = await stdinLines.next();
|
|
62
|
+
if (next.done) throw new Error(`stdin closed before "${word}"`);
|
|
63
|
+
if (next.value.trim() === word) return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One entry of the native view, reduced to what the gate compares. */
|
|
68
|
+
function summarize(entry) {
|
|
69
|
+
const message = entry.model?.[0];
|
|
70
|
+
const blocks = Array.isArray(message?.content) ? message.content : [];
|
|
71
|
+
const text = (typeof message?.content === "string" ? [message.content] : [])
|
|
72
|
+
.concat(blocks.flatMap((b) => (b.type === "text" ? [b.text] : [])))
|
|
73
|
+
.join("\n");
|
|
74
|
+
return {
|
|
75
|
+
kind: entry.kind,
|
|
76
|
+
role: message?.role,
|
|
77
|
+
toolCalls: blocks
|
|
78
|
+
.filter((b) => b.type === "toolCall")
|
|
79
|
+
.map((b) => ({ id: b.id, name: b.name, arguments: b.arguments })),
|
|
80
|
+
...(message?.role === "toolResult"
|
|
81
|
+
? {
|
|
82
|
+
toolCallId: message.toolCallId,
|
|
83
|
+
toolName: message.toolName,
|
|
84
|
+
isError: message.isError === true,
|
|
85
|
+
// The entry's own diagnostics (`data.diagnostics`), copied as they are.
|
|
86
|
+
diagnostics: Array.isArray(entry.data?.diagnostics) ? entry.data.diagnostics : [],
|
|
87
|
+
}
|
|
88
|
+
: {}),
|
|
89
|
+
text,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Give the app one input through `controller` and resolve once `source` shows the turn settled. */
|
|
94
|
+
function submitAndSettle(source, controller) {
|
|
95
|
+
const before = source.current();
|
|
96
|
+
const noticesBefore = before.notices.length;
|
|
97
|
+
let submitted = false;
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
const check = () => {
|
|
100
|
+
if (!submitted) return;
|
|
101
|
+
const view = source.current();
|
|
102
|
+
const busy = view.conversation.docs["pi.live"]?.run !== undefined;
|
|
103
|
+
const answered = view.conversation.entries.some((e) => e.kind === "pi.assistant");
|
|
104
|
+
const failed = view.notices.slice(noticesBefore).some((n) => n.level === "error");
|
|
105
|
+
if ((!busy && answered) || failed) {
|
|
106
|
+
unsubscribe();
|
|
107
|
+
resolve(before);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const unsubscribe = source.subscribe(check);
|
|
111
|
+
controller.submit(prompt, "followUp").then(() => {
|
|
112
|
+
submitted = true;
|
|
113
|
+
check();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (nativeModule !== undefined) {
|
|
119
|
+
let code = 0;
|
|
120
|
+
try {
|
|
121
|
+
await main(
|
|
122
|
+
["--provider", "loopback", "--model", "scripted", "--width", "task-wide", "--native-module", nativeModule],
|
|
123
|
+
{
|
|
124
|
+
bridgeEntry,
|
|
125
|
+
loadTui: async () => ({
|
|
126
|
+
runDurableTui: async (source, controller) => {
|
|
127
|
+
const before = await submitAndSettle(source, controller);
|
|
128
|
+
const view = source.current();
|
|
129
|
+
process.stdout.write(
|
|
130
|
+
`${JSON.stringify({
|
|
131
|
+
native: true,
|
|
132
|
+
hostPid: process.pid,
|
|
133
|
+
session: view.session,
|
|
134
|
+
conversationIdBefore: before.conversation.conversation.id,
|
|
135
|
+
conversationIdAfter: view.conversation.conversation.id,
|
|
136
|
+
entries: view.conversation.entries.filter((e) => e.kind.startsWith("pi.")).map(summarize),
|
|
137
|
+
busy: view.conversation.docs["pi.live"]?.run !== undefined,
|
|
138
|
+
notices: view.notices.map((n) => `${n.level}: ${n.message}`),
|
|
139
|
+
})}\n`,
|
|
140
|
+
);
|
|
141
|
+
await waitFor("close");
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
code = 1;
|
|
148
|
+
process.stdout.write(`${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
149
|
+
}
|
|
150
|
+
process.stdout.write(`${JSON.stringify({ closed: true })}\n`);
|
|
151
|
+
process.exit(code);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const citizen = await openDurableCitizen({
|
|
155
|
+
bridgeEntry,
|
|
156
|
+
cwd: process.cwd(),
|
|
157
|
+
continueSession: recoverCall !== undefined,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/** The frame the gate needs: graph node + same-task live slot, as the native view holds them. */
|
|
161
|
+
function intentFrame(view, callId) {
|
|
162
|
+
const nodes = Object.values(view.tasks?.tasks ?? {});
|
|
163
|
+
const slots = view.conversation.docs["pi.live"]?.tools ?? [];
|
|
164
|
+
for (const node of nodes) {
|
|
165
|
+
if (node.kind !== "pi.tool" || node.conversationId !== 1) continue;
|
|
166
|
+
if (node.state?.status !== "running" || node.state?.phase !== "execute") continue;
|
|
167
|
+
const slot = slots.find((s) => s.taskId === node.id);
|
|
168
|
+
if (slot && slot.name === "entwurf_v2" && slot.callId === callId && slot.status === "running") {
|
|
169
|
+
return { node, slot, nodes, slots };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function reportOf(view, before) {
|
|
176
|
+
return {
|
|
177
|
+
hostPid: process.pid,
|
|
178
|
+
session: view.session,
|
|
179
|
+
attachment: citizen.attachment,
|
|
180
|
+
conversationIdBefore: before.conversation.conversation.id,
|
|
181
|
+
conversationIdAfter: view.conversation.conversation.id,
|
|
182
|
+
entries: view.conversation.entries.filter((e) => e.kind.startsWith("pi.")).map(summarize),
|
|
183
|
+
busy: view.conversation.docs["pi.live"]?.run !== undefined,
|
|
184
|
+
notices: view.notices.map((n) => `${n.level}: ${n.message}`),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
let code = 0;
|
|
188
|
+
try {
|
|
189
|
+
if (process.env.ENTWURF_PI_DURABLE_SEND_FAULT === "malformed-report") {
|
|
190
|
+
// The gate's before-valid-report cleanup control: born, no input given to the app, a report
|
|
191
|
+
// line that is deliberately not JSON, then alive until told to close.
|
|
192
|
+
process.stdout.write(`{"malformed send report line\n`);
|
|
193
|
+
await waitFor("close");
|
|
194
|
+
throw new Error("malformed-report control closed");
|
|
195
|
+
}
|
|
196
|
+
const { durable } = citizen;
|
|
197
|
+
if (recoverCall !== undefined) {
|
|
198
|
+
// Recovery may already have run inside openDurable (resume precedes attach): check the current
|
|
199
|
+
// view as well as every later one, so a settled outcome is never missed.
|
|
200
|
+
const before = durable.view.current();
|
|
201
|
+
await new Promise((resolve) => {
|
|
202
|
+
let done = false;
|
|
203
|
+
const check = () => {
|
|
204
|
+
if (done) return;
|
|
205
|
+
const view = durable.view.current();
|
|
206
|
+
const entries = view.conversation.entries;
|
|
207
|
+
const resultAt = entries.findIndex(
|
|
208
|
+
(e) => e.kind === "pi.tool-result" && e.model?.[0]?.toolCallId === recoverCall,
|
|
209
|
+
);
|
|
210
|
+
const answeredAfter = resultAt >= 0 && entries.slice(resultAt + 1).some((e) => e.kind === "pi.assistant");
|
|
211
|
+
const busy = view.conversation.docs["pi.live"]?.run !== undefined;
|
|
212
|
+
const failed = view.notices.some((n) => n.level === "error");
|
|
213
|
+
if ((answeredAfter && !busy) || failed) {
|
|
214
|
+
done = true;
|
|
215
|
+
unsubscribe();
|
|
216
|
+
resolve();
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const unsubscribe = durable.view.subscribe(check);
|
|
220
|
+
check();
|
|
221
|
+
});
|
|
222
|
+
process.stdout.write(`${JSON.stringify({ recovered: true, ...reportOf(durable.view.current(), before) })}\n`);
|
|
223
|
+
await waitFor("close");
|
|
224
|
+
throw new Error("__closed__");
|
|
225
|
+
}
|
|
226
|
+
if (awaitGo) {
|
|
227
|
+
process.stdout.write(
|
|
228
|
+
`${JSON.stringify({ born: true, hostPid: process.pid, attachment: citizen.attachment, session: durable.view.current().session })}\n`,
|
|
229
|
+
);
|
|
230
|
+
await waitFor("go");
|
|
231
|
+
}
|
|
232
|
+
if (reportIntent !== undefined) {
|
|
233
|
+
let reported = false;
|
|
234
|
+
const check = () => {
|
|
235
|
+
if (reported) return;
|
|
236
|
+
const view = durable.view.current();
|
|
237
|
+
const frame = intentFrame(view, reportIntent);
|
|
238
|
+
if (frame === undefined) return;
|
|
239
|
+
reported = true;
|
|
240
|
+
process.stdout.write(
|
|
241
|
+
`${JSON.stringify({ intent: true, hostPid: process.pid, session: view.session, frame, entries: view.conversation.entries.filter((e) => e.kind.startsWith("pi.")).map(summarize) })}\n`,
|
|
242
|
+
);
|
|
243
|
+
};
|
|
244
|
+
durable.view.subscribe(check);
|
|
245
|
+
durable.controller.submit(prompt, "followUp").then(check);
|
|
246
|
+
check();
|
|
247
|
+
// The gate kills this process after the frame; nothing else happens here.
|
|
248
|
+
await waitFor("close");
|
|
249
|
+
throw new Error("report-intent host was asked to close instead of being killed");
|
|
250
|
+
}
|
|
251
|
+
const before = durable.view.current();
|
|
252
|
+
const noticesBefore = before.notices.length;
|
|
253
|
+
let submitted = false;
|
|
254
|
+
const settled = new Promise((resolve) => {
|
|
255
|
+
const check = () => {
|
|
256
|
+
if (!submitted) return;
|
|
257
|
+
const view = durable.view.current();
|
|
258
|
+
const busy = view.conversation.docs["pi.live"]?.run !== undefined;
|
|
259
|
+
const answered = view.conversation.entries.some((e) => e.kind === "pi.assistant");
|
|
260
|
+
const failed = view.notices.slice(noticesBefore).some((n) => n.level === "error");
|
|
261
|
+
if ((!busy && answered) || failed) {
|
|
262
|
+
unsubscribe();
|
|
263
|
+
resolve();
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
const unsubscribe = durable.view.subscribe(check);
|
|
267
|
+
durable.controller.submit(prompt, "followUp").then(() => {
|
|
268
|
+
submitted = true;
|
|
269
|
+
check();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
await settled;
|
|
273
|
+
process.stdout.write(`${JSON.stringify(reportOf(durable.view.current(), before))}\n`);
|
|
274
|
+
await waitFor("close");
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (!(error instanceof Error && error.message === "__closed__")) {
|
|
277
|
+
code = 1;
|
|
278
|
+
process.stdout.write(`${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
279
|
+
}
|
|
280
|
+
} finally {
|
|
281
|
+
await citizen.close();
|
|
282
|
+
}
|
|
283
|
+
process.stdout.write(`${JSON.stringify({ closed: true })}\n`);
|
|
284
|
+
process.exit(code);
|
|
@@ -108,7 +108,7 @@ if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then
|
|
|
108
108
|
exit 0
|
|
109
109
|
fi
|
|
110
110
|
|
|
111
|
-
PI_SPEC="$(node -p "require('$REPO/package.json').
|
|
111
|
+
PI_SPEC="$(node -p "require('$REPO/package.json').dependencies['@earendil-works/pi-coding-agent']")"
|
|
112
112
|
# Pinned, not floating: a receipt that cannot say WHICH Claude Code it ran is not
|
|
113
113
|
# reproducible. This is the version measured in the container on 2026-09-19. Claude Code
|
|
114
114
|
# is scaffolding here (Rule 17) — the product face starts at `herdr integration install`.
|