@junghanacs/entwurf 0.39.0 → 0.41.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/BASELINE.md +60 -5
- package/CHANGELOG.md +557 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +52 -2
- package/FAQ.md +8 -4
- package/README.md +37 -17
- package/VERIFY.md +23 -10
- package/docs/acp-backend-rail.md +5 -1
- package/docs/external-mcp-host.md +8 -6
- package/docs/fresh-cut-policy.md +9 -1
- package/docs/setup-clean-host.md +64 -9
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +100 -24
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +10 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js +123 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +259 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-transport.js +80 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
- package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +65 -0
- package/mcp/entwurf-bridge/src/index.ts +118 -24
- package/mcp/entwurf-bridge/test.sh +2 -2
- package/mcp/entwurf-bridge/tsconfig.build.json +17 -1
- package/package.json +5 -5
- package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
- package/pi-extensions/entwurf-control.ts +55 -20
- package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/pi-extensions/lib/cc-retire-request.ts +553 -0
- package/pi-extensions/lib/control-retire.ts +195 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
- package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
- package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
- package/pi-extensions/lib/fresh-call-composition.ts +15 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
- package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +13 -4
- package/pi-extensions/lib/meta-session.ts +140 -16
- package/pi-extensions/lib/mux-fresh-call.ts +7 -27
- package/pi-extensions/lib/mux-launch.ts +29 -0
- package/pi-extensions/lib/mux-resume-call.ts +33 -12
- package/pi-extensions/lib/pi-retire-request.ts +111 -0
- package/pi-extensions/lib/remote-pair-receive.ts +184 -0
- package/pi-extensions/lib/remote-pair-send.ts +325 -0
- package/pi-extensions/lib/remote-pair-transport.ts +81 -0
- package/pi-extensions/lib/resume-launch-identity.ts +104 -18
- package/pi-extensions/lib/retirement-event.ts +253 -0
- package/pi-extensions/meta-bridge-hook.ts +142 -13
- package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
- package/run.sh +122 -7
- package/scripts/agy-bridge-config.py +4 -1
- package/scripts/build-bridge.sh +14 -0
- package/scripts/check-capability-bundle-reach.ts +135 -2
- package/scripts/check-entwurf-bridge-boot.ts +2 -1
- package/scripts/check-entwurf-fact-provider.ts +1 -0
- package/scripts/check-entwurf-v2-production.ts +2 -0
- package/scripts/check-entwurf-v2-surface.ts +7 -0
- package/scripts/check-gate-qualification.ts +20 -8
- package/scripts/check-herdr-activation.ts +2 -2
- package/scripts/check-herdr-placement.ts +1 -0
- package/scripts/check-hook-launch-topology.ts +6 -15
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-meta-doctor-oracle.sh +27 -1
- package/scripts/check-meta-facts.ts +8 -3
- package/scripts/check-meta-hook-session-switch.ts +230 -13
- package/scripts/check-meta-identity-consumers.ts +1 -0
- package/scripts/check-meta-listing.ts +1 -0
- package/scripts/check-meta-manifest-schema.py +1 -1
- package/scripts/check-meta-session.ts +31 -0
- package/scripts/check-meta-v3-record.ts +55 -1
- package/scripts/check-mux-resume-call.ts +35 -9
- package/scripts/check-peer-facts.ts +1 -0
- package/scripts/check-pi-durable-contact.ts +3 -1
- package/scripts/check-pi-durable-send.ts +4 -2
- package/scripts/check-pi-mcp-bridge.ts +1 -0
- package/scripts/check-remote-pair-installed.ts +242 -0
- package/scripts/check-resume-launch-identity.ts +62 -23
- package/scripts/lib/meta-hook-bundle.ts +72 -0
- package/scripts/lib/remote-pair-installed-audit.ts +25 -0
- package/scripts/meta-bridge-claude-floor.sh +13 -9
- package/scripts/meta-bridge-doctor.sh +17 -0
- package/scripts/meta-bridge-install.sh +6 -0
- package/scripts/meta-facts.ts +1 -1
- package/scripts/mutants/bridge-boot-resume.json +5 -2
- package/scripts/mutants/cc-retire-pidfd.json +129 -0
- package/scripts/mutants/cc-retire-request.json +171 -0
- package/scripts/mutants/control-retire.json +105 -0
- package/scripts/mutants/control-send-receipt.json +2 -2
- package/scripts/mutants/copilot-receive.json +2 -6
- package/scripts/mutants/entwurf-v2-retire.json +476 -0
- package/scripts/mutants/fresh-call-dispatch.json +37 -0
- package/scripts/mutants/meta-hook-session-switch.json +88 -0
- package/scripts/mutants/meta-identity.json +57 -0
- package/scripts/mutants/mux-fresh-call.json +1 -1
- package/scripts/mutants/mux-resume-call.json +40 -4
- package/scripts/mutants/pi-durable.json +79 -0
- package/scripts/mutants/pi-retire-request.json +72 -0
- package/scripts/mutants/remote-pair-display.json +53 -0
- package/scripts/mutants/remote-pair-installed-audit.json +28 -0
- package/scripts/mutants/remote-pair-receive.json +126 -0
- package/scripts/mutants/remote-pair-roundtrip.json +83 -0
- package/scripts/mutants/remote-pair-send.json +194 -0
- package/scripts/mutants/remote-pair-transport.json +66 -0
- package/scripts/mutants/resume-launch-identity.json +35 -8
- package/scripts/mutants/retirement-event.json +195 -0
- package/scripts/mutants/v2-surface.json +16 -0
- package/scripts/mutants/v2-visible-resume.json +283 -13
- package/scripts/probe-bridge-command.ts +1 -0
- package/scripts/remote-pair-receive.ts +73 -0
- package/scripts/smoke-agy-install-state.sh +2 -2
- package/scripts/smoke-copilot-mcp-state.sh +1 -1
- package/scripts/smoke-herdr-raw-install-live.sh +5 -4
- package/scripts/smoke-meta-install-state.sh +4 -4
- package/scripts/smoke-mux-lifecycle-live.ts +10 -10
- package/scripts/smoke-omp-mcp-state.sh +1 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
- package/scripts/smoke-pi-provider-state.sh +3 -3
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remote-pair-transport — the production process adapter behind #107's `RemoteTransport` (rail 2c).
|
|
3
|
+
*
|
|
4
|
+
* One child per send: `spawn(argv[0], argv.slice(1))` with no shell, stdin written once and closed,
|
|
5
|
+
* stdout and stderr captured separately. On the bound it signals ITS OWN child only (TERM, then KILL
|
|
6
|
+
* after a short grace); if the pipes are still held after one more grace, it closes its own read ends
|
|
7
|
+
* and settles as a timeout rather than waiting for a close that may never come. It is not a process manager: it tracks no
|
|
8
|
+
* other process, scans nothing, and never retries. Classification belongs to
|
|
9
|
+
* `classifyRemoteReceipt`; this module only reports what the child did.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import type { RemoteTransport, RemoteTransportResult } from "./remote-pair-send.ts";
|
|
14
|
+
|
|
15
|
+
const CAPTURE_CAP = 64 * 1024;
|
|
16
|
+
const KILL_GRACE_MS = 2000;
|
|
17
|
+
const FINAL_GRACE_MS = 2000;
|
|
18
|
+
|
|
19
|
+
function capped(chunks: Buffer[], size: { n: number }, chunk: Buffer): void {
|
|
20
|
+
if (size.n >= CAPTURE_CAP) return;
|
|
21
|
+
const room = CAPTURE_CAP - size.n;
|
|
22
|
+
const piece = chunk.length > room ? chunk.subarray(0, room) : chunk;
|
|
23
|
+
chunks.push(piece);
|
|
24
|
+
size.n += piece.length;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const processTransport: RemoteTransport = {
|
|
28
|
+
run(argv, stdin, timeoutMs): Promise<RemoteTransportResult> {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const out: Buffer[] = [];
|
|
31
|
+
const err: Buffer[] = [];
|
|
32
|
+
const outN = { n: 0 };
|
|
33
|
+
const errN = { n: 0 };
|
|
34
|
+
let timedOut = false;
|
|
35
|
+
let spawnError: string | null = null;
|
|
36
|
+
let settled = false;
|
|
37
|
+
const child = spawn(argv[0], argv.slice(1), { shell: false, stdio: ["pipe", "pipe", "pipe"] });
|
|
38
|
+
let killTimer: NodeJS.Timeout | undefined;
|
|
39
|
+
let finalTimer: NodeJS.Timeout | undefined;
|
|
40
|
+
const settle = (code: number | null, signal: NodeJS.Signals | null): void => {
|
|
41
|
+
if (settled) return;
|
|
42
|
+
settled = true;
|
|
43
|
+
clearTimeout(bound);
|
|
44
|
+
if (killTimer) clearTimeout(killTimer);
|
|
45
|
+
if (finalTimer) clearTimeout(finalTimer);
|
|
46
|
+
const stderr = Buffer.concat(err).toString("utf8");
|
|
47
|
+
resolve({
|
|
48
|
+
exitCode: spawnError ? null : code,
|
|
49
|
+
signal: spawnError ? null : signal,
|
|
50
|
+
timedOut,
|
|
51
|
+
stdout: Buffer.concat(out).toString("utf8"),
|
|
52
|
+
stderr: spawnError ? `spawn failed: ${spawnError}${stderr ? `\n${stderr}` : ""}` : stderr,
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
const bound = setTimeout(() => {
|
|
56
|
+
timedOut = true;
|
|
57
|
+
child.kill("SIGTERM");
|
|
58
|
+
killTimer = setTimeout(() => {
|
|
59
|
+
child.kill("SIGKILL");
|
|
60
|
+
// Something else may still hold our stdout/stderr pipes (an inherited descendant,
|
|
61
|
+
// an ssh ControlMaster). We do not chase it: after one more grace we close OUR read
|
|
62
|
+
// ends and settle as a timeout, which the receipt reads as indeterminate.
|
|
63
|
+
finalTimer = setTimeout(() => {
|
|
64
|
+
child.stdout.destroy();
|
|
65
|
+
child.stderr.destroy();
|
|
66
|
+
settle(child.exitCode, child.signalCode);
|
|
67
|
+
}, FINAL_GRACE_MS);
|
|
68
|
+
}, KILL_GRACE_MS);
|
|
69
|
+
}, timeoutMs);
|
|
70
|
+
child.stdout.on("data", (c: Buffer) => capped(out, outN, c));
|
|
71
|
+
child.stderr.on("data", (c: Buffer) => capped(err, errN, c));
|
|
72
|
+
child.on("error", (e) => {
|
|
73
|
+
spawnError = e.message;
|
|
74
|
+
});
|
|
75
|
+
// A child that exits before reading stdin closes the pipe; that is its outcome, not ours.
|
|
76
|
+
child.stdin.on("error", () => {});
|
|
77
|
+
child.on("close", (code, signal) => settle(code, signal));
|
|
78
|
+
child.stdin.end(stdin);
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
};
|
|
@@ -1,29 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* resume-launch-identity — the RECORD-AUTHORITATIVE half of
|
|
3
|
-
*
|
|
2
|
+
* resume-launch-identity — the RECORD-AUTHORITATIVE half of a visible same-id resume: garden id →
|
|
3
|
+
* the meta-record → which native session is this citizen's, and where it lives.
|
|
4
4
|
*
|
|
5
|
-
* It
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* It began as the preamble of the retired `spawn-bg` watcher (garden id → the meta-record → the
|
|
6
|
+
* transcript that record claims → the provider/model/cwd that transcript's own header carries).
|
|
7
|
+
* None of that is about being hidden or visible; it is the answer to "which being is this, and
|
|
8
|
+
* which conversation is theirs". `entwurf-v2-visible-resume` is its consumer, and
|
|
9
|
+
* `check-resume-launch-identity` holds the record-integrity rules below — the transcript header id
|
|
10
|
+
* must equal `record.nativeSessionId`, an addressable read (#52) rather than a plain targeted one,
|
|
11
|
+
* a recorded ACP provider must resolve its bridge.
|
|
10
12
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* the transcript header
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* The domain is pi and claude-code (#147 L2). Both own their transcript and re-attach to the same
|
|
14
|
+
* record when their native session reopens; this leaf only names that session. For pi it reads
|
|
15
|
+
* the transcript header (the launch needs its provider/model/cwd). For claude-code it reads no
|
|
16
|
+
* transcript at all — the vendor owns it, and `claude --resume <id>` needs nothing from it — and
|
|
17
|
+
* checks only that the recorded path is an absolute file that still exists. Every other backend
|
|
18
|
+
* is a named refusal: native-push citizens have no resume authority (AGENTS Hard Rule 10), and a
|
|
19
|
+
* durable host is not reopened through Entwurf.
|
|
16
20
|
*
|
|
17
21
|
* It takes a bare garden id, not a plan: the plan type it used to destructure belonged to
|
|
18
22
|
* the removed transport, and the identity question never needed the rest of it.
|
|
19
23
|
*/
|
|
20
24
|
|
|
21
|
-
import { existsSync } from "node:fs";
|
|
25
|
+
import { existsSync, statSync } from "node:fs";
|
|
22
26
|
import path from "node:path";
|
|
23
27
|
import { AcpModelNotCuratedError, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
|
|
24
28
|
import { getEntwurfExplicitExtensions, readSessionIdentity } from "./entwurf-core.ts";
|
|
25
29
|
import { readAddressableMetaIdentity } from "./meta-session.ts";
|
|
26
30
|
|
|
31
|
+
/** The backends with a visible same-id resume. A closed set: nothing outside it is reopened. */
|
|
32
|
+
export const RESUME_BACKENDS = ["pi", "claude-code"] as const;
|
|
33
|
+
export type ResumeBackend = (typeof RESUME_BACKENDS)[number];
|
|
34
|
+
|
|
27
35
|
/**
|
|
28
36
|
* One of the TWO expected refusals on this leaf: the target is a citizen of a backend that has no
|
|
29
37
|
* same-id resume at all. The other is `AcpModelNotCuratedError` (#128, `acp/model-ids.js`).
|
|
@@ -33,20 +41,22 @@ import { readAddressableMetaIdentity } from "./meta-session.ts";
|
|
|
33
41
|
* bridge — and those keep their bare cause-rich `Error`, because a caller cannot act on them
|
|
34
42
|
* except by looking. A Claude Code or agy garden id is different in kind: nothing is broken,
|
|
35
43
|
* the operator simply asked a capability boundary to do something it does not cover, and the
|
|
36
|
-
* honest answer is a named refusal rather than an error report about a store that is fine.
|
|
44
|
+
* honest answer is a named refusal rather than an error report about a store that is fine. Since
|
|
45
|
+
* #147 L2 the boundary is `RESUME_BACKENDS`; a Codex, agy, Copilot, OMP or pi-durable garden id is
|
|
46
|
+
* this refusal.
|
|
37
47
|
*
|
|
38
48
|
* It carries a `reason` so the caller matches on a FIELD. Parsing the message string would make
|
|
39
49
|
* the wording load-bearing, and re-reading the record to ask "was it pi?" would read an address
|
|
40
50
|
* twice and could answer differently the second time.
|
|
41
51
|
*/
|
|
42
52
|
export class ResumeBackendUnsupportedError extends Error {
|
|
43
|
-
readonly reason = "target-
|
|
53
|
+
readonly reason = "target-backend-unsupported" as const;
|
|
44
54
|
readonly backend: string;
|
|
45
55
|
constructor(gardenId: string, backend: string) {
|
|
46
56
|
super(
|
|
47
57
|
`resume-launch-identity: ${gardenId} is a ${backend} citizen — same-id resume is a host-adapter relaunch ` +
|
|
48
|
-
`capability, and its domain
|
|
49
|
-
`This is a capability boundary, not
|
|
58
|
+
`capability, and its domain is ${RESUME_BACKENDS.join(" and ")}. ` +
|
|
59
|
+
`This is a capability boundary, not citizen rank.`,
|
|
50
60
|
);
|
|
51
61
|
this.name = "ResumeBackendUnsupportedError";
|
|
52
62
|
this.backend = backend;
|
|
@@ -55,7 +65,8 @@ export class ResumeBackendUnsupportedError extends Error {
|
|
|
55
65
|
|
|
56
66
|
/** The launch-time facts `buildResumePiArgs` needs, resolved from the meta-record + the
|
|
57
67
|
* recorded transcript (record authority, #50 C2/C3). */
|
|
58
|
-
export interface
|
|
68
|
+
export interface PiLaunchIdentity {
|
|
69
|
+
backend: "pi";
|
|
59
70
|
/** The EXACT session JSONL to resume — `pi --session <path>`. */
|
|
60
71
|
sessionFile: string;
|
|
61
72
|
cwd: string;
|
|
@@ -64,6 +75,79 @@ export interface LaunchIdentity {
|
|
|
64
75
|
model: string;
|
|
65
76
|
}
|
|
66
77
|
|
|
78
|
+
/**
|
|
79
|
+
* A Claude Code citizen's launch facts, all from the record. `nativeSessionId` is the argument of
|
|
80
|
+
* `claude --resume`; `createdAt` is carried so the observation can confirm the SAME record came
|
|
81
|
+
* back, not merely a record under the same garden id. `attachIncarnation` is the record's arm
|
|
82
|
+
* binding (#147 L2 B1), compared with the receiver marker's copy — `null` is carried as null,
|
|
83
|
+
* never filled. No model or permission flag is here: a resume does not restore the options a
|
|
84
|
+
* session was once started with.
|
|
85
|
+
*/
|
|
86
|
+
export interface ClaudeCodeLaunchIdentity {
|
|
87
|
+
backend: "claude-code";
|
|
88
|
+
nativeSessionId: string;
|
|
89
|
+
cwd: string;
|
|
90
|
+
createdAt: string;
|
|
91
|
+
attachIncarnation: string | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type LaunchIdentity = PiLaunchIdentity | ClaudeCodeLaunchIdentity;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Claude Code's session id grammar: a lowercase UUID. The value becomes an argv token after
|
|
98
|
+
* `--resume`, so anything else — a leading `-`, whitespace, a path — is refused as a bad record
|
|
99
|
+
* rather than handed to the vendor CLI as something it would parse differently.
|
|
100
|
+
*/
|
|
101
|
+
const CLAUDE_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
102
|
+
|
|
103
|
+
/** A claude-code record → its resume facts. Throws, with its own cause, on a record that cannot be resumed. */
|
|
104
|
+
function resolveClaudeCodeLaunchIdentity(
|
|
105
|
+
gardenId: string,
|
|
106
|
+
record: {
|
|
107
|
+
nativeSessionId: string;
|
|
108
|
+
cwd: string;
|
|
109
|
+
transcriptPath: string | null;
|
|
110
|
+
createdAt: string;
|
|
111
|
+
attachIncarnation: string | null;
|
|
112
|
+
},
|
|
113
|
+
): ClaudeCodeLaunchIdentity {
|
|
114
|
+
if (!CLAUDE_SESSION_ID.test(record.nativeSessionId)) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`resume-launch-identity: ${gardenId} records nativeSessionId ${JSON.stringify(record.nativeSessionId)}, ` +
|
|
117
|
+
`which is not a Claude Code session id (lowercase UUID) — refusing to hand it to claude --resume.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const transcript = record.transcriptPath;
|
|
121
|
+
if (!transcript || !path.isAbsolute(transcript)) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`resume-launch-identity: ${gardenId} records no absolute transcriptPath (${JSON.stringify(transcript)}) — ` +
|
|
124
|
+
`the birth hook writes one for every Claude Code session, so this record cannot name a session to resume.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
let isFile = false;
|
|
128
|
+
try {
|
|
129
|
+
isFile = statSync(transcript).isFile();
|
|
130
|
+
} catch {
|
|
131
|
+
isFile = false;
|
|
132
|
+
}
|
|
133
|
+
if (!isFile) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`resume-launch-identity: ${gardenId} recorded transcriptPath "${transcript}" is not a file on disk — ` +
|
|
136
|
+
`the session's transcript is gone, so claude --resume would open nothing.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (!path.isAbsolute(record.cwd)) {
|
|
140
|
+
throw new Error(`resume-launch-identity: ${gardenId} records a relative cwd ${JSON.stringify(record.cwd)}.`);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
backend: "claude-code",
|
|
144
|
+
nativeSessionId: record.nativeSessionId,
|
|
145
|
+
cwd: record.cwd,
|
|
146
|
+
createdAt: record.createdAt,
|
|
147
|
+
attachIncarnation: record.attachIncarnation,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
67
151
|
/**
|
|
68
152
|
* Resolve launch identity for a resume. The TARGET is now resolved through the
|
|
69
153
|
* meta-record (#50 C2): `gardenId → record.transcriptPath`. It used to be a global
|
|
@@ -95,6 +179,7 @@ export interface LaunchIdentity {
|
|
|
95
179
|
*/
|
|
96
180
|
export function resolveResumeLaunchIdentity(gardenId: string): LaunchIdentity {
|
|
97
181
|
const record = readAddressableMetaIdentity(gardenId);
|
|
182
|
+
if (record.backend === "claude-code") return resolveClaudeCodeLaunchIdentity(gardenId, record);
|
|
98
183
|
if (record.backend !== "pi") {
|
|
99
184
|
throw new ResumeBackendUnsupportedError(gardenId, record.backend);
|
|
100
185
|
}
|
|
@@ -161,6 +246,7 @@ export function resolveResumeLaunchIdentity(gardenId: string): LaunchIdentity {
|
|
|
161
246
|
const model = explicitExtensions.modelOverride ?? resumeModel;
|
|
162
247
|
if (provider === PROVIDER_ID && !isCuratedAcpModelId(model)) throw new AcpModelNotCuratedError(model);
|
|
163
248
|
return {
|
|
249
|
+
backend: "pi",
|
|
164
250
|
sessionFile,
|
|
165
251
|
cwd: identity.cwd,
|
|
166
252
|
explicitExtensionArgs: explicitExtensions.args,
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* retirement-event — the #106 B machine-readable retirement record's APPEND leaf (#147).
|
|
3
|
+
*
|
|
4
|
+
* One target-gid-keyed append-only sidecar per citizen: `<retirementsDir>/<targetGardenId>.jsonl`,
|
|
5
|
+
* one closed v1 JSON line per event. The event body names both ends explicitly
|
|
6
|
+
* (`actorGardenId` / `targetGardenId`) — the filename is never the only carrier.
|
|
7
|
+
*
|
|
8
|
+
* What this leaf is NOT:
|
|
9
|
+
* - not an identity field, liveness fact, dormancy proof, permission or grant. Nothing that
|
|
10
|
+
* decides dispatch, facts, peers or resume reads it; there is no production reader at all.
|
|
11
|
+
* - not a receipt framework. It appends exactly the event it is handed; minting `eventId`/`at`,
|
|
12
|
+
* mapping a dispatch outcome to a summary and the requested/outcome ordering belong to the
|
|
13
|
+
* caller's join.
|
|
14
|
+
* - not a retention, GC or scanning surface. Absence of a line is `unknown`, never "no retire".
|
|
15
|
+
*
|
|
16
|
+
* Path: the directory is an explicit argument. The composition computes it ONCE from its own
|
|
17
|
+
* fixed sessions directory with `retirementsDirBesideSessions`; this module reads no environment
|
|
18
|
+
* and resolves no home directory.
|
|
19
|
+
*
|
|
20
|
+
* IO boundary (Linux-certified, same stance as `readStoreRecordFile` in meta-session.ts):
|
|
21
|
+
* - the directory is created 0700 if absent (parent must exist); an existing entry must be a
|
|
22
|
+
* real directory, never a symlink.
|
|
23
|
+
* - the file is opened O_WRONLY|O_APPEND|O_CREAT|O_NOFOLLOW|O_NONBLOCK, 0600 on create. A final
|
|
24
|
+
* symlink fails (ELOOP), a directory fails (EISDIR), a FIFO never blocks (ENXIO without a
|
|
25
|
+
* reader; with one, the fstat check below refuses it).
|
|
26
|
+
* - the OPENED fd must be a regular file with exactly one link (a hardlink is not appended to).
|
|
27
|
+
* - the whole line is ONE writeSync; a short write is an error. A short write can leave a
|
|
28
|
+
* partial tail line in the file — that is reported, never hidden or repaired here.
|
|
29
|
+
* - fsyncSync, then close. fsync is a flush request to this file, not a power-loss or
|
|
30
|
+
* multi-file transaction guarantee.
|
|
31
|
+
* Residual, outside this leaf: ancestors of the directory are not checked; an existing file's
|
|
32
|
+
* mode is not changed; a same-user process that can rename entries in the directory can swap the
|
|
33
|
+
* name after open (the write still lands on the inode that was checked). The threat model is the
|
|
34
|
+
* same-user store every other meta sidecar shares.
|
|
35
|
+
*
|
|
36
|
+
* Errors: an invalid event or a refused entry throws `RetirementEventError`; a native fs error
|
|
37
|
+
* (ELOOP, EISDIR, ENXIO, EIO, …) is thrown as it is. Either way the append fails ONCE — no retry.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import * as fs from "node:fs";
|
|
41
|
+
import * as path from "node:path";
|
|
42
|
+
import { isValidSessionId } from "./session-id.js";
|
|
43
|
+
|
|
44
|
+
export const RETIREMENT_EVENT_VERSION = 1 as const;
|
|
45
|
+
export const RETIREMENTS_DIR_NAME = "meta-retirements";
|
|
46
|
+
export const RETIREMENT_EVENT_SUFFIX = ".jsonl";
|
|
47
|
+
|
|
48
|
+
export type RetirementTargetBackend = "pi" | "claude-code";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Which outcome statuses each backend can truthfully report: Pi's request boundaries
|
|
52
|
+
* (`shutdown-requested` is a request ACK, never an exit) and the Claude Code adapter's outcomes
|
|
53
|
+
* (`exited` is that fd-pinned process only). A cross-domain pair — Pi `exited`, Claude Code
|
|
54
|
+
* `shutdown-requested` — is refused, so it can never become a durable line. A dispatch-level
|
|
55
|
+
* unknown is `unknown` on either side.
|
|
56
|
+
*/
|
|
57
|
+
export const RETIREMENT_OUTCOME_STATUSES_BY_BACKEND = {
|
|
58
|
+
pi: ["shutdown-requested", "refused", "unknown"],
|
|
59
|
+
"claude-code": ["exited", "refused", "not-signalled", "unknown"],
|
|
60
|
+
} as const satisfies Record<RetirementTargetBackend, readonly string[]>;
|
|
61
|
+
export type RetirementOutcomeStatus = (typeof RETIREMENT_OUTCOME_STATUSES_BY_BACKEND)[RetirementTargetBackend][number];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A closed semantic summary. `reason` is null or a short lowercase token; it is never detail,
|
|
65
|
+
* cause, stderr or other free text. The token check is a FORMAT check, not a secret sanitizer.
|
|
66
|
+
* The adapters' reason values are typed `string` (not an enum): which of them may be recorded,
|
|
67
|
+
* or mapped to null, is the composition's join to decide, not this leaf's.
|
|
68
|
+
*/
|
|
69
|
+
export interface RetirementOutcomeSummary {
|
|
70
|
+
status: RetirementOutcomeStatus;
|
|
71
|
+
reason: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RetirementRequestedEvent {
|
|
75
|
+
v: typeof RETIREMENT_EVENT_VERSION;
|
|
76
|
+
kind: "retirement-requested";
|
|
77
|
+
eventId: string;
|
|
78
|
+
at: string;
|
|
79
|
+
actorGardenId: string;
|
|
80
|
+
targetGardenId: string;
|
|
81
|
+
targetBackend: RetirementTargetBackend;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface RetirementOutcomeEvent {
|
|
85
|
+
v: typeof RETIREMENT_EVENT_VERSION;
|
|
86
|
+
kind: "retirement-outcome";
|
|
87
|
+
eventId: string;
|
|
88
|
+
at: string;
|
|
89
|
+
actorGardenId: string;
|
|
90
|
+
targetGardenId: string;
|
|
91
|
+
targetBackend: RetirementTargetBackend;
|
|
92
|
+
outcome: RetirementOutcomeSummary;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type RetirementEvent = RetirementRequestedEvent | RetirementOutcomeEvent;
|
|
96
|
+
|
|
97
|
+
export class RetirementEventError extends Error {
|
|
98
|
+
constructor(message: string) {
|
|
99
|
+
super(message);
|
|
100
|
+
this.name = "RetirementEventError";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const EVENT_ID_RE = /^[0-9a-f]{32}$/;
|
|
105
|
+
/** The shape of `Date#toISOString()` output; calendar validity is the round-trip below. */
|
|
106
|
+
const AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
107
|
+
const REASON_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
108
|
+
|
|
109
|
+
const BASE_KEYS = ["v", "kind", "eventId", "at", "actorGardenId", "targetGardenId", "targetBackend"] as const;
|
|
110
|
+
const OUTCOME_KEYS = ["status", "reason"] as const;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read a plain object's own DATA properties exactly once. A foreign prototype (and with it an
|
|
114
|
+
* inherited `toJSON`), an accessor or a symbol key refuses, so what is validated is what is
|
|
115
|
+
* written: serialization only ever sees the fresh object built from these values.
|
|
116
|
+
*/
|
|
117
|
+
function snapshotPlain(value: unknown, what: string): Map<string, unknown> {
|
|
118
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
119
|
+
throw new RetirementEventError(`${what} must be an object`);
|
|
120
|
+
}
|
|
121
|
+
const proto = Object.getPrototypeOf(value);
|
|
122
|
+
if (proto !== Object.prototype && proto !== null) throw new RetirementEventError(`${what} must be a plain object`);
|
|
123
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
124
|
+
const fields = new Map<string, unknown>();
|
|
125
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
126
|
+
if (typeof key !== "string") throw new RetirementEventError(`${what} must not carry symbol keys`);
|
|
127
|
+
const descriptor = descriptors[key];
|
|
128
|
+
if (!("value" in descriptor)) throw new RetirementEventError(`${what}.${key} must be a data property`);
|
|
129
|
+
fields.set(key, descriptor.value);
|
|
130
|
+
}
|
|
131
|
+
return fields;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function requireExactKeys(fields: Map<string, unknown>, keys: readonly string[], what: string): void {
|
|
135
|
+
const extra = [...fields.keys()].filter((key) => !keys.includes(key));
|
|
136
|
+
const missing = keys.filter((key) => !fields.has(key));
|
|
137
|
+
if (extra.length > 0 || missing.length > 0) {
|
|
138
|
+
throw new RetirementEventError(
|
|
139
|
+
`${what} keyset is closed: missing [${missing.join(", ")}], not allowed [${extra.join(", ")}]`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Exactly `Date#toISOString()` of a real instant: 2026-02-30 or 24:00 does not round-trip. */
|
|
145
|
+
function isIsoInstant(value: unknown): value is string {
|
|
146
|
+
if (typeof value !== "string" || !AT_RE.test(value)) return false;
|
|
147
|
+
const ms = Date.parse(value);
|
|
148
|
+
return !Number.isNaN(ms) && new Date(ms).toISOString() === value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Validate ONE snapshot of the event and return its exact on-disk bytes (canonical key order, one
|
|
153
|
+
* UTF-8 line) together with the target id taken from that same snapshot. The append routes the
|
|
154
|
+
* file by this id, never by re-reading the caller's object.
|
|
155
|
+
*/
|
|
156
|
+
function validateRetirementEvent(event: RetirementEvent): { bytes: Buffer; targetGardenId: string } {
|
|
157
|
+
const f = snapshotPlain(event, "retirement event");
|
|
158
|
+
const kind = f.get("kind");
|
|
159
|
+
if (kind !== "retirement-requested" && kind !== "retirement-outcome") {
|
|
160
|
+
throw new RetirementEventError(`unknown retirement event kind: ${String(kind)}`);
|
|
161
|
+
}
|
|
162
|
+
requireExactKeys(f, kind === "retirement-outcome" ? [...BASE_KEYS, "outcome"] : BASE_KEYS, `${kind} event`);
|
|
163
|
+
const v = f.get("v");
|
|
164
|
+
const eventId = f.get("eventId");
|
|
165
|
+
const at = f.get("at");
|
|
166
|
+
const actorGardenId = f.get("actorGardenId");
|
|
167
|
+
const targetGardenId = f.get("targetGardenId");
|
|
168
|
+
const targetBackend = f.get("targetBackend");
|
|
169
|
+
if (v !== RETIREMENT_EVENT_VERSION) throw new RetirementEventError(`unsupported event version: ${String(v)}`);
|
|
170
|
+
if (typeof eventId !== "string" || !EVENT_ID_RE.test(eventId)) {
|
|
171
|
+
throw new RetirementEventError("eventId must be 32 lowercase hex characters");
|
|
172
|
+
}
|
|
173
|
+
if (!isIsoInstant(at)) throw new RetirementEventError("at must be a valid Date#toISOString() instant");
|
|
174
|
+
if (!isValidSessionId(actorGardenId)) throw new RetirementEventError("actorGardenId is not a garden id");
|
|
175
|
+
if (!isValidSessionId(targetGardenId)) throw new RetirementEventError("targetGardenId is not a garden id");
|
|
176
|
+
if (targetBackend !== "pi" && targetBackend !== "claude-code") {
|
|
177
|
+
throw new RetirementEventError(`unsupported targetBackend: ${String(targetBackend)}`);
|
|
178
|
+
}
|
|
179
|
+
const base = { v, kind, eventId, at, actorGardenId, targetGardenId, targetBackend };
|
|
180
|
+
const line = (body: object) => ({ bytes: Buffer.from(`${JSON.stringify(body)}\n`, "utf8"), targetGardenId });
|
|
181
|
+
if (kind === "retirement-requested") return line(base);
|
|
182
|
+
|
|
183
|
+
const o = snapshotPlain(f.get("outcome"), "outcome summary");
|
|
184
|
+
requireExactKeys(o, OUTCOME_KEYS, "outcome summary");
|
|
185
|
+
const status = o.get("status");
|
|
186
|
+
const reason = o.get("reason");
|
|
187
|
+
const allowed: readonly unknown[] = RETIREMENT_OUTCOME_STATUSES_BY_BACKEND[targetBackend];
|
|
188
|
+
if (!allowed.includes(status)) {
|
|
189
|
+
throw new RetirementEventError(`outcome status ${String(status)} is not a ${targetBackend} outcome`);
|
|
190
|
+
}
|
|
191
|
+
if (reason !== null && (typeof reason !== "string" || !REASON_RE.test(reason))) {
|
|
192
|
+
throw new RetirementEventError("outcome reason must be null or a short lowercase token");
|
|
193
|
+
}
|
|
194
|
+
return line({ ...base, outcome: { status, reason } });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Validate one event and return its exact on-disk bytes: canonical key order, one UTF-8 line. */
|
|
198
|
+
export function serializeRetirementEvent(event: RetirementEvent): Buffer {
|
|
199
|
+
return validateRetirementEvent(event).bytes;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Pure path: the sidecar directory beside a FIXED sessions directory. */
|
|
203
|
+
export function retirementsDirBesideSessions(sessionsDir: string): string {
|
|
204
|
+
if (!path.isAbsolute(sessionsDir)) throw new RetirementEventError("sessionsDir must be an absolute path");
|
|
205
|
+
return path.join(path.dirname(path.resolve(sessionsDir)), RETIREMENTS_DIR_NAME);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function retirementEventFile(retirementsDir: string, targetGardenId: string): string {
|
|
209
|
+
if (!path.isAbsolute(retirementsDir)) throw new RetirementEventError("retirementsDir must be an absolute path");
|
|
210
|
+
if (!isValidSessionId(targetGardenId)) throw new RetirementEventError("targetGardenId is not a garden id");
|
|
211
|
+
return path.join(retirementsDir, `${targetGardenId}${RETIREMENT_EVENT_SUFFIX}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function ensureRealDirectory(dir: string): void {
|
|
215
|
+
try {
|
|
216
|
+
fs.mkdirSync(dir, { mode: 0o700 });
|
|
217
|
+
} catch (err) {
|
|
218
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
219
|
+
}
|
|
220
|
+
const st = fs.lstatSync(dir);
|
|
221
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
222
|
+
throw new RetirementEventError(`${path.basename(dir)} is not a real directory; refusing to append`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Append ONE validated event line to the target's sidecar. Throws once on any failure. */
|
|
227
|
+
export function appendRetirementEvent(retirementsDir: string, event: RetirementEvent): { bytes: number } {
|
|
228
|
+
const { bytes, targetGardenId } = validateRetirementEvent(event);
|
|
229
|
+
const file = retirementEventFile(retirementsDir, targetGardenId);
|
|
230
|
+
const { O_WRONLY, O_APPEND, O_CREAT, O_NOFOLLOW, O_NONBLOCK } = fs.constants;
|
|
231
|
+
if (!O_NOFOLLOW || !O_NONBLOCK) {
|
|
232
|
+
throw new RetirementEventError(
|
|
233
|
+
"this platform exposes no O_NOFOLLOW/O_NONBLOCK, so the append cannot refuse a symlink or FIFO",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
ensureRealDirectory(retirementsDir);
|
|
237
|
+
const fd = fs.openSync(file, O_WRONLY | O_APPEND | O_CREAT | O_NOFOLLOW | O_NONBLOCK, 0o600);
|
|
238
|
+
try {
|
|
239
|
+
const st = fs.fstatSync(fd);
|
|
240
|
+
if (!st.isFile()) throw new RetirementEventError(`${path.basename(file)} is not a regular file`);
|
|
241
|
+
if (st.nlink !== 1) throw new RetirementEventError(`${path.basename(file)} has ${st.nlink} links; refusing`);
|
|
242
|
+
const written = fs.writeSync(fd, bytes, 0, bytes.length);
|
|
243
|
+
if (written !== bytes.length) {
|
|
244
|
+
throw new RetirementEventError(
|
|
245
|
+
`short write: ${written} of ${bytes.length} bytes; the file may now end in a partial line`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
fs.fsyncSync(fd);
|
|
249
|
+
} finally {
|
|
250
|
+
fs.closeSync(fd);
|
|
251
|
+
}
|
|
252
|
+
return { bytes: bytes.length };
|
|
253
|
+
}
|