@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,211 @@
|
|
|
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
|
+
import * as fs from "node:fs";
|
|
40
|
+
import * as path from "node:path";
|
|
41
|
+
import { isValidSessionId } from "./session-id.js";
|
|
42
|
+
export const RETIREMENT_EVENT_VERSION = 1;
|
|
43
|
+
export const RETIREMENTS_DIR_NAME = "meta-retirements";
|
|
44
|
+
export const RETIREMENT_EVENT_SUFFIX = ".jsonl";
|
|
45
|
+
/**
|
|
46
|
+
* Which outcome statuses each backend can truthfully report: Pi's request boundaries
|
|
47
|
+
* (`shutdown-requested` is a request ACK, never an exit) and the Claude Code adapter's outcomes
|
|
48
|
+
* (`exited` is that fd-pinned process only). A cross-domain pair — Pi `exited`, Claude Code
|
|
49
|
+
* `shutdown-requested` — is refused, so it can never become a durable line. A dispatch-level
|
|
50
|
+
* unknown is `unknown` on either side.
|
|
51
|
+
*/
|
|
52
|
+
export const RETIREMENT_OUTCOME_STATUSES_BY_BACKEND = {
|
|
53
|
+
pi: ["shutdown-requested", "refused", "unknown"],
|
|
54
|
+
"claude-code": ["exited", "refused", "not-signalled", "unknown"],
|
|
55
|
+
};
|
|
56
|
+
export class RetirementEventError extends Error {
|
|
57
|
+
constructor(message) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = "RetirementEventError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const EVENT_ID_RE = /^[0-9a-f]{32}$/;
|
|
63
|
+
/** The shape of `Date#toISOString()` output; calendar validity is the round-trip below. */
|
|
64
|
+
const AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
65
|
+
const REASON_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
66
|
+
const BASE_KEYS = ["v", "kind", "eventId", "at", "actorGardenId", "targetGardenId", "targetBackend"];
|
|
67
|
+
const OUTCOME_KEYS = ["status", "reason"];
|
|
68
|
+
/**
|
|
69
|
+
* Read a plain object's own DATA properties exactly once. A foreign prototype (and with it an
|
|
70
|
+
* inherited `toJSON`), an accessor or a symbol key refuses, so what is validated is what is
|
|
71
|
+
* written: serialization only ever sees the fresh object built from these values.
|
|
72
|
+
*/
|
|
73
|
+
function snapshotPlain(value, what) {
|
|
74
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
75
|
+
throw new RetirementEventError(`${what} must be an object`);
|
|
76
|
+
}
|
|
77
|
+
const proto = Object.getPrototypeOf(value);
|
|
78
|
+
if (proto !== Object.prototype && proto !== null)
|
|
79
|
+
throw new RetirementEventError(`${what} must be a plain object`);
|
|
80
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
81
|
+
const fields = new Map();
|
|
82
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
83
|
+
if (typeof key !== "string")
|
|
84
|
+
throw new RetirementEventError(`${what} must not carry symbol keys`);
|
|
85
|
+
const descriptor = descriptors[key];
|
|
86
|
+
if (!("value" in descriptor))
|
|
87
|
+
throw new RetirementEventError(`${what}.${key} must be a data property`);
|
|
88
|
+
fields.set(key, descriptor.value);
|
|
89
|
+
}
|
|
90
|
+
return fields;
|
|
91
|
+
}
|
|
92
|
+
function requireExactKeys(fields, keys, what) {
|
|
93
|
+
const extra = [...fields.keys()].filter((key) => !keys.includes(key));
|
|
94
|
+
const missing = keys.filter((key) => !fields.has(key));
|
|
95
|
+
if (extra.length > 0 || missing.length > 0) {
|
|
96
|
+
throw new RetirementEventError(`${what} keyset is closed: missing [${missing.join(", ")}], not allowed [${extra.join(", ")}]`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Exactly `Date#toISOString()` of a real instant: 2026-02-30 or 24:00 does not round-trip. */
|
|
100
|
+
function isIsoInstant(value) {
|
|
101
|
+
if (typeof value !== "string" || !AT_RE.test(value))
|
|
102
|
+
return false;
|
|
103
|
+
const ms = Date.parse(value);
|
|
104
|
+
return !Number.isNaN(ms) && new Date(ms).toISOString() === value;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Validate ONE snapshot of the event and return its exact on-disk bytes (canonical key order, one
|
|
108
|
+
* UTF-8 line) together with the target id taken from that same snapshot. The append routes the
|
|
109
|
+
* file by this id, never by re-reading the caller's object.
|
|
110
|
+
*/
|
|
111
|
+
function validateRetirementEvent(event) {
|
|
112
|
+
const f = snapshotPlain(event, "retirement event");
|
|
113
|
+
const kind = f.get("kind");
|
|
114
|
+
if (kind !== "retirement-requested" && kind !== "retirement-outcome") {
|
|
115
|
+
throw new RetirementEventError(`unknown retirement event kind: ${String(kind)}`);
|
|
116
|
+
}
|
|
117
|
+
requireExactKeys(f, kind === "retirement-outcome" ? [...BASE_KEYS, "outcome"] : BASE_KEYS, `${kind} event`);
|
|
118
|
+
const v = f.get("v");
|
|
119
|
+
const eventId = f.get("eventId");
|
|
120
|
+
const at = f.get("at");
|
|
121
|
+
const actorGardenId = f.get("actorGardenId");
|
|
122
|
+
const targetGardenId = f.get("targetGardenId");
|
|
123
|
+
const targetBackend = f.get("targetBackend");
|
|
124
|
+
if (v !== RETIREMENT_EVENT_VERSION)
|
|
125
|
+
throw new RetirementEventError(`unsupported event version: ${String(v)}`);
|
|
126
|
+
if (typeof eventId !== "string" || !EVENT_ID_RE.test(eventId)) {
|
|
127
|
+
throw new RetirementEventError("eventId must be 32 lowercase hex characters");
|
|
128
|
+
}
|
|
129
|
+
if (!isIsoInstant(at))
|
|
130
|
+
throw new RetirementEventError("at must be a valid Date#toISOString() instant");
|
|
131
|
+
if (!isValidSessionId(actorGardenId))
|
|
132
|
+
throw new RetirementEventError("actorGardenId is not a garden id");
|
|
133
|
+
if (!isValidSessionId(targetGardenId))
|
|
134
|
+
throw new RetirementEventError("targetGardenId is not a garden id");
|
|
135
|
+
if (targetBackend !== "pi" && targetBackend !== "claude-code") {
|
|
136
|
+
throw new RetirementEventError(`unsupported targetBackend: ${String(targetBackend)}`);
|
|
137
|
+
}
|
|
138
|
+
const base = { v, kind, eventId, at, actorGardenId, targetGardenId, targetBackend };
|
|
139
|
+
const line = (body) => ({ bytes: Buffer.from(`${JSON.stringify(body)}\n`, "utf8"), targetGardenId });
|
|
140
|
+
if (kind === "retirement-requested")
|
|
141
|
+
return line(base);
|
|
142
|
+
const o = snapshotPlain(f.get("outcome"), "outcome summary");
|
|
143
|
+
requireExactKeys(o, OUTCOME_KEYS, "outcome summary");
|
|
144
|
+
const status = o.get("status");
|
|
145
|
+
const reason = o.get("reason");
|
|
146
|
+
const allowed = RETIREMENT_OUTCOME_STATUSES_BY_BACKEND[targetBackend];
|
|
147
|
+
if (!allowed.includes(status)) {
|
|
148
|
+
throw new RetirementEventError(`outcome status ${String(status)} is not a ${targetBackend} outcome`);
|
|
149
|
+
}
|
|
150
|
+
if (reason !== null && (typeof reason !== "string" || !REASON_RE.test(reason))) {
|
|
151
|
+
throw new RetirementEventError("outcome reason must be null or a short lowercase token");
|
|
152
|
+
}
|
|
153
|
+
return line({ ...base, outcome: { status, reason } });
|
|
154
|
+
}
|
|
155
|
+
/** Validate one event and return its exact on-disk bytes: canonical key order, one UTF-8 line. */
|
|
156
|
+
export function serializeRetirementEvent(event) {
|
|
157
|
+
return validateRetirementEvent(event).bytes;
|
|
158
|
+
}
|
|
159
|
+
/** Pure path: the sidecar directory beside a FIXED sessions directory. */
|
|
160
|
+
export function retirementsDirBesideSessions(sessionsDir) {
|
|
161
|
+
if (!path.isAbsolute(sessionsDir))
|
|
162
|
+
throw new RetirementEventError("sessionsDir must be an absolute path");
|
|
163
|
+
return path.join(path.dirname(path.resolve(sessionsDir)), RETIREMENTS_DIR_NAME);
|
|
164
|
+
}
|
|
165
|
+
export function retirementEventFile(retirementsDir, targetGardenId) {
|
|
166
|
+
if (!path.isAbsolute(retirementsDir))
|
|
167
|
+
throw new RetirementEventError("retirementsDir must be an absolute path");
|
|
168
|
+
if (!isValidSessionId(targetGardenId))
|
|
169
|
+
throw new RetirementEventError("targetGardenId is not a garden id");
|
|
170
|
+
return path.join(retirementsDir, `${targetGardenId}${RETIREMENT_EVENT_SUFFIX}`);
|
|
171
|
+
}
|
|
172
|
+
function ensureRealDirectory(dir) {
|
|
173
|
+
try {
|
|
174
|
+
fs.mkdirSync(dir, { mode: 0o700 });
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
if (err.code !== "EEXIST")
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
const st = fs.lstatSync(dir);
|
|
181
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
182
|
+
throw new RetirementEventError(`${path.basename(dir)} is not a real directory; refusing to append`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** Append ONE validated event line to the target's sidecar. Throws once on any failure. */
|
|
186
|
+
export function appendRetirementEvent(retirementsDir, event) {
|
|
187
|
+
const { bytes, targetGardenId } = validateRetirementEvent(event);
|
|
188
|
+
const file = retirementEventFile(retirementsDir, targetGardenId);
|
|
189
|
+
const { O_WRONLY, O_APPEND, O_CREAT, O_NOFOLLOW, O_NONBLOCK } = fs.constants;
|
|
190
|
+
if (!O_NOFOLLOW || !O_NONBLOCK) {
|
|
191
|
+
throw new RetirementEventError("this platform exposes no O_NOFOLLOW/O_NONBLOCK, so the append cannot refuse a symlink or FIFO");
|
|
192
|
+
}
|
|
193
|
+
ensureRealDirectory(retirementsDir);
|
|
194
|
+
const fd = fs.openSync(file, O_WRONLY | O_APPEND | O_CREAT | O_NOFOLLOW | O_NONBLOCK, 0o600);
|
|
195
|
+
try {
|
|
196
|
+
const st = fs.fstatSync(fd);
|
|
197
|
+
if (!st.isFile())
|
|
198
|
+
throw new RetirementEventError(`${path.basename(file)} is not a regular file`);
|
|
199
|
+
if (st.nlink !== 1)
|
|
200
|
+
throw new RetirementEventError(`${path.basename(file)} has ${st.nlink} links; refusing`);
|
|
201
|
+
const written = fs.writeSync(fd, bytes, 0, bytes.length);
|
|
202
|
+
if (written !== bytes.length) {
|
|
203
|
+
throw new RetirementEventError(`short write: ${written} of ${bytes.length} bytes; the file may now end in a partial line`);
|
|
204
|
+
}
|
|
205
|
+
fs.fsyncSync(fd);
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
fs.closeSync(fd);
|
|
209
|
+
}
|
|
210
|
+
return { bytes: bytes.length };
|
|
211
|
+
}
|
|
@@ -53,13 +53,26 @@
|
|
|
53
53
|
* closure (build-bridge → dist), because Node REFUSES strip-types on a `.ts` below
|
|
54
54
|
* node_modules (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). The installer picks
|
|
55
55
|
* the artifact by layout (0.12.5), mirroring start.sh / store-doctor.
|
|
56
|
-
* Imports `./lib/meta-session.ts` (+ `./lib/session-id.js`);
|
|
57
|
-
* rewrites
|
|
58
|
-
* copies
|
|
56
|
+
* Imports `./lib/meta-session.ts` and `./lib/entwurf-v2-lock.ts` (+ `./lib/session-id.js`);
|
|
57
|
+
* `rewriteRelativeImportExtensions` rewrites those to `.js` in the emitted closure. The
|
|
58
|
+
* installer copies exactly those lib files alongside this file so `${CLAUDE_PLUGIN_ROOT}`
|
|
59
|
+
* self-locates them.
|
|
60
|
+
*
|
|
61
|
+
* RECORD-ARM LOCK (#147 L2 B1). Every write this hook makes to an EXISTING record happens
|
|
62
|
+
* under a per-gid lock (`defaultMetaRecordArmLocksDir`, the shared `acquireLock` primitive in
|
|
63
|
+
* its own directory — never the v2 dispatch lock dir, which visible resume holds across its
|
|
64
|
+
* observation). The garden id is learned by a READ-ONLY certified lookup; a record found
|
|
65
|
+
* absent is published create-only. On an arm-capable event the lock is held from the upsert
|
|
66
|
+
* that commits a fresh `attachIncarnation` through the receiver marker that copies it, so two
|
|
67
|
+
* normal arms (or an arm and a UserPromptSubmit) always finish as a matching record/marker
|
|
68
|
+
* pair. An arm that cannot take the lock within its bound writes NOTHING and logs ERROR: that
|
|
69
|
+
* attach is not committed, and a live native process may then exist that no record names — a
|
|
70
|
+
* boundary, not a refusal. A UserPromptSubmit that finds the lock busy skips its backfill.
|
|
59
71
|
*/
|
|
60
72
|
import * as fs from "node:fs";
|
|
61
73
|
import * as path from "node:path";
|
|
62
|
-
import {
|
|
74
|
+
import { acquireLock, releaseLock } from "./lib/entwurf-v2-lock.js";
|
|
75
|
+
import { defaultMetaMailboxDir, defaultMetaRecordArmLocksDir, defaultMetaSessionsDir, findCertifiedIdentityByNativeSessionId, isPlausibleOwnerPid, mintAttachIncarnation, readMetaSenderMarker, removeMetaReceiverMarker, UpsertActionMismatchError, upsertMetaSession, writeMetaReceiverMarker, writeMetaSenderMarker, } from "./lib/meta-session.js";
|
|
63
76
|
function logLine(level, message) {
|
|
64
77
|
try {
|
|
65
78
|
// dirname(meta-sessions) == the pi agent dir — no extra resolver export needed.
|
|
@@ -71,8 +84,59 @@ function logLine(level, message) {
|
|
|
71
84
|
/* logging is best-effort; a broken log must not break the session */
|
|
72
85
|
}
|
|
73
86
|
}
|
|
87
|
+
/** The record-arm lock this run holds, released on every exit path (see {@link emit}). */
|
|
88
|
+
let heldArmLock = null;
|
|
89
|
+
/** Arm-capable events wait this long for the record-arm lock; the critical section is a few file writes. */
|
|
90
|
+
const ARM_LOCK_WAIT_MS = 2_000;
|
|
91
|
+
const ARM_LOCK_POLL_MS = 25;
|
|
92
|
+
function sleepSync(ms) {
|
|
93
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Take the record-arm lock for `gardenId`: ONE attempt, or (arm-capable events) polled up to
|
|
97
|
+
* {@link ARM_LOCK_WAIT_MS}. A throw from the primitive (its claim could not be written) is
|
|
98
|
+
* the same answer as busy — the caller writes nothing.
|
|
99
|
+
*/
|
|
100
|
+
function takeArmLock(gardenId, wait) {
|
|
101
|
+
const dir = defaultMetaRecordArmLocksDir();
|
|
102
|
+
const deadline = Date.now() + (wait ? ARM_LOCK_WAIT_MS : 0);
|
|
103
|
+
for (;;) {
|
|
104
|
+
let detail;
|
|
105
|
+
try {
|
|
106
|
+
const acquired = acquireLock(gardenId, { dir });
|
|
107
|
+
if (acquired.ok) {
|
|
108
|
+
heldArmLock = acquired.claim;
|
|
109
|
+
return { ok: true };
|
|
110
|
+
}
|
|
111
|
+
detail = acquired.conflict.detail;
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
detail = err instanceof Error ? err.message : String(err);
|
|
115
|
+
}
|
|
116
|
+
if (Date.now() >= deadline)
|
|
117
|
+
return { ok: false, detail };
|
|
118
|
+
sleepSync(ARM_LOCK_POLL_MS);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function releaseArmLock() {
|
|
122
|
+
const claim = heldArmLock;
|
|
123
|
+
if (claim === null)
|
|
124
|
+
return;
|
|
125
|
+
heldArmLock = null;
|
|
126
|
+
try {
|
|
127
|
+
const outcome = releaseLock(claim, { dir: defaultMetaRecordArmLocksDir() });
|
|
128
|
+
if (outcome !== "released")
|
|
129
|
+
logLine("WARN", `record-arm lock release for ${claim.gardenId}: ${outcome}`);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
// The holder pid is this short-lived hook, so a lock it failed to unlink is reclaimed
|
|
133
|
+
// by the next acquirer once this process is gone (same host + ESRCH).
|
|
134
|
+
logLine("WARN", `record-arm lock release failed for ${claim.gardenId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
74
137
|
/** Emit a hook response on stdout and exit 0. `{}` means "did nothing, do not block startup". */
|
|
75
138
|
function emit(payload) {
|
|
139
|
+
releaseArmLock();
|
|
76
140
|
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
77
141
|
process.exit(0);
|
|
78
142
|
}
|
|
@@ -149,6 +213,53 @@ function resolveMetaHookOwnerPid() {
|
|
|
149
213
|
return null;
|
|
150
214
|
return ownerPid;
|
|
151
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* Commit this event's record write and return the garden id (#147 L2 B1). The garden id to
|
|
218
|
+
* lock is learned READ ONLY; a record found absent is published create-only (a create needs no
|
|
219
|
+
* lock: its exclusive publish cannot replace anything, and a concurrent publisher turns the
|
|
220
|
+
* create into a refusal here, never into an unlocked attach). Every write to an EXISTING
|
|
221
|
+
* record then happens under the record-arm lock, which the caller keeps through the receiver
|
|
222
|
+
* marker write (released by {@link emit}). An arm-capable event mints its binding in that same
|
|
223
|
+
* atomic upsert. Lock busy: an arm writes nothing and logs ERROR (admission failure — not a
|
|
224
|
+
* committed attach, not an armed watch); any other event skips its backfill with a WARN.
|
|
225
|
+
* Throws on a store defect, exactly as the upsert does.
|
|
226
|
+
*/
|
|
227
|
+
function commitRecord(input, armsWatch, logContext) {
|
|
228
|
+
let existing = findCertifiedIdentityByNativeSessionId(input.nativeSessionId);
|
|
229
|
+
if (existing === null) {
|
|
230
|
+
try {
|
|
231
|
+
const created = upsertMetaSession({ input, expectAction: "create" });
|
|
232
|
+
logLine("INFO", `create record ${path.basename(created.path)} (${logContext})`);
|
|
233
|
+
// Nothing to bind and nothing older to preserve: the create is the whole write.
|
|
234
|
+
if (!armsWatch)
|
|
235
|
+
return { gardenId: created.record.gardenId, attachIncarnation: undefined };
|
|
236
|
+
existing = created.record;
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
if (!(err instanceof UpsertActionMismatchError))
|
|
240
|
+
throw err;
|
|
241
|
+
existing = findCertifiedIdentityByNativeSessionId(input.nativeSessionId);
|
|
242
|
+
if (existing === null)
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const locked = takeArmLock(existing.gardenId, armsWatch);
|
|
247
|
+
if (!locked.ok) {
|
|
248
|
+
if (armsWatch) {
|
|
249
|
+
// Admission failure: NOTHING is written, so the store keeps whatever pair it had. This
|
|
250
|
+
// attach is not committed, and a live process may now hold this session unseen.
|
|
251
|
+
logLine("ERROR", `record-arm lock busy — attach NOT committed, watch not armed (garden=${existing.gardenId}, ${logContext}): ${locked.detail}`);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
logLine("WARN", `record-arm lock busy — backfill skipped (garden=${existing.gardenId}, ${logContext}): ${locked.detail}`);
|
|
255
|
+
}
|
|
256
|
+
emit({});
|
|
257
|
+
}
|
|
258
|
+
const attachIncarnation = armsWatch ? mintAttachIncarnation() : undefined;
|
|
259
|
+
const result = upsertMetaSession({ input: { ...input, attachIncarnation }, expectAction: "attach" });
|
|
260
|
+
logLine("INFO", `${result.action} record ${path.basename(result.path)} (${logContext})`);
|
|
261
|
+
return { gardenId: result.record.gardenId, attachIncarnation };
|
|
262
|
+
}
|
|
152
263
|
function main() {
|
|
153
264
|
let raw;
|
|
154
265
|
try {
|
|
@@ -200,13 +311,12 @@ function main() {
|
|
|
200
311
|
logLine(degradedLevel, `degraded envelope (event=${eventName}, session_id=${sessionId ? "set" : "MISSING"}, transcript_path=${transcriptPath ? "set" : "MISSING"})`);
|
|
201
312
|
emit({});
|
|
202
313
|
}
|
|
314
|
+
// Arm-capable events commit a fresh binding; every other event preserves it.
|
|
315
|
+
const armsWatch = armProvenanceFor(eventName) !== null;
|
|
203
316
|
let gardenId;
|
|
317
|
+
let attachIncarnation;
|
|
204
318
|
try {
|
|
205
|
-
|
|
206
|
-
input: { backend: "claude-code", nativeSessionId: sessionId, transcriptPath, cwd, model },
|
|
207
|
-
});
|
|
208
|
-
gardenId = result.record.gardenId;
|
|
209
|
-
logLine("INFO", `${result.action} record ${path.basename(result.path)} (event=${eventName}, source=${source}, native=${sessionId})`);
|
|
319
|
+
({ gardenId, attachIncarnation } = commitRecord({ backend: "claude-code", nativeSessionId: sessionId, transcriptPath, cwd, model }, armsWatch, `event=${eventName}, source=${source}, native=${sessionId}`));
|
|
210
320
|
}
|
|
211
321
|
catch (err) {
|
|
212
322
|
// Best-effort: a broken record store must surface via the doctor, not by
|
|
@@ -318,11 +428,15 @@ function main() {
|
|
|
318
428
|
nativeSessionId: sessionId,
|
|
319
429
|
ownerPid,
|
|
320
430
|
armProvenance,
|
|
431
|
+
attachIncarnation,
|
|
321
432
|
});
|
|
322
433
|
logLine("INFO", `receiver marker ${gardenId} owner=${ownerPid} arm=${eventName} source=${source}`);
|
|
323
434
|
}
|
|
324
435
|
catch (err) {
|
|
325
|
-
|
|
436
|
+
// ERROR, after `INFO armed watch` so the hook-log doctor does not read a later arm as
|
|
437
|
+
// recovery: the record already carries this arm's binding, the marker does not, so the
|
|
438
|
+
// garden is undeliverable AND a later resume refuses it as unbound.
|
|
439
|
+
logLine("ERROR", `receiver marker write failed — record binding committed, marker not (event=${eventName}, garden=${gardenId}): ${err instanceof Error ? err.message : String(err)}`);
|
|
326
440
|
}
|
|
327
441
|
}
|
|
328
442
|
else {
|
|
@@ -98,6 +98,13 @@
|
|
|
98
98
|
* operator host started outside tmux gets the bridge's named `no-tmux-context` refusal. Exposing the
|
|
99
99
|
* tool is not a caller LIVE proof.
|
|
100
100
|
*
|
|
101
|
+
* CALLER RESUME (the seventh tool, #147 L1). `entwurf_resume_call` lets this durable citizen reopen a
|
|
102
|
+
* DORMANT pi or Claude Code citizen under its own garden id (#147 L2), under the bridge's own
|
|
103
|
+
* single-`target` schema (pinned below). The bridge decides everything else from the target's record,
|
|
104
|
+
* exactly as for any other caller: other backends have no same-id resume, a live target is refused,
|
|
105
|
+
* and the window opens in the tmux session this host was launched in. Exposing the tool names no new authority and is not a caller LIVE
|
|
106
|
+
* proof; it never resumes this durable host itself.
|
|
107
|
+
*
|
|
101
108
|
* NOT HERE: crash-safe receive. `entwurf_inbox_read` archives a body before the durable tool
|
|
102
109
|
* result commits, so a crash between the two loses that body to the model; recovery settles the
|
|
103
110
|
* call as interrupted and no doorbell rings again. A drain that fails part-way has archived the
|
|
@@ -135,12 +142,14 @@ export const PI_DURABLE_TOOL_NAMES = [
|
|
|
135
142
|
"entwurf_inbox_read",
|
|
136
143
|
"entwurf_callback",
|
|
137
144
|
"entwurf_fresh_call",
|
|
145
|
+
"entwurf_resume_call",
|
|
146
|
+
"entwurf_retire_call",
|
|
138
147
|
];
|
|
139
148
|
const DRAFT_07 = "http://json-schema.org/draft-07/schema#";
|
|
140
149
|
/**
|
|
141
150
|
* The bridge's `tools/list` inputSchemas for the exposed tools (`mcp/entwurf-bridge/src/index.ts`
|
|
142
151
|
* `entwurf_v2` 456-507, `entwurf_self`, `entwurf_peers`, `entwurf_inbox_read`, `entwurf_callback`,
|
|
143
|
-
* `entwurf_fresh_call`),
|
|
152
|
+
* `entwurf_fresh_call`, `entwurf_resume_call`, `entwurf_retire_call`),
|
|
144
153
|
* as the bridge serialises them.
|
|
145
154
|
* `attach` compares the live list against these and refuses on any difference.
|
|
146
155
|
*/
|
|
@@ -205,7 +214,7 @@ export const PI_DURABLE_TOOL_SCHEMAS = {
|
|
|
205
214
|
minLength: 1,
|
|
206
215
|
maxLength: 200,
|
|
207
216
|
pattern: "^[A-Za-z0-9][A-Za-z0-9._/:\\[\\]-]*$",
|
|
208
|
-
description: "Required runtime model: canonical provider/model for pi, provider/<exact catalog model id> for pi-durable, a Claude Code model id/alias, or a Copilot/OMP/Codex model name.",
|
|
217
|
+
description: "Required runtime model: canonical provider/model for pi (a bare alias is refused), provider/<exact catalog model id> for pi-durable, a Claude Code model id/alias, or a Copilot/OMP/Codex model name. Neither pi nor pi-durable may name the openrouter provider.",
|
|
209
218
|
},
|
|
210
219
|
task: {
|
|
211
220
|
type: "string",
|
|
@@ -232,6 +241,32 @@ export const PI_DURABLE_TOOL_SCHEMAS = {
|
|
|
232
241
|
required: ["backend", "model", "task"],
|
|
233
242
|
$schema: DRAFT_07,
|
|
234
243
|
},
|
|
244
|
+
entwurf_resume_call: {
|
|
245
|
+
type: "object",
|
|
246
|
+
properties: {
|
|
247
|
+
target: {
|
|
248
|
+
type: "string",
|
|
249
|
+
minLength: 1,
|
|
250
|
+
pattern: "^\\d{8}T\\d{6}-[0-9a-f]{6}$",
|
|
251
|
+
description: "Garden id of the DORMANT pi or Claude Code citizen to reopen (discover with entwurf_peers).",
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
required: ["target"],
|
|
255
|
+
$schema: DRAFT_07,
|
|
256
|
+
},
|
|
257
|
+
entwurf_retire_call: {
|
|
258
|
+
type: "object",
|
|
259
|
+
properties: {
|
|
260
|
+
target: {
|
|
261
|
+
type: "string",
|
|
262
|
+
minLength: 1,
|
|
263
|
+
pattern: "^\\d{8}T\\d{6}-[0-9a-f]{6}$",
|
|
264
|
+
description: "Exact garden id of the Pi or Claude Code target the human operator explicitly requested to retire.",
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
required: ["target"],
|
|
268
|
+
$schema: DRAFT_07,
|
|
269
|
+
},
|
|
235
270
|
};
|
|
236
271
|
/** Key-order-insensitive JSON, for comparing a schema copy against the bridge's own. */
|
|
237
272
|
function canonicalJson(value) {
|
|
@@ -438,6 +473,22 @@ const TOOL_DESCRIPTIONS = {
|
|
|
438
473
|
"(backend, model and task required; optional cwd and placement). Returns the bridge's LAUNCH receipt or its " +
|
|
439
474
|
"named refusal — not a started runtime, a delivered task or a reply; the sibling's callback names it. Opening " +
|
|
440
475
|
"beside this host needs the tmux pane this host itself was launched in.",
|
|
476
|
+
entwurf_resume_call: "Reopen ONE dormant pi or Claude Code citizen under its own garden id in a visible tmux window through the " +
|
|
477
|
+
"entwurf-bridge (target required; the record supplies the session and cwd — no task, no prompt, no turn). " +
|
|
478
|
+
"Returns the bridge's LAUNCH and OBSERVATION receipts or its named refusal: a live target, another backend, " +
|
|
479
|
+
"or a Claude Code citizen not proven dormant is refused by name, and opening the window needs the tmux " +
|
|
480
|
+
"session this host itself was launched in.",
|
|
481
|
+
entwurf_retire_call: "Only when the human operator explicitly requested this particular target/work: make ONE retirement attempt for that request " +
|
|
482
|
+
"through the bridge (target only, Pi or Claude Code; self retirement refuses). Development work, a role or " +
|
|
483
|
+
"a record-backed actor is NOT a grant. Use host approval where provided/configured; " +
|
|
484
|
+
"Pi and pi-durable have no built-in per-call approval prompt by default. An explicit operator request and " +
|
|
485
|
+
"agent operating policy are required; attribution and MCP annotations are hints, not grants or proof of approval. " +
|
|
486
|
+
"No autonomous cleanup, timers or automatic " +
|
|
487
|
+
"retry of unknown, cancelled or lost results. Do not manually /resume or /clear the target during the request. " +
|
|
488
|
+
"Pi shutdown-requested is only an ACK, not process exit, flush or model silence. Claude Code exited is that " +
|
|
489
|
+
"fd-pinned process only, not conversation end, children or flush. The JSON receipt keeps outcome, record and " +
|
|
490
|
+
"release separate; receipt return does not mean exit, and cancellation does not undo an action. Invoke once " +
|
|
491
|
+
"per explicitly named target, never infer a roster. This contact does not turn hints into authorization.",
|
|
441
492
|
};
|
|
442
493
|
/** Announce, never push: the notice names the garden and the tool, never the body. */
|
|
443
494
|
function doorbellNotice(gardenId, unread) {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* {
|
|
16
16
|
* "schemaVersion": 1, // of THIS projection, not of the records
|
|
17
17
|
* "storeDir": "/abs/path",
|
|
18
|
-
* "citizens": [ <full verbatim strict
|
|
18
|
+
* "citizens": [ <full verbatim strict v3 records, sorted by gardenId> ],
|
|
19
19
|
* "defects": [ { "filename", "message" }, … sorted by filename ]
|
|
20
20
|
* }
|
|
21
21
|
*
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* remote-pair-receive — `entwurf remote-pair-receive --pair <absolute file>` (#107 rail 1).
|
|
4
|
+
*
|
|
5
|
+
* The receiving host's entry for one declared remote pair. The sending host runs it over one
|
|
6
|
+
* SSH call and writes one JSON claim to stdin. This entry reads the operator-named pair
|
|
7
|
+
* declaration once, applies the pair scope check, and hands a matching claim to the existing
|
|
8
|
+
* v2 dispatch. The scope and trust model are in pi-extensions/lib/remote-pair-receive.ts.
|
|
9
|
+
*
|
|
10
|
+
* Scope: `--pair` must name THIS host's own opt-in file (`remotePairOptInPath()`), absolute and
|
|
11
|
+
* normalized-equal; any other path — even a valid declaration — is exit 2. That one file is the pair
|
|
12
|
+
* this host accepts and, because it names the sender's host as its route, the declared route back.
|
|
13
|
+
* The sender's declaration carries the path only so the argv shape is explicit; it selects nothing.
|
|
14
|
+
*
|
|
15
|
+
* EXIT CONTRACT (stdout carries a JSON receipt line only where named):
|
|
16
|
+
* 0 dispatched, the dispatch did not report an error — receipt on stdout
|
|
17
|
+
* 4 dispatched, the dispatch reported an error (its text says what is known, e.g. a
|
|
18
|
+
* rejection or `retry-safe: false` after a possible write) — receipt
|
|
19
|
+
* 3 refused by the pair scope check, no dispatch ran — receipt
|
|
20
|
+
* 2 --pair is not this host's opt-in, the opt-in is absent/broken, or the claim is invalid; no
|
|
21
|
+
* dispatch ran — `{kind:"invalid"}` receipt;
|
|
22
|
+
* wrong argv is ALSO 2 but prints usage on stderr only, with no receipt
|
|
23
|
+
* 1 an uncaught dispatch/store throw (crash, don't warn) — no receipt; stderr carries the
|
|
24
|
+
* error. The sending host reads that as indeterminate.
|
|
25
|
+
*/
|
|
26
|
+
import { readFileSync } from "node:fs";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
import { runAndRenderEntwurfV2FromSurface } from "../pi-extensions/lib/entwurf-v2-surface.js";
|
|
29
|
+
import { parseRemoteClaim, receiveRemoteClaim, } from "../pi-extensions/lib/remote-pair-receive.js";
|
|
30
|
+
import { readRemotePairOptIn, remotePairOptInPath } from "../pi-extensions/lib/remote-pair-send.js";
|
|
31
|
+
function emit(receipt, code) {
|
|
32
|
+
process.stdout.write(`${JSON.stringify(receipt)}\n`);
|
|
33
|
+
process.exit(code);
|
|
34
|
+
}
|
|
35
|
+
const args = process.argv.slice(2);
|
|
36
|
+
if (args.length !== 2 || args[0] !== "--pair") {
|
|
37
|
+
console.error("usage: entwurf remote-pair-receive --pair <this host's opt-in file> (claim JSON on stdin)");
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
const pairFile = args[1];
|
|
41
|
+
let decl;
|
|
42
|
+
let claim;
|
|
43
|
+
try {
|
|
44
|
+
// The scope is THIS host's own opt-in, never a file the sender picks: `--pair` must name it
|
|
45
|
+
// exactly (absolute, normalized), and that one file is read for this receive.
|
|
46
|
+
const own = remotePairOptInPath();
|
|
47
|
+
if (!path.isAbsolute(pairFile) || path.normalize(pairFile) !== own) {
|
|
48
|
+
throw new Error(`--pair must be this host's opt-in file ${own} (got ${pairFile})`);
|
|
49
|
+
}
|
|
50
|
+
const route = readRemotePairOptIn(own);
|
|
51
|
+
if (route === null)
|
|
52
|
+
throw new Error(`this host has no remote pair opt-in (${own} is absent)`);
|
|
53
|
+
decl = { remoteGardenId: route.remoteGardenId, ssh: route.ssh, localGardenId: route.localGardenId };
|
|
54
|
+
claim = parseRemoteClaim(readFileSync(0, "utf8"));
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
emit({ kind: "invalid", detail: err instanceof Error ? err.message : String(err) }, 2);
|
|
58
|
+
}
|
|
59
|
+
const receipt = await receiveRemoteClaim(claim, decl, {
|
|
60
|
+
dispatch: (params, sender) => runAndRenderEntwurfV2FromSurface(params, { senderProvider: () => sender }),
|
|
61
|
+
// The scope file IS this host's own opt-in, and it names the sender's host as its route: a
|
|
62
|
+
// matching claim always has a declared route back. Declared, not probed.
|
|
63
|
+
replyRoute: "declared",
|
|
64
|
+
});
|
|
65
|
+
emit(receipt, receipt.kind === "rejected" ? 3 : receipt.isError ? 4 : 0);
|