@junghanacs/entwurf 0.40.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.
Files changed (118) hide show
  1. package/AGENTS.md +4 -2
  2. package/BASELINE.md +55 -2
  3. package/CHANGELOG.md +321 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +18 -2
  6. package/FAQ.md +8 -4
  7. package/README.md +26 -8
  8. package/VERIFY.md +13 -10
  9. package/docs/acp-backend-rail.md +5 -1
  10. package/docs/external-mcp-host.md +8 -6
  11. package/docs/fresh-cut-policy.md +9 -1
  12. package/docs/setup-clean-host.md +24 -8
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +81 -19
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
  32. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
  33. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  34. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  35. package/mcp/entwurf-bridge/src/index.ts +93 -19
  36. package/mcp/entwurf-bridge/test.sh +2 -2
  37. package/mcp/entwurf-bridge/tsconfig.build.json +10 -1
  38. package/package.json +4 -4
  39. package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
  40. package/pi-extensions/entwurf-control.ts +55 -20
  41. package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  42. package/pi-extensions/lib/cc-retire-request.ts +553 -0
  43. package/pi-extensions/lib/control-retire.ts +195 -0
  44. package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
  45. package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
  46. package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
  48. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
  49. package/pi-extensions/lib/fresh-call-composition.ts +15 -0
  50. package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
  51. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  52. package/pi-extensions/lib/meta-session.ts +140 -16
  53. package/pi-extensions/lib/mux-fresh-call.ts +7 -27
  54. package/pi-extensions/lib/mux-launch.ts +29 -0
  55. package/pi-extensions/lib/mux-resume-call.ts +33 -12
  56. package/pi-extensions/lib/pi-retire-request.ts +111 -0
  57. package/pi-extensions/lib/resume-launch-identity.ts +104 -18
  58. package/pi-extensions/lib/retirement-event.ts +253 -0
  59. package/pi-extensions/meta-bridge-hook.ts +142 -13
  60. package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
  61. package/run.sh +78 -7
  62. package/scripts/agy-bridge-config.py +4 -1
  63. package/scripts/build-bridge.sh +14 -0
  64. package/scripts/check-capability-bundle-reach.ts +135 -2
  65. package/scripts/check-entwurf-bridge-boot.ts +2 -1
  66. package/scripts/check-entwurf-fact-provider.ts +1 -0
  67. package/scripts/check-entwurf-v2-production.ts +2 -0
  68. package/scripts/check-entwurf-v2-surface.ts +7 -0
  69. package/scripts/check-gate-qualification.ts +14 -8
  70. package/scripts/check-herdr-activation.ts +2 -2
  71. package/scripts/check-herdr-placement.ts +1 -0
  72. package/scripts/check-hook-launch-topology.ts +6 -15
  73. package/scripts/check-install-container.sh +2 -2
  74. package/scripts/check-meta-doctor-oracle.sh +27 -1
  75. package/scripts/check-meta-facts.ts +8 -3
  76. package/scripts/check-meta-hook-session-switch.ts +230 -13
  77. package/scripts/check-meta-identity-consumers.ts +1 -0
  78. package/scripts/check-meta-listing.ts +1 -0
  79. package/scripts/check-meta-manifest-schema.py +1 -1
  80. package/scripts/check-meta-session.ts +31 -0
  81. package/scripts/check-meta-v3-record.ts +55 -1
  82. package/scripts/check-mux-resume-call.ts +35 -9
  83. package/scripts/check-peer-facts.ts +1 -0
  84. package/scripts/check-pi-durable-contact.ts +3 -1
  85. package/scripts/check-pi-durable-send.ts +4 -2
  86. package/scripts/check-pi-mcp-bridge.ts +1 -0
  87. package/scripts/check-resume-launch-identity.ts +62 -23
  88. package/scripts/lib/meta-hook-bundle.ts +72 -0
  89. package/scripts/meta-bridge-claude-floor.sh +13 -9
  90. package/scripts/meta-bridge-doctor.sh +17 -0
  91. package/scripts/meta-bridge-install.sh +6 -0
  92. package/scripts/meta-facts.ts +1 -1
  93. package/scripts/mutants/bridge-boot-resume.json +5 -2
  94. package/scripts/mutants/cc-retire-pidfd.json +129 -0
  95. package/scripts/mutants/cc-retire-request.json +171 -0
  96. package/scripts/mutants/control-retire.json +105 -0
  97. package/scripts/mutants/copilot-receive.json +2 -6
  98. package/scripts/mutants/entwurf-v2-retire.json +476 -0
  99. package/scripts/mutants/fresh-call-dispatch.json +37 -0
  100. package/scripts/mutants/meta-hook-session-switch.json +88 -0
  101. package/scripts/mutants/meta-identity.json +57 -0
  102. package/scripts/mutants/mux-fresh-call.json +1 -1
  103. package/scripts/mutants/mux-resume-call.json +40 -4
  104. package/scripts/mutants/pi-durable.json +79 -0
  105. package/scripts/mutants/pi-retire-request.json +72 -0
  106. package/scripts/mutants/resume-launch-identity.json +35 -8
  107. package/scripts/mutants/retirement-event.json +195 -0
  108. package/scripts/mutants/v2-surface.json +16 -0
  109. package/scripts/mutants/v2-visible-resume.json +283 -13
  110. package/scripts/probe-bridge-command.ts +1 -0
  111. package/scripts/smoke-agy-install-state.sh +2 -2
  112. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  113. package/scripts/smoke-herdr-raw-install-live.sh +5 -4
  114. package/scripts/smoke-meta-install-state.sh +4 -4
  115. package/scripts/smoke-mux-lifecycle-live.ts +10 -10
  116. package/scripts/smoke-omp-mcp-state.sh +1 -1
  117. package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
  118. package/scripts/smoke-pi-provider-state.sh +3 -3
