@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,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remote-pair-send — the SENDING half of #107's one declared remote pair (rail 2a).
|
|
3
|
+
*
|
|
4
|
+
* It sits in front of the local v2 decider, at the surface. With no route declaration, or a target
|
|
5
|
+
* the declaration does not name, the local path runs unchanged. When the declaration names the
|
|
6
|
+
* target, any local trace of that gid (a record entry in any state, or anything at its control
|
|
7
|
+
* socket path) fails closed as an ambiguity before transport. Otherwise the call runs over one
|
|
8
|
+
* transport call: no synthetic record, no new backend, no new rail plan, and no retry.
|
|
9
|
+
*
|
|
10
|
+
* Sender: only the caller's authoritative envelope becomes the claim. It must be the declared local
|
|
11
|
+
* citizen; an anonymous envelope or another gid is refused before transport. The model chooses
|
|
12
|
+
* neither host nor command: both come from the operator's declaration.
|
|
13
|
+
*
|
|
14
|
+
* Remote command: ssh joins the remote words into one shell string. So the alias and the remote
|
|
15
|
+
* declaration path are held to a narrow POSIX-safe charset that needs no quoting, and the command
|
|
16
|
+
* is the fixed `entwurf remote-pair-receive --pair <path>`. There is no quoting engine here.
|
|
17
|
+
*
|
|
18
|
+
* Receipt: a result is decided only when the exit code and the one stdout JSON line agree
|
|
19
|
+
* (see {@link classifyRemoteReceipt}). Everything else is `remote-indeterminate`: the other host
|
|
20
|
+
* may or may not have enqueued. Read and answer stay unknown until a reply arrives.
|
|
21
|
+
*/
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { controlSocketPathIn } from "./control-socket-path.js";
|
|
25
|
+
import { defaultMetaSessionsDir, metaRecordExistsByGardenId, requireGardenId } from "./meta-session.js";
|
|
26
|
+
import { inspectControlSocketPath } from "./socket-discovery.js";
|
|
27
|
+
export class RemoteRouteDeclarationError extends Error {
|
|
28
|
+
}
|
|
29
|
+
const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
30
|
+
const REMOTE_PATH = /^\/[A-Za-z0-9._/-]+$/;
|
|
31
|
+
const CONNECT_TIMEOUT_SECONDS = 10;
|
|
32
|
+
export const REMOTE_SEND_TIMEOUT_MS = 30_000;
|
|
33
|
+
const PAIR_REJECT_REASONS = ["remote-pair-from-mismatch", "remote-pair-target-mismatch"];
|
|
34
|
+
export function parseRemoteRouteDeclaration(text) {
|
|
35
|
+
const what = "remote route declaration";
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = JSON.parse(text);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
throw new RemoteRouteDeclarationError(`${what}: not JSON (${err instanceof Error ? err.message : String(err)})`);
|
|
42
|
+
}
|
|
43
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
44
|
+
throw new RemoteRouteDeclarationError(`${what}: must be a JSON object`);
|
|
45
|
+
}
|
|
46
|
+
const obj = raw;
|
|
47
|
+
const want = ["localGardenId", "remoteGardenId", "remotePairFile", "ssh"];
|
|
48
|
+
const got = Object.keys(obj).sort();
|
|
49
|
+
if (got.length !== want.length || got.some((k, i) => k !== want[i])) {
|
|
50
|
+
throw new RemoteRouteDeclarationError(`${what}: keys must be exactly ${want.join(", ")} (got ${got.join(", ")})`);
|
|
51
|
+
}
|
|
52
|
+
const { ssh, remotePairFile } = obj;
|
|
53
|
+
if (typeof ssh !== "string" || !SSH_ALIAS.test(ssh)) {
|
|
54
|
+
throw new RemoteRouteDeclarationError(`${what}: "ssh" must be an alias matching ${SSH_ALIAS}`);
|
|
55
|
+
}
|
|
56
|
+
if (typeof remotePairFile !== "string" ||
|
|
57
|
+
!REMOTE_PATH.test(remotePairFile) ||
|
|
58
|
+
remotePairFile.split("/").includes("..")) {
|
|
59
|
+
throw new RemoteRouteDeclarationError(`${what}: "remotePairFile" must be an absolute path matching ${REMOTE_PATH} with no ".." segment`);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
remoteGardenId: requireGardenId(obj.remoteGardenId),
|
|
63
|
+
ssh,
|
|
64
|
+
localGardenId: requireGardenId(obj.localGardenId),
|
|
65
|
+
remotePairFile,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** The one argv the transport runs. Every word is inside the no-quoting charset above. */
|
|
69
|
+
export function remoteReceiveArgv(route) {
|
|
70
|
+
return [
|
|
71
|
+
"ssh",
|
|
72
|
+
"-o",
|
|
73
|
+
"BatchMode=yes",
|
|
74
|
+
"-o",
|
|
75
|
+
`ConnectTimeout=${CONNECT_TIMEOUT_SECONDS}`,
|
|
76
|
+
route.ssh,
|
|
77
|
+
"entwurf",
|
|
78
|
+
"remote-pair-receive",
|
|
79
|
+
"--pair",
|
|
80
|
+
route.remotePairFile,
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
function tail(s) {
|
|
84
|
+
return s.length > 400 ? s.slice(-400) : s;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Decide only on agreement. 0 + dispatched/!isError = accepted; 3 + rejected(pair reason) = pair
|
|
88
|
+
* refused; 4 + dispatched/isError = the other host reported an error while processing (its own text
|
|
89
|
+
* says what is known, e.g. `retry-safe: false` after a possible write; this line infers neither
|
|
90
|
+
* delivery nor non-delivery); 2 + invalid = it refused our
|
|
91
|
+
* input. Any other exit, a signal, a timeout, no line, more than one line, non-JSON, or a JSON
|
|
92
|
+
* kind/field that disagrees with the exit is indeterminate.
|
|
93
|
+
*/
|
|
94
|
+
export function classifyRemoteReceipt(r) {
|
|
95
|
+
const indeterminate = (why) => ({
|
|
96
|
+
kind: "remote-indeterminate",
|
|
97
|
+
why,
|
|
98
|
+
exitCode: r.exitCode,
|
|
99
|
+
stderrTail: tail(r.stderr),
|
|
100
|
+
});
|
|
101
|
+
if (r.timedOut)
|
|
102
|
+
return indeterminate("timed out");
|
|
103
|
+
if (r.signal !== null)
|
|
104
|
+
return indeterminate(`ended by signal ${r.signal}`);
|
|
105
|
+
if (r.exitCode === null) {
|
|
106
|
+
return indeterminate(r.stderr.startsWith("spawn failed")
|
|
107
|
+
? "the transport did not start (spawn failed)"
|
|
108
|
+
: "did not exit (no exit code)");
|
|
109
|
+
}
|
|
110
|
+
if (![0, 2, 3, 4].includes(r.exitCode))
|
|
111
|
+
return indeterminate(`exit ${r.exitCode}`);
|
|
112
|
+
const nl = r.stdout.indexOf("\n");
|
|
113
|
+
if (nl === -1 || nl !== r.stdout.length - 1)
|
|
114
|
+
return indeterminate("stdout is not exactly one line");
|
|
115
|
+
let j;
|
|
116
|
+
try {
|
|
117
|
+
j = JSON.parse(r.stdout);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return indeterminate("stdout is not JSON");
|
|
121
|
+
}
|
|
122
|
+
if (j === null || typeof j !== "object" || Array.isArray(j))
|
|
123
|
+
return indeterminate("stdout is not a JSON object");
|
|
124
|
+
const o = j;
|
|
125
|
+
const keys = Object.keys(o).sort().join(",");
|
|
126
|
+
if (r.exitCode === 0 && keys === "isError,kind,text" && o.kind === "dispatched" && o.isError === false) {
|
|
127
|
+
if (typeof o.text === "string")
|
|
128
|
+
return { kind: "remote-accepted", text: o.text };
|
|
129
|
+
}
|
|
130
|
+
if (r.exitCode === 4 && keys === "isError,kind,text" && o.kind === "dispatched" && o.isError === true) {
|
|
131
|
+
if (typeof o.text === "string")
|
|
132
|
+
return { kind: "remote-reported-error", text: o.text };
|
|
133
|
+
}
|
|
134
|
+
if (r.exitCode === 3 && keys === "detail,kind,reason" && o.kind === "rejected") {
|
|
135
|
+
if (typeof o.reason === "string" && PAIR_REJECT_REASONS.includes(o.reason) && typeof o.detail === "string") {
|
|
136
|
+
return { kind: "remote-pair-refused", reason: o.reason, detail: o.detail };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (r.exitCode === 2 && keys === "detail,kind" && o.kind === "invalid" && typeof o.detail === "string") {
|
|
140
|
+
return { kind: "remote-invalid", detail: o.detail };
|
|
141
|
+
}
|
|
142
|
+
return indeterminate(`exit ${r.exitCode} disagrees with its receipt`);
|
|
143
|
+
}
|
|
144
|
+
function render(route, o) {
|
|
145
|
+
const via = `via ${route.ssh}`;
|
|
146
|
+
switch (o.kind) {
|
|
147
|
+
case "remote-accepted":
|
|
148
|
+
return {
|
|
149
|
+
isError: false,
|
|
150
|
+
text: `entwurf_v2 remote ${via} → accepted by the other host (its fact; read/answer unknown until a reply arrives; paths below are on the other host)\n${o.text}`,
|
|
151
|
+
};
|
|
152
|
+
case "remote-reported-error":
|
|
153
|
+
// The other host's dispatch returned isError. That can follow a write (an enqueue whose
|
|
154
|
+
// stamp or signal then threw is `retry-safe: false`), so this header claims neither
|
|
155
|
+
// non-delivery nor safety to resend: the other host's own text below is the fact.
|
|
156
|
+
return {
|
|
157
|
+
isError: true,
|
|
158
|
+
text: `entwurf_v2 remote ${via} → the other host reported an error while processing; read its receipt below, delivery is not inferred from this line; not retried (paths below are on the other host)\n${o.text}`,
|
|
159
|
+
};
|
|
160
|
+
case "remote-pair-refused":
|
|
161
|
+
return {
|
|
162
|
+
isError: true,
|
|
163
|
+
text: `entwurf_v2 remote ${via} → refused by the other host's pair: ${o.reason} (${o.detail})`,
|
|
164
|
+
};
|
|
165
|
+
case "remote-invalid":
|
|
166
|
+
return { isError: true, text: `entwurf_v2 remote ${via} → the other host refused the input: ${o.detail}` };
|
|
167
|
+
case "remote-indeterminate":
|
|
168
|
+
return {
|
|
169
|
+
isError: true,
|
|
170
|
+
text: `entwurf_v2 remote ${via} → indeterminate: ${o.why}. The other host may or may not have enqueued; ` +
|
|
171
|
+
`not retried.${o.stderrTail ? `\n${o.stderrTail}` : ""}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function refuse(text) {
|
|
176
|
+
return { isError: true, text: `entwurf_v2 rejected: ${text}` };
|
|
177
|
+
}
|
|
178
|
+
export async function runEntwurfV2WithRemoteRoute(params, opts) {
|
|
179
|
+
const route = opts.route;
|
|
180
|
+
if (route === null || params.target !== route.remoteGardenId) {
|
|
181
|
+
return opts.runLocal(params, { senderProvider: opts.senderProvider });
|
|
182
|
+
}
|
|
183
|
+
if (await opts.localTrace(route.remoteGardenId)) {
|
|
184
|
+
return refuse(`remote-route-local-conflict: ${route.remoteGardenId} is declared remote ${`via ${route.ssh}`} but this host also has a local trace of it; not choosing`);
|
|
185
|
+
}
|
|
186
|
+
const sender = opts.senderProvider();
|
|
187
|
+
if (!sender || sender.origin === "external-mcp" || sender.sessionId !== route.localGardenId) {
|
|
188
|
+
return refuse(`remote-sender-not-declared: only ${route.localGardenId} may send over this route (sender: ${sender ? sender.sessionId : "none"})`);
|
|
189
|
+
}
|
|
190
|
+
const claim = {
|
|
191
|
+
from: sender.sessionId,
|
|
192
|
+
fromAgentId: sender.agentId,
|
|
193
|
+
fromCwd: sender.cwd,
|
|
194
|
+
target: route.remoteGardenId,
|
|
195
|
+
message: params.message,
|
|
196
|
+
wantsReply: params.wants_reply ?? false,
|
|
197
|
+
};
|
|
198
|
+
const result = await opts.transport.run(remoteReceiveArgv(route), JSON.stringify(claim), opts.timeoutMs ?? REMOTE_SEND_TIMEOUT_MS);
|
|
199
|
+
return render(route, classifyRemoteReceipt(result));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The production local-trace fact: the store's own record-entry inspection (absent vs anything
|
|
203
|
+
* else — readable, unreadable, symlinked or drifted all count) and the control-socket path
|
|
204
|
+
* inspection, in EVERY socket dir the caller names. The bridge's `ENTWURF_DIR` can differ from the
|
|
205
|
+
* decider's canonical dir, so both are passed and a trace in either one means this host already
|
|
206
|
+
* answers to the gid.
|
|
207
|
+
*/
|
|
208
|
+
export function makeLocalTraceFact(dirs) {
|
|
209
|
+
const socketDirs = [...new Set(dirs.controlSocketDirs.map((d) => path.resolve(d)))];
|
|
210
|
+
return async (gardenId) => {
|
|
211
|
+
if (metaRecordExistsByGardenId(gardenId, dirs.sessionsDir))
|
|
212
|
+
return true;
|
|
213
|
+
for (const dir of socketDirs) {
|
|
214
|
+
const inspection = await inspectControlSocketPath(controlSocketPathIn(dir, gardenId));
|
|
215
|
+
if (inspection.kind !== "absent")
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/** The operator's opt-in file name. One per host; it declares that host's one remote pair. */
|
|
222
|
+
export const REMOTE_PAIR_OPT_IN_FILE = "entwurf-remote-pair.json";
|
|
223
|
+
/**
|
|
224
|
+
* Where the opt-in file lives: beside the meta-record store, i.e. the pi agent dir unless an
|
|
225
|
+
* `ENTWURF_META_SESSIONS_DIR` override relocates the store (then the file follows the store it
|
|
226
|
+
* pairs). Read-only reuse of the existing root resolution; nothing here writes.
|
|
227
|
+
*/
|
|
228
|
+
export function remotePairOptInPath() {
|
|
229
|
+
return path.join(path.dirname(defaultMetaSessionsDir()), REMOTE_PAIR_OPT_IN_FILE);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Read the opt-in file once, at the moment of use (no cache). Absent is the ONLY "off". A
|
|
233
|
+
* symlink, a non-regular entry, an unreadable file or an invalid declaration throws: the operator
|
|
234
|
+
* opted in, so a broken opt-in is refused rather than read as off.
|
|
235
|
+
*
|
|
236
|
+
* The bridge this declares is the operator's to build and secure (for example a private network
|
|
237
|
+
* such as Tailscale, plus SSH authentication and permissions). Entwurf only walks the declared
|
|
238
|
+
* route: it installs, configures, discovers and verifies none of it, and never retries.
|
|
239
|
+
*/
|
|
240
|
+
export function readRemotePairOptIn(file) {
|
|
241
|
+
let st;
|
|
242
|
+
try {
|
|
243
|
+
st = fs.lstatSync(file);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
if (err.code === "ENOENT")
|
|
247
|
+
return null;
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
if (!st.isFile()) {
|
|
251
|
+
throw new RemoteRouteDeclarationError(`remote pair opt-in ${file} must be a regular non-symlink file`);
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
return parseRemoteRouteDeclaration(fs.readFileSync(file, "utf8"));
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
throw new RemoteRouteDeclarationError(`${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
const CAPTURE_CAP = 64 * 1024;
|
|
13
|
+
const KILL_GRACE_MS = 2000;
|
|
14
|
+
const FINAL_GRACE_MS = 2000;
|
|
15
|
+
function capped(chunks, size, chunk) {
|
|
16
|
+
if (size.n >= CAPTURE_CAP)
|
|
17
|
+
return;
|
|
18
|
+
const room = CAPTURE_CAP - size.n;
|
|
19
|
+
const piece = chunk.length > room ? chunk.subarray(0, room) : chunk;
|
|
20
|
+
chunks.push(piece);
|
|
21
|
+
size.n += piece.length;
|
|
22
|
+
}
|
|
23
|
+
export const processTransport = {
|
|
24
|
+
run(argv, stdin, timeoutMs) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const out = [];
|
|
27
|
+
const err = [];
|
|
28
|
+
const outN = { n: 0 };
|
|
29
|
+
const errN = { n: 0 };
|
|
30
|
+
let timedOut = false;
|
|
31
|
+
let spawnError = null;
|
|
32
|
+
let settled = false;
|
|
33
|
+
const child = spawn(argv[0], argv.slice(1), { shell: false, stdio: ["pipe", "pipe", "pipe"] });
|
|
34
|
+
let killTimer;
|
|
35
|
+
let finalTimer;
|
|
36
|
+
const settle = (code, signal) => {
|
|
37
|
+
if (settled)
|
|
38
|
+
return;
|
|
39
|
+
settled = true;
|
|
40
|
+
clearTimeout(bound);
|
|
41
|
+
if (killTimer)
|
|
42
|
+
clearTimeout(killTimer);
|
|
43
|
+
if (finalTimer)
|
|
44
|
+
clearTimeout(finalTimer);
|
|
45
|
+
const stderr = Buffer.concat(err).toString("utf8");
|
|
46
|
+
resolve({
|
|
47
|
+
exitCode: spawnError ? null : code,
|
|
48
|
+
signal: spawnError ? null : signal,
|
|
49
|
+
timedOut,
|
|
50
|
+
stdout: Buffer.concat(out).toString("utf8"),
|
|
51
|
+
stderr: spawnError ? `spawn failed: ${spawnError}${stderr ? `\n${stderr}` : ""}` : stderr,
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
const bound = setTimeout(() => {
|
|
55
|
+
timedOut = true;
|
|
56
|
+
child.kill("SIGTERM");
|
|
57
|
+
killTimer = setTimeout(() => {
|
|
58
|
+
child.kill("SIGKILL");
|
|
59
|
+
// Something else may still hold our stdout/stderr pipes (an inherited descendant,
|
|
60
|
+
// an ssh ControlMaster). We do not chase it: after one more grace we close OUR read
|
|
61
|
+
// ends and settle as a timeout, which the receipt reads as indeterminate.
|
|
62
|
+
finalTimer = setTimeout(() => {
|
|
63
|
+
child.stdout.destroy();
|
|
64
|
+
child.stderr.destroy();
|
|
65
|
+
settle(child.exitCode, child.signalCode);
|
|
66
|
+
}, FINAL_GRACE_MS);
|
|
67
|
+
}, KILL_GRACE_MS);
|
|
68
|
+
}, timeoutMs);
|
|
69
|
+
child.stdout.on("data", (c) => capped(out, outN, c));
|
|
70
|
+
child.stderr.on("data", (c) => capped(err, errN, c));
|
|
71
|
+
child.on("error", (e) => {
|
|
72
|
+
spawnError = e.message;
|
|
73
|
+
});
|
|
74
|
+
// A child that exits before reading stdin closes the pipe; that is its outcome, not ours.
|
|
75
|
+
child.stdin.on("error", () => { });
|
|
76
|
+
child.on("close", (code, signal) => settle(code, signal));
|
|
77
|
+
child.stdin.end(stdin);
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
};
|
|
@@ -1,27 +1,33 @@
|
|
|
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
|
-
import { existsSync } from "node:fs";
|
|
24
|
+
import { existsSync, statSync } from "node:fs";
|
|
21
25
|
import path from "node:path";
|
|
22
26
|
import { AcpModelNotCuratedError, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
|
|
23
27
|
import { getEntwurfExplicitExtensions, readSessionIdentity } from "./entwurf-core.js";
|
|
24
28
|
import { readAddressableMetaIdentity } from "./meta-session.js";
|
|
29
|
+
/** The backends with a visible same-id resume. A closed set: nothing outside it is reopened. */
|
|
30
|
+
export const RESUME_BACKENDS = ["pi", "claude-code"];
|
|
25
31
|
/**
|
|
26
32
|
* One of the TWO expected refusals on this leaf: the target is a citizen of a backend that has no
|
|
27
33
|
* same-id resume at all. The other is `AcpModelNotCuratedError` (#128, `acp/model-ids.js`).
|
|
@@ -31,23 +37,64 @@ import { readAddressableMetaIdentity } from "./meta-session.js";
|
|
|
31
37
|
* bridge — and those keep their bare cause-rich `Error`, because a caller cannot act on them
|
|
32
38
|
* except by looking. A Claude Code or agy garden id is different in kind: nothing is broken,
|
|
33
39
|
* the operator simply asked a capability boundary to do something it does not cover, and the
|
|
34
|
-
* honest answer is a named refusal rather than an error report about a store that is fine.
|
|
40
|
+
* honest answer is a named refusal rather than an error report about a store that is fine. Since
|
|
41
|
+
* #147 L2 the boundary is `RESUME_BACKENDS`; a Codex, agy, Copilot, OMP or pi-durable garden id is
|
|
42
|
+
* this refusal.
|
|
35
43
|
*
|
|
36
44
|
* It carries a `reason` so the caller matches on a FIELD. Parsing the message string would make
|
|
37
45
|
* the wording load-bearing, and re-reading the record to ask "was it pi?" would read an address
|
|
38
46
|
* twice and could answer differently the second time.
|
|
39
47
|
*/
|
|
40
48
|
export class ResumeBackendUnsupportedError extends Error {
|
|
41
|
-
reason = "target-
|
|
49
|
+
reason = "target-backend-unsupported";
|
|
42
50
|
backend;
|
|
43
51
|
constructor(gardenId, backend) {
|
|
44
52
|
super(`resume-launch-identity: ${gardenId} is a ${backend} citizen — same-id resume is a host-adapter relaunch ` +
|
|
45
|
-
`capability, and its domain
|
|
46
|
-
`This is a capability boundary, not
|
|
53
|
+
`capability, and its domain is ${RESUME_BACKENDS.join(" and ")}. ` +
|
|
54
|
+
`This is a capability boundary, not citizen rank.`);
|
|
47
55
|
this.name = "ResumeBackendUnsupportedError";
|
|
48
56
|
this.backend = backend;
|
|
49
57
|
}
|
|
50
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Claude Code's session id grammar: a lowercase UUID. The value becomes an argv token after
|
|
61
|
+
* `--resume`, so anything else — a leading `-`, whitespace, a path — is refused as a bad record
|
|
62
|
+
* rather than handed to the vendor CLI as something it would parse differently.
|
|
63
|
+
*/
|
|
64
|
+
const CLAUDE_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
65
|
+
/** A claude-code record → its resume facts. Throws, with its own cause, on a record that cannot be resumed. */
|
|
66
|
+
function resolveClaudeCodeLaunchIdentity(gardenId, record) {
|
|
67
|
+
if (!CLAUDE_SESSION_ID.test(record.nativeSessionId)) {
|
|
68
|
+
throw new Error(`resume-launch-identity: ${gardenId} records nativeSessionId ${JSON.stringify(record.nativeSessionId)}, ` +
|
|
69
|
+
`which is not a Claude Code session id (lowercase UUID) — refusing to hand it to claude --resume.`);
|
|
70
|
+
}
|
|
71
|
+
const transcript = record.transcriptPath;
|
|
72
|
+
if (!transcript || !path.isAbsolute(transcript)) {
|
|
73
|
+
throw new Error(`resume-launch-identity: ${gardenId} records no absolute transcriptPath (${JSON.stringify(transcript)}) — ` +
|
|
74
|
+
`the birth hook writes one for every Claude Code session, so this record cannot name a session to resume.`);
|
|
75
|
+
}
|
|
76
|
+
let isFile = false;
|
|
77
|
+
try {
|
|
78
|
+
isFile = statSync(transcript).isFile();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
isFile = false;
|
|
82
|
+
}
|
|
83
|
+
if (!isFile) {
|
|
84
|
+
throw new Error(`resume-launch-identity: ${gardenId} recorded transcriptPath "${transcript}" is not a file on disk — ` +
|
|
85
|
+
`the session's transcript is gone, so claude --resume would open nothing.`);
|
|
86
|
+
}
|
|
87
|
+
if (!path.isAbsolute(record.cwd)) {
|
|
88
|
+
throw new Error(`resume-launch-identity: ${gardenId} records a relative cwd ${JSON.stringify(record.cwd)}.`);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
backend: "claude-code",
|
|
92
|
+
nativeSessionId: record.nativeSessionId,
|
|
93
|
+
cwd: record.cwd,
|
|
94
|
+
createdAt: record.createdAt,
|
|
95
|
+
attachIncarnation: record.attachIncarnation,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
51
98
|
/**
|
|
52
99
|
* Resolve launch identity for a resume. The TARGET is now resolved through the
|
|
53
100
|
* meta-record (#50 C2): `gardenId → record.transcriptPath`. It used to be a global
|
|
@@ -79,6 +126,8 @@ export class ResumeBackendUnsupportedError extends Error {
|
|
|
79
126
|
*/
|
|
80
127
|
export function resolveResumeLaunchIdentity(gardenId) {
|
|
81
128
|
const record = readAddressableMetaIdentity(gardenId);
|
|
129
|
+
if (record.backend === "claude-code")
|
|
130
|
+
return resolveClaudeCodeLaunchIdentity(gardenId, record);
|
|
82
131
|
if (record.backend !== "pi") {
|
|
83
132
|
throw new ResumeBackendUnsupportedError(gardenId, record.backend);
|
|
84
133
|
}
|
|
@@ -136,6 +185,7 @@ export function resolveResumeLaunchIdentity(gardenId) {
|
|
|
136
185
|
if (provider === PROVIDER_ID && !isCuratedAcpModelId(model))
|
|
137
186
|
throw new AcpModelNotCuratedError(model);
|
|
138
187
|
return {
|
|
188
|
+
backend: "pi",
|
|
139
189
|
sessionFile,
|
|
140
190
|
cwd: identity.cwd,
|
|
141
191
|
explicitExtensionArgs: explicitExtensions.args,
|