@@ -0,0 +1,111 @@
1
+ /** Pi core-side request adapter, not a public tool or authorization boundary.
2
+ * The caller supplies a CERTIFIED MetaIdentity and owns authorization, self-retire refusal,
3
+ * target locking and record freshness. No second lock, record scan or receiver admission
4
+ * implementation lives here. The receiver owns generation/TUI/idle/pending validation.
5
+ */
6
+ import * as os from "node:os";
7
+ import type { RetireResult } from "./control-retire.js";
8
+ import { controlSocketPathIn, defaultControlSocketDir } from "./control-socket-path.js";
9
+ import { type RpcRetireCommand, sendRpcCommand } from "./entwurf-control-rpc.ts";
10
+ import type { MetaIdentity } from "./meta-session.js";
11
+ import { SESSION_ID_RE } from "./session-id.js";
12
+
13
+ type ReceiverRefusal = Extract<RetireResult, { boundary: "refused" }>["reason"];
14
+ const RECEIVER_REFUSALS: readonly ReceiverRefusal[] = [
15
+ "target-closing",
16
+ "target-stale",
17
+ "target-context-unavailable",
18
+ "target-non-tui",
19
+ "target-busy",
20
+ "target-pending",
21
+ ];
22
+
23
+ export type PiRetireRequestResult =
24
+ | { boundary: "shutdown-requested" }
25
+ | { boundary: "refused"; phase: "preflight"; reason: "target-backend-unsupported" | "target-identity-mismatch" }
26
+ | { boundary: "refused"; phase: "receiver"; reason: ReceiverRefusal }
27
+ | { boundary: "unknown"; reason: "malformed-response" | "transport-error" | "shutdown-call-failed" };
28
+
29
+ function object(value: unknown): value is Record<string, unknown> {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
31
+ }
32
+
33
+ function exactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
34
+ const actual = Object.keys(value);
35
+ return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
36
+ }
37
+
38
+ /** Closed wire combinations only. A malformed answer AFTER sending cannot prove no action.
39
+ * SDK request acceptance is not exit, persistence/flush, or no-further-model-activity evidence.
40
+ */
41
+ export function parsePiRetireResponse(response: unknown): PiRetireRequestResult {
42
+ const malformed: PiRetireRequestResult = { boundary: "unknown", reason: "malformed-response" };
43
+ if (!object(response) || response.type !== "response" || response.command !== "retire") return malformed;
44
+ if (Object.keys(response).some((key) => !["type", "command", "success", "data", "error", "id"].includes(key))) {
45
+ return malformed;
46
+ }
47
+ if (Object.hasOwn(response, "id") && typeof response.id !== "string") return malformed;
48
+ const data = response.data;
49
+ if (!object(data)) return malformed;
50
+ if (response.success === true && data.boundary === "shutdown-requested") {
51
+ if (!exactKeys(data, ["boundary"]) || Object.hasOwn(response, "error")) return malformed;
52
+ return { boundary: "shutdown-requested" };
53
+ }
54
+ if (response.success !== false || !exactKeys(data, ["boundary", "reason"]) || response.error !== data.reason) {
55
+ return malformed;
56
+ }
57
+ if (data.boundary === "refused" && RECEIVER_REFUSALS.includes(data.reason as ReceiverRefusal)) {
58
+ return { boundary: "refused", phase: "receiver", reason: data.reason as ReceiverRefusal };
59
+ }
60
+ if (data.boundary === "unknown" && data.reason === "shutdown-call-failed") {
61
+ return { boundary: "unknown", reason: "shutdown-call-failed" };
62
+ }
63
+ return malformed;
64
+ }
65
+
66
+ export interface PiRetireRequestOptions {
67
+ /** Internal composition root's existing socket directory; never a target-supplied path. */
68
+ controlSocketDir?: string;
69
+ /** Bounds this ONE transport request; a timeout never starts a retry/fallback. */
70
+ timeoutMs?: number;
71
+ /** Existing RPC seam, injectable for deterministic wire-result tests. */
72
+ sendRpc?: (
73
+ socketPath: string,
74
+ command: RpcRetireCommand,
75
+ options: { timeout: number },
76
+ ) => Promise<{ response: unknown }>;
77
+ }
78
+
79
+ export async function requestPiRetire(
80
+ target: MetaIdentity,
81
+ expected: { gardenId: string; nativeSessionId: string },
82
+ options: PiRetireRequestOptions = {},
83
+ ): Promise<PiRetireRequestResult> {
84
+ if (target.backend !== "pi") {
85
+ return { boundary: "refused", phase: "preflight", reason: "target-backend-unsupported" };
86
+ }
87
+ if (
88
+ target.gardenId !== expected.gardenId ||
89
+ target.nativeSessionId !== expected.nativeSessionId ||
90
+ !SESSION_ID_RE.test(target.gardenId) ||
91
+ target.nativeSessionId.length === 0
92
+ ) {
93
+ return { boundary: "refused", phase: "preflight", reason: "target-identity-mismatch" };
94
+ }
95
+ const socketPath = controlSocketPathIn(
96
+ options.controlSocketDir ?? defaultControlSocketDir(os.homedir()),
97
+ target.gardenId,
98
+ );
99
+ try {
100
+ const { response } = await (options.sendRpc ?? sendRpcCommand)(
101
+ socketPath,
102
+ { type: "retire", gardenId: target.gardenId, nativeSessionId: target.nativeSessionId },
103
+ { timeout: options.timeoutMs ?? 5000 },
104
+ );
105
+ return parsePiRetireResponse(response);
106
+ } catch {
107
+ // The RPC API does not expose whether bytes were written. Even connection errors
108
+ // are conservatively unknown here; do not infer a safe pre-send refusal or retry.
109
+ return { boundary: "unknown", reason: "transport-error" };
110
+ }
111
+ }
@@ -1,29 +1,37 @@
1
1
  /**
2
- * resume-launch-identity — the RECORD-AUTHORITATIVE half of resuming a pi citizen,
3
- * kept after the visible-first cut removed everything that used to call it.
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 was the preamble of the `spawn-bg` watcher's `spawnChild` (entwurf-v2-spawn-production.ts,
6
- * deleted with that transport): garden id → the meta-record → the transcript that record
7
- * claims → the provider/model/cwd that transcript's own header carries. None of that is
8
- * about being hidden or visible. It is the answer to "which being is this, and which
9
- * conversation is theirs", and it is the same answer a VISIBLE resume will need.
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
- * So this leaf is preserved deliberately, with no consumer in the shipped tree today.
12
- * `check-resume-launch-identity` holds its contract so the record-integrity rules below —
13
- * the transcript header id must equal `record.nativeSessionId`, an addressable read
14
- * (#52) rather than a plain targeted one, a recorded ACP provider must resolve its
15
- * bridge — cannot rot while they wait.
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-not-pi" as const;
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 currently contains backend pi only (only pi stands a control socket up). ` +
49
- `This is a capability boundary, not the control-socket rail and not citizen rank.`,
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 LaunchIdentity {
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
+ }