@khorsheed/dsh-ankh-guard 0.1.1 → 0.3.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/CHANGELOG.md +33 -0
- package/README.en.md +75 -29
- package/README.i18n.yaml +2 -2
- package/README.md +74 -29
- package/lib/cli.js +2349 -182
- package/lib/client.js +344 -0
- package/lib/exit-agent.js +5 -2
- package/lib/index.js +746 -40
- package/lib/invariant.js +1 -1
- package/lib/preflight-runner.js +125 -47
- package/lib/restart-context-DUyExi9O.js +1245 -0
- package/lib/restart-context-DlaDZWKq.js +1250 -0
- package/lib/{state-Dhx9VG44.js → state-CZMypGkB.js} +97 -13
- package/lib/test-seam-cli.js +24 -0
- package/lib/test-seam-dwvaKjRp.js +459 -0
- package/lib/test-seam.js +2 -0
- package/lib/types/browser-handoff.d.ts +55 -0
- package/lib/types/browser-handoff.js +489 -0
- package/lib/types/cli.d.ts +34 -4
- package/lib/types/cli.js +1421 -166
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +348 -0
- package/lib/types/deployment-proof.d.ts +24 -0
- package/lib/types/deployment-proof.js +314 -0
- package/lib/types/exit-agent.js +2 -0
- package/lib/types/git.d.ts +12 -3
- package/lib/types/git.js +69 -7
- package/lib/types/index.d.ts +66 -3
- package/lib/types/index.js +192 -41
- package/lib/types/launch-spec.d.ts +263 -0
- package/lib/types/launch-spec.js +823 -0
- package/lib/types/preflight-runner.d.ts +23 -12
- package/lib/types/preflight-runner.js +152 -57
- package/lib/types/processes.d.ts +38 -6
- package/lib/types/processes.js +236 -10
- package/lib/types/restart-context.d.ts +50 -0
- package/lib/types/restart-context.js +117 -6
- package/lib/types/restart-request.d.ts +32 -0
- package/lib/types/restart-request.js +128 -0
- package/lib/types/state-files.d.ts +30 -0
- package/lib/types/state-files.js +55 -0
- package/lib/types/state.d.ts +29 -2
- package/lib/types/state.js +52 -7
- package/lib/types/temp-artifact.d.ts +15 -0
- package/lib/types/temp-artifact.js +17 -0
- package/lib/types/test-seam-cli.d.ts +3 -0
- package/lib/types/test-seam-cli.js +27 -0
- package/lib/types/test-seam.d.ts +55 -0
- package/lib/types/test-seam.js +112 -0
- package/lib/types/transition.d.ts +118 -0
- package/lib/types/transition.js +717 -0
- package/package.json +30 -11
- package/scripts/dsh-watchdog.sh +1388 -80
- package/scripts/install-launchd.sh +43 -5
- package/scripts/install-systemd.sh +43 -5
- package/scripts/on-install.js +1 -1
- package/skills/dsh-self-restart-guard/SKILL.md +38 -12
- package/lib/processes-hCAmwma-.js +0 -127
- package/lib/restart-context-DmnQXNf-.js +0 -421
package/lib/index.js
CHANGED
|
@@ -1,15 +1,673 @@
|
|
|
1
1
|
import { i as resolveStateDir, r as resolveRepoDir, t as SRC_ARTIFACT_PATTERN } from "./defaults-CduvOcGq.js";
|
|
2
|
-
import { a as interruptedSnapshotFile, b as
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { M as currentHead, N as isWorkingTreeClean, P as resetToCheckpoint, S as cutoverBlocksWake, a as interruptedSnapshotFile, b as activeCutover, c as readInstanceLaunch, d as restartContextText, g as writeInterruptedSnapshot, i as continueInterruptedText, j as commitCheckpoint, l as readInterruptedSnapshot, m as writeInstanceLaunch, n as buildLaunchCommand, o as isParkedOnUserInput, r as continueAndReportText, s as pendingRestartRecord, t as acknowledgeRestartRecord, v as writeSkillRegistration, w as readCutoverReceipt } from "./restart-context-DlaDZWKq.js";
|
|
3
|
+
import { _ as processIdentity, g as listeningPortsForPid, v as processIdentityMatches } from "./test-seam-dwvaKjRp.js";
|
|
4
|
+
import { f as stateFile, i as setCheckpoint, n as loadState, r as recordCredential, s as verifyCredential, t as clearCredential, u as liveWatchdogPid } from "./state-CZMypGkB.js";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import { chmodSync, existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
8
|
import { connect } from "node:net";
|
|
7
|
-
import {
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
8
11
|
import { fileURLToPath } from "node:url";
|
|
9
12
|
import z from "@deepseek-ai/schemastery";
|
|
10
13
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
11
|
-
import
|
|
14
|
+
import * as agentPresetsHost from "@deepseek-ai/dsh-agent-presets";
|
|
15
|
+
//#region lib/types/browser-handoff.js
|
|
16
|
+
/**
|
|
17
|
+
* Browser handoff bridge for launch cutovers.
|
|
18
|
+
*
|
|
19
|
+
* Old authenticated pages register random per-tab capabilities while the
|
|
20
|
+
* previous process is still the proven listener. Only SHA-256 digests are
|
|
21
|
+
* durable. After the successor proves one stable listener and passes canary,
|
|
22
|
+
* every registered tab may ask the final process for either a plain reload
|
|
23
|
+
* (its cookie is still valid) or that process's authenticated URL. The URL
|
|
24
|
+
* exists only in the response object and the browser's location.replace call:
|
|
25
|
+
* it never enters a state file, receipt, or log.
|
|
26
|
+
*
|
|
27
|
+
* Alongside the cutover channel, a boot-generation channel covers ordinary
|
|
28
|
+
* restarts (unsupervised `restart`/`schedule-exit`, watchdog crash respawns):
|
|
29
|
+
* the serving process's own pid + start token is its boot id, a polling tab
|
|
30
|
+
* carries the boot id it last saw, and a stale id with no active cutover
|
|
31
|
+
* means the process it knew is gone — answer ready/reload. A cutover receipt
|
|
32
|
+
* always wins over the generation check: during a cutover the old tab's known
|
|
33
|
+
* id is already stale, and the receipt protocol owns the pacing.
|
|
34
|
+
*/
|
|
35
|
+
const BROWSER_HANDOFF_ROUTE = "/_ankh-guard/browser-handoff";
|
|
36
|
+
const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
37
|
+
const MAX_BODY_BYTES = 4096;
|
|
38
|
+
const MAX_REGISTRATIONS = 64;
|
|
39
|
+
const DEFAULT_LONG_POLL_MS = 25e3;
|
|
40
|
+
const DEFAULT_LONG_POLL_INTERVAL_MS = 200;
|
|
41
|
+
function isObject(value) {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
function isCapability(value) {
|
|
45
|
+
return typeof value === "string" && CAPABILITY_PATTERN.test(value);
|
|
46
|
+
}
|
|
47
|
+
function capabilitySha256(capability) {
|
|
48
|
+
return createHash("sha256").update(capability).digest("hex");
|
|
49
|
+
}
|
|
50
|
+
function parseMessage(value) {
|
|
51
|
+
if (!isObject(value) || value.version !== 1) return null;
|
|
52
|
+
if (value.operation === "poll" && isCapability(value.capability)) {
|
|
53
|
+
const knownBootId = typeof value.knownBootId === "string" && value.knownBootId.length <= 256 ? value.knownBootId : void 0;
|
|
54
|
+
return {
|
|
55
|
+
version: 1,
|
|
56
|
+
operation: "poll",
|
|
57
|
+
capability: value.capability,
|
|
58
|
+
...knownBootId === void 0 ? {} : { knownBootId }
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (value.operation !== "ack" || typeof value.cutoverId !== "string" || value.cutoverId === "") return null;
|
|
62
|
+
if (value.channel !== "original-tab" && value.channel !== "fallback-tab") return null;
|
|
63
|
+
if (value.authentication !== "existing-cookie" && value.authentication !== "launch-url") return null;
|
|
64
|
+
if (value.channel === "original-tab" && !isCapability(value.capability)) return null;
|
|
65
|
+
return {
|
|
66
|
+
version: 1,
|
|
67
|
+
operation: "ack",
|
|
68
|
+
cutoverId: value.cutoverId,
|
|
69
|
+
channel: value.channel,
|
|
70
|
+
authentication: value.authentication,
|
|
71
|
+
...typeof value.capability === "string" ? { capability: value.capability } : {}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function readJson(file) {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function createJsonOnce(file, value) {
|
|
82
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
83
|
+
const tmp = `${file}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
84
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, {
|
|
85
|
+
mode: 384,
|
|
86
|
+
flag: "wx"
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
linkSync(tmp, file);
|
|
90
|
+
try {
|
|
91
|
+
chmodSync(file, 384);
|
|
92
|
+
} catch {}
|
|
93
|
+
return true;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error.code === "EEXIST") return false;
|
|
96
|
+
throw error;
|
|
97
|
+
} finally {
|
|
98
|
+
unlinkSync(tmp);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function writeJsonAtomic(file, value) {
|
|
102
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
103
|
+
const tmp = `${file}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
104
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, {
|
|
105
|
+
mode: 384,
|
|
106
|
+
flag: "wx"
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
renameSync(tmp, file);
|
|
110
|
+
try {
|
|
111
|
+
chmodSync(file, 384);
|
|
112
|
+
} catch {}
|
|
113
|
+
} finally {
|
|
114
|
+
try {
|
|
115
|
+
unlinkSync(tmp);
|
|
116
|
+
} catch {}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function validRegistration(value) {
|
|
120
|
+
return isObject(value) && typeof value.authority === "string" && value.authority !== "" && typeof value.capabilitySha256 === "string" && /^[a-f0-9]{64}$/.test(value.capabilitySha256) && typeof value.armedAt === "number" && Number.isFinite(value.armedAt) && (value.acknowledgedAt === void 0 || typeof value.acknowledgedAt === "number" && Number.isFinite(value.acknowledgedAt));
|
|
121
|
+
}
|
|
122
|
+
function readBrowserHandoffRequest(stateDir) {
|
|
123
|
+
const request = readJson(stateFile(stateDir, "browserHandoffRequest"));
|
|
124
|
+
if (request?.version !== 2 || typeof request.cutoverId !== "string" || !Array.isArray(request.registrations) || request.registrations.length === 0 || request.registrations.length > MAX_REGISTRATIONS || !request.registrations.every(validRegistration)) return null;
|
|
125
|
+
return request;
|
|
126
|
+
}
|
|
127
|
+
function registerBrowserTab(stateDir, cutoverId, registration) {
|
|
128
|
+
const existing = readBrowserHandoffRequest(stateDir);
|
|
129
|
+
const registrations = existing?.cutoverId === cutoverId ? [...existing.registrations] : [];
|
|
130
|
+
if (!registrations.some((item) => item.authority === registration.authority && item.capabilitySha256 === registration.capabilitySha256)) {
|
|
131
|
+
if (registrations.length >= MAX_REGISTRATIONS) return null;
|
|
132
|
+
registrations.push(registration);
|
|
133
|
+
}
|
|
134
|
+
const request = {
|
|
135
|
+
version: 2,
|
|
136
|
+
cutoverId,
|
|
137
|
+
registrations
|
|
138
|
+
};
|
|
139
|
+
writeJsonAtomic(stateFile(stateDir, "browserHandoffRequest"), request);
|
|
140
|
+
return request;
|
|
141
|
+
}
|
|
142
|
+
function registrationMatches(request, cutoverId, authority, digest) {
|
|
143
|
+
return request?.cutoverId === cutoverId && request.registrations.some((item) => item.authority === authority && item.capabilitySha256 === digest);
|
|
144
|
+
}
|
|
145
|
+
function matchingRegistration(request, cutoverId, authority, digest) {
|
|
146
|
+
if (request?.cutoverId !== cutoverId) return void 0;
|
|
147
|
+
return request.registrations.find((item) => item.authority === authority && item.capabilitySha256 === digest);
|
|
148
|
+
}
|
|
149
|
+
function acknowledgeBrowserTab(stateDir, cutoverId, authority, digest, acknowledgedAt) {
|
|
150
|
+
const request = readBrowserHandoffRequest(stateDir);
|
|
151
|
+
const registration = matchingRegistration(request, cutoverId, authority, digest);
|
|
152
|
+
if (request === null || registration === void 0) return false;
|
|
153
|
+
registration.acknowledgedAt = acknowledgedAt;
|
|
154
|
+
writeJsonAtomic(stateFile(stateDir, "browserHandoffRequest"), request);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
function readBrowserHandoffAcknowledgement(stateDir) {
|
|
158
|
+
const ack = readJson(stateFile(stateDir, "browserHandoffAck"));
|
|
159
|
+
if (ack?.version !== 1 || typeof ack.cutoverId !== "string" || ack.role !== "target" && ack.role !== "previous" || typeof ack.authority !== "string" || ack.authority === "" || !Number.isInteger(ack.listenerPid) || (ack.listenerPid ?? 0) <= 0 || typeof ack.listenerStartToken !== "string" || ack.listenerStartToken === "" || ack.channel !== "original-tab" && ack.channel !== "fallback-tab" || ack.authentication !== "existing-cookie" && ack.authentication !== "launch-url" || typeof ack.acknowledgedAt !== "number") return null;
|
|
160
|
+
return ack;
|
|
161
|
+
}
|
|
162
|
+
function authorityOf(req) {
|
|
163
|
+
const host = req.headers.host;
|
|
164
|
+
const origin = req.headers.origin;
|
|
165
|
+
if (typeof host !== "string" || typeof origin !== "string") return null;
|
|
166
|
+
try {
|
|
167
|
+
const parsed = new URL(origin);
|
|
168
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.host !== host || parsed.origin !== origin) return null;
|
|
169
|
+
return {
|
|
170
|
+
authority: parsed.host,
|
|
171
|
+
origin: parsed.origin
|
|
172
|
+
};
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function ownershipMatches(ownership, pid, identityMatches) {
|
|
178
|
+
return ownership !== void 0 && ownership.listenerPid === pid && identityMatches({
|
|
179
|
+
pid,
|
|
180
|
+
startToken: ownership.listenerStartToken
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
function json(res, status, body) {
|
|
184
|
+
res.writeHead(status, {
|
|
185
|
+
"cache-control": "no-store",
|
|
186
|
+
"content-type": "application/json; charset=utf-8",
|
|
187
|
+
"referrer-policy": "no-referrer",
|
|
188
|
+
"x-content-type-options": "nosniff"
|
|
189
|
+
});
|
|
190
|
+
res.end(`${JSON.stringify(body)}\n`);
|
|
191
|
+
}
|
|
192
|
+
async function bodyOf(req) {
|
|
193
|
+
let size = 0;
|
|
194
|
+
const chunks = [];
|
|
195
|
+
for await (const chunk of req) {
|
|
196
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
197
|
+
size += bytes.byteLength;
|
|
198
|
+
if (size > MAX_BODY_BYTES) throw new Error("body too large");
|
|
199
|
+
chunks.push(bytes);
|
|
200
|
+
}
|
|
201
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
202
|
+
}
|
|
203
|
+
function currentReadyOwnership(stateDir, pid, identityMatches) {
|
|
204
|
+
const receipt = readCutoverReceipt(stateDir);
|
|
205
|
+
if (receipt === null || receipt.phase === "awaiting-user" || receipt.readiness?.retryCount !== 0) return null;
|
|
206
|
+
const role = receipt.readiness.role;
|
|
207
|
+
const ownership = role === "target" ? receipt.ownership.target : receipt.ownership.restored;
|
|
208
|
+
const canarySettled = role === "target" ? receipt.targetValidation?.canary?.outcome === "pass" : receipt.recovery.validation?.canary !== void 0;
|
|
209
|
+
if (!ownershipMatches(ownership, pid, identityMatches) || !canarySettled || receipt.readiness.listenerPid !== ownership.listenerPid || receipt.readiness.childPid !== ownership.childPid) return null;
|
|
210
|
+
return {
|
|
211
|
+
cutoverId: receipt.id,
|
|
212
|
+
role,
|
|
213
|
+
ownership,
|
|
214
|
+
protected: receipt.authentication.launchUrlObserved === true
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function delay(ms) {
|
|
218
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
219
|
+
}
|
|
220
|
+
async function waitForPollState(ready, waitMs, intervalMs) {
|
|
221
|
+
if (ready() || waitMs <= 0) return;
|
|
222
|
+
const deadline = Date.now() + waitMs;
|
|
223
|
+
while (Date.now() < deadline) {
|
|
224
|
+
await delay(Math.min(intervalMs, Math.max(1, deadline - Date.now())));
|
|
225
|
+
if (ready()) return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function authenticationState(connection, req) {
|
|
229
|
+
if (connection?.requestRejection === void 0) return "legacy-public";
|
|
230
|
+
const rejection = connection.requestRejection(req);
|
|
231
|
+
if (rejection === void 0) return "authenticated";
|
|
232
|
+
return rejection === 403 ? "forbidden" : "unauthenticated";
|
|
233
|
+
}
|
|
234
|
+
/** Create the exact HTTP handler; dependencies are injectable for deterministic protocol tests. */
|
|
235
|
+
function createBrowserHandoffHandler(dependencies) {
|
|
236
|
+
const pid = dependencies.pid ?? process.pid;
|
|
237
|
+
const now = dependencies.now ?? Date.now;
|
|
238
|
+
const identityMatches = dependencies.identityMatches ?? processIdentityMatches;
|
|
239
|
+
const identityProvider = dependencies.identityProvider ?? processIdentity;
|
|
240
|
+
const longPollMs = dependencies.longPollMs ?? DEFAULT_LONG_POLL_MS;
|
|
241
|
+
const longPollIntervalMs = dependencies.longPollIntervalMs ?? DEFAULT_LONG_POLL_INTERVAL_MS;
|
|
242
|
+
let bootIdCache;
|
|
243
|
+
const thisBootId = () => {
|
|
244
|
+
if (bootIdCache === void 0) {
|
|
245
|
+
const identity = identityProvider(pid);
|
|
246
|
+
bootIdCache = identity === null ? null : `${String(identity.pid)}:${identity.startToken}`;
|
|
247
|
+
}
|
|
248
|
+
return bootIdCache ?? void 0;
|
|
249
|
+
};
|
|
250
|
+
return async (req, res) => {
|
|
251
|
+
const connection = dependencies.connectionProvider?.() ?? dependencies.connection;
|
|
252
|
+
if (req.method !== "POST" || req.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") {
|
|
253
|
+
json(res, 404, { state: "unavailable" });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const requestAuthority = authorityOf(req);
|
|
257
|
+
if (requestAuthority === null) {
|
|
258
|
+
json(res, 403, { state: "forbidden" });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
let message;
|
|
262
|
+
try {
|
|
263
|
+
message = parseMessage(await bodyOf(req));
|
|
264
|
+
} catch {
|
|
265
|
+
message = null;
|
|
266
|
+
}
|
|
267
|
+
if (message === null) {
|
|
268
|
+
json(res, 400, { state: "invalid" });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (message.operation === "poll") {
|
|
272
|
+
const digest = capabilitySha256(message.capability);
|
|
273
|
+
const bootId = thisBootId();
|
|
274
|
+
const respond = (status, body) => {
|
|
275
|
+
json(res, status, bootId === void 0 ? body : {
|
|
276
|
+
...body,
|
|
277
|
+
bootId
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
if (bootId !== void 0 && message.knownBootId !== void 0 && message.knownBootId !== bootId && activeCutover(dependencies.stateDir) === null) {
|
|
281
|
+
respond(200, {
|
|
282
|
+
state: "ready",
|
|
283
|
+
action: "reload",
|
|
284
|
+
authentication: "existing-cookie"
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const pollHasState = () => {
|
|
289
|
+
const active = activeCutover(dependencies.stateDir);
|
|
290
|
+
if (active !== null) {
|
|
291
|
+
if (active.receipt.phase === "awaiting-user" || active.receipt.browserHandoff.status === "off") return true;
|
|
292
|
+
if (ownershipMatches(active.receipt.ownership.previous, pid, identityMatches)) return !registrationMatches(readBrowserHandoffRequest(dependencies.stateDir), active.receipt.id, requestAuthority.authority, digest);
|
|
293
|
+
return currentReadyOwnership(dependencies.stateDir, pid, identityMatches) !== null;
|
|
294
|
+
}
|
|
295
|
+
const ready = currentReadyOwnership(dependencies.stateDir, pid, identityMatches);
|
|
296
|
+
const registration = readBrowserHandoffRequest(dependencies.stateDir);
|
|
297
|
+
return ready !== null && matchingRegistration(registration, ready.cutoverId, requestAuthority.authority, digest)?.acknowledgedAt === void 0;
|
|
298
|
+
};
|
|
299
|
+
await waitForPollState(pollHasState, longPollMs, longPollIntervalMs);
|
|
300
|
+
const active = activeCutover(dependencies.stateDir);
|
|
301
|
+
const ready = currentReadyOwnership(dependencies.stateDir, pid, identityMatches);
|
|
302
|
+
const registration = readBrowserHandoffRequest(dependencies.stateDir);
|
|
303
|
+
const registered = ready === null ? void 0 : matchingRegistration(registration, ready.cutoverId, requestAuthority.authority, digest);
|
|
304
|
+
if (registered?.acknowledgedAt !== void 0 || active === null && (ready === null || registered === void 0) || active?.receipt.phase === "awaiting-user" || active?.receipt.browserHandoff.status === "off") {
|
|
305
|
+
respond(200, { state: "idle" });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const previous = active?.receipt.ownership.previous;
|
|
309
|
+
if (active !== null && ownershipMatches(previous, pid, identityMatches)) {
|
|
310
|
+
const oldAuthentication = authenticationState(connection, req);
|
|
311
|
+
if (oldAuthentication === "unauthenticated" || oldAuthentication === "forbidden") {
|
|
312
|
+
json(res, 401, { state: "unauthorized" });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const registration = {
|
|
316
|
+
authority: requestAuthority.authority,
|
|
317
|
+
capabilitySha256: digest,
|
|
318
|
+
armedAt: now()
|
|
319
|
+
};
|
|
320
|
+
respond(200, registrationMatches(registerBrowserTab(dependencies.stateDir, active.receipt.id, registration), active.receipt.id, requestAuthority.authority, digest) ? {
|
|
321
|
+
state: "waiting",
|
|
322
|
+
cutoverId: active.receipt.id
|
|
323
|
+
} : {
|
|
324
|
+
state: "standby",
|
|
325
|
+
cutoverId: active.receipt.id
|
|
326
|
+
});
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (ready === null || active !== null && ready.cutoverId !== active.receipt.id || !registrationMatches(registration, ready?.cutoverId ?? "", requestAuthority.authority, digest)) {
|
|
330
|
+
respond(200, active === null ? { state: "idle" } : {
|
|
331
|
+
state: "waiting",
|
|
332
|
+
cutoverId: active.receipt.id
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (ready.protected && connection?.requestRejection === void 0) {
|
|
337
|
+
respond(200, {
|
|
338
|
+
state: "waiting",
|
|
339
|
+
cutoverId: ready.cutoverId
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const cookieState = authenticationState(connection, req);
|
|
344
|
+
if (cookieState === "forbidden") {
|
|
345
|
+
json(res, 403, { state: "forbidden" });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (cookieState !== "unauthenticated") {
|
|
349
|
+
respond(200, {
|
|
350
|
+
state: "ready",
|
|
351
|
+
action: "reload",
|
|
352
|
+
cutoverId: ready.cutoverId,
|
|
353
|
+
role: ready.role,
|
|
354
|
+
authentication: "existing-cookie"
|
|
355
|
+
});
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (connection?.authenticatedUrl === void 0) {
|
|
359
|
+
respond(200, {
|
|
360
|
+
state: "waiting",
|
|
361
|
+
cutoverId: ready.cutoverId
|
|
362
|
+
});
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
let launchUrl;
|
|
366
|
+
try {
|
|
367
|
+
launchUrl = connection.authenticatedUrl(requestAuthority.origin);
|
|
368
|
+
} catch {
|
|
369
|
+
json(res, 500, { state: "unavailable" });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
let safe = false;
|
|
373
|
+
try {
|
|
374
|
+
const parsed = new URL(launchUrl);
|
|
375
|
+
safe = parsed.origin === requestAuthority.origin && parsed.pathname === "/" && parsed.username === "" && parsed.password === "" && parsed.search !== "" && parsed.hash === "";
|
|
376
|
+
} catch {}
|
|
377
|
+
if (!safe) {
|
|
378
|
+
json(res, 500, { state: "unavailable" });
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
respond(200, {
|
|
382
|
+
state: "ready",
|
|
383
|
+
action: "replace",
|
|
384
|
+
cutoverId: ready.cutoverId,
|
|
385
|
+
role: ready.role,
|
|
386
|
+
authentication: "launch-url",
|
|
387
|
+
launchUrl
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const receipt = readCutoverReceipt(dependencies.stateDir);
|
|
392
|
+
if (receipt === null || receipt.id !== message.cutoverId) {
|
|
393
|
+
json(res, 409, { state: "stale" });
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const ready = currentReadyOwnership(dependencies.stateDir, pid, identityMatches);
|
|
397
|
+
if (ready === null || ready.cutoverId !== message.cutoverId) {
|
|
398
|
+
json(res, 409, { state: "waiting" });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (ready.protected && connection?.requestRejection === void 0) {
|
|
402
|
+
json(res, 409, { state: "waiting" });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const ackAuthentication = authenticationState(connection, req);
|
|
406
|
+
if (ackAuthentication === "unauthenticated" || ackAuthentication === "forbidden") {
|
|
407
|
+
json(res, ackAuthentication === "forbidden" ? 403 : 401, { state: ackAuthentication === "forbidden" ? "forbidden" : "unauthorized" });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const acknowledgedAt = now();
|
|
411
|
+
if (message.channel === "original-tab") {
|
|
412
|
+
const registration = readBrowserHandoffRequest(dependencies.stateDir);
|
|
413
|
+
if (message.capability === void 0 || !registrationMatches(registration, message.cutoverId, requestAuthority.authority, capabilitySha256(message.capability))) {
|
|
414
|
+
json(res, 403, { state: "forbidden" });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (!acknowledgeBrowserTab(dependencies.stateDir, message.cutoverId, requestAuthority.authority, capabilitySha256(message.capability), acknowledgedAt)) {
|
|
418
|
+
json(res, 409, { state: "conflict" });
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const acknowledgement = {
|
|
423
|
+
version: 1,
|
|
424
|
+
cutoverId: message.cutoverId,
|
|
425
|
+
role: ready.role,
|
|
426
|
+
authority: requestAuthority.authority,
|
|
427
|
+
listenerPid: ready.ownership.listenerPid,
|
|
428
|
+
listenerStartToken: ready.ownership.listenerStartToken,
|
|
429
|
+
channel: message.channel,
|
|
430
|
+
authentication: message.authentication,
|
|
431
|
+
acknowledgedAt
|
|
432
|
+
};
|
|
433
|
+
if (!createJsonOnce(stateFile(dependencies.stateDir, "browserHandoffAck"), acknowledgement)) {
|
|
434
|
+
const existing = readBrowserHandoffAcknowledgement(dependencies.stateDir);
|
|
435
|
+
if (existing?.cutoverId !== acknowledgement.cutoverId || existing.listenerPid !== acknowledgement.listenerPid || existing.listenerStartToken !== acknowledgement.listenerStartToken) {
|
|
436
|
+
json(res, 409, { state: "conflict" });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
res.writeHead(204, { "cache-control": "no-store" });
|
|
441
|
+
res.end();
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
/** Register the optional Web-profile bridge; headless/TUI compositions simply never activate it. */
|
|
445
|
+
function registerBrowserHandoff(ctx, stateDir) {
|
|
446
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
447
|
+
const webServer = webCtx.get("webServer");
|
|
448
|
+
if (webServer === void 0) return;
|
|
449
|
+
const handler = createBrowserHandoffHandler({
|
|
450
|
+
stateDir,
|
|
451
|
+
connectionProvider: () => webCtx.get("connection")
|
|
452
|
+
});
|
|
453
|
+
webCtx.effect(() => webServer.register({
|
|
454
|
+
kind: "exact",
|
|
455
|
+
path: BROWSER_HANDOFF_ROUTE,
|
|
456
|
+
handler
|
|
457
|
+
}), "ankh-guard: browser handoff route");
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
//#endregion
|
|
461
|
+
//#region lib/types/restart-request.js
|
|
462
|
+
/**
|
|
463
|
+
* In-process guarded-restart trigger for UI-grade callers (the mode
|
|
464
|
+
* switcher's host half). Dispatches on supervision — no live watchdog: the
|
|
465
|
+
* `restart` verb's self-contained stop→start→canary; supervised:
|
|
466
|
+
* `reconfigure`'s transactional cutover, the only safe way to change the
|
|
467
|
+
* launch command under a live watchdog (its respawn would otherwise fight
|
|
468
|
+
* the new command). The trigger shells out to this package's own CLI, so
|
|
469
|
+
* the gate chain (credential → preflight → marker/lock) keeps exactly one
|
|
470
|
+
* behavior source and the detached driver keeps its proven lifetime rules;
|
|
471
|
+
* the structured verdict returns through DSH_ANKH_VERDICT_FILE, never
|
|
472
|
+
* scraped from the human stderr text.
|
|
473
|
+
*
|
|
474
|
+
* Never import the CLI module from the plugin graph: a runtime edge pulls
|
|
475
|
+
* cli.ts into a shared tsdown chunk, evicting its code from lib/cli.js so
|
|
476
|
+
* the direct-invocation guard never fires and the built CLI silently exits
|
|
477
|
+
* 0 on every call (observed 2026-09-09; type-only imports are erased and
|
|
478
|
+
* stay safe).
|
|
479
|
+
*/
|
|
480
|
+
/** Bounded human detail carried alongside the structured verdict, for display. */
|
|
481
|
+
const DETAIL_CAP = 4096;
|
|
482
|
+
/**
|
|
483
|
+
* argv (after process.execPath) that runs this package's CLI with the given
|
|
484
|
+
* args — the same source/built split as the CLI's own cliInvocation, resolved
|
|
485
|
+
* from this module's directory (src/ and lib/ keep their entries side by
|
|
486
|
+
* side). The two copies must not be unified through an import: see the module
|
|
487
|
+
* doc for what a runtime edge into the CLI module does to the built bundle.
|
|
488
|
+
*/
|
|
489
|
+
function cliEntryArgs(args) {
|
|
490
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
491
|
+
const here = dirname(modulePath);
|
|
492
|
+
if (modulePath.includes(`${sep}src${sep}`)) {
|
|
493
|
+
const tsx = join(resolve(here, "../../../node_modules"), "tsx", "dist", "esm", "index.mjs");
|
|
494
|
+
if (existsSync(tsx)) return [
|
|
495
|
+
"--import",
|
|
496
|
+
tsx,
|
|
497
|
+
join(here, "cli.ts"),
|
|
498
|
+
...args
|
|
499
|
+
];
|
|
500
|
+
return [join(here, "cli.ts"), ...args];
|
|
501
|
+
}
|
|
502
|
+
return [join(here, "cli.js"), ...args];
|
|
503
|
+
}
|
|
504
|
+
/** Read the caller-side verdict file, or null when the CLI never wrote one. */
|
|
505
|
+
function readVerdict(file) {
|
|
506
|
+
try {
|
|
507
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
508
|
+
return typeof value.stage === "string" && typeof value.reason === "string" ? {
|
|
509
|
+
stage: value.stage,
|
|
510
|
+
reason: value.reason
|
|
511
|
+
} : null;
|
|
512
|
+
} catch {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Validate and dispatch one restart request through the guard's own gate
|
|
518
|
+
* chain. A refusal never stops the running instance.
|
|
519
|
+
* @param request - the successor command, target profile, and owning session.
|
|
520
|
+
* @param context - the guard's resolved state dir and credential repo.
|
|
521
|
+
* @returns the structured verdict with bounded human detail for display.
|
|
522
|
+
*/
|
|
523
|
+
async function requestRestart(request, context) {
|
|
524
|
+
if (request.start.trim() === "") return {
|
|
525
|
+
accepted: false,
|
|
526
|
+
stage: "usage",
|
|
527
|
+
reason: "start (the successor launch command) is required",
|
|
528
|
+
detail: ""
|
|
529
|
+
};
|
|
530
|
+
if (request.profile.trim() === "") return {
|
|
531
|
+
accepted: false,
|
|
532
|
+
stage: "usage",
|
|
533
|
+
reason: "profile (the dsh profile to preflight) is required",
|
|
534
|
+
detail: ""
|
|
535
|
+
};
|
|
536
|
+
if (request.initiator.trim() === "") return {
|
|
537
|
+
accepted: false,
|
|
538
|
+
stage: "usage",
|
|
539
|
+
reason: "initiator (the session id the restart report returns to) is required — never invent one",
|
|
540
|
+
detail: ""
|
|
541
|
+
};
|
|
542
|
+
const port = readInstanceLaunch(context.stateDir)?.port;
|
|
543
|
+
if (port === void 0) return {
|
|
544
|
+
accepted: false,
|
|
545
|
+
stage: "usage",
|
|
546
|
+
reason: "no instance launch record in the state dir — the guard does not know this instance's port",
|
|
547
|
+
detail: ""
|
|
548
|
+
};
|
|
549
|
+
const via = liveWatchdogPid(context.stateDir) === null ? "restart" : "reconfigure";
|
|
550
|
+
const argv = via === "restart" ? [
|
|
551
|
+
"restart",
|
|
552
|
+
"--port",
|
|
553
|
+
String(port),
|
|
554
|
+
"--start",
|
|
555
|
+
request.start,
|
|
556
|
+
"--profile",
|
|
557
|
+
request.profile,
|
|
558
|
+
"--initiator",
|
|
559
|
+
request.initiator,
|
|
560
|
+
"--state-dir",
|
|
561
|
+
context.stateDir,
|
|
562
|
+
"--repo",
|
|
563
|
+
context.repoDir
|
|
564
|
+
] : [
|
|
565
|
+
"reconfigure",
|
|
566
|
+
"--start",
|
|
567
|
+
request.start,
|
|
568
|
+
"--profile",
|
|
569
|
+
request.profile,
|
|
570
|
+
"--on-failure",
|
|
571
|
+
"restore-previous",
|
|
572
|
+
"--initiator",
|
|
573
|
+
request.initiator,
|
|
574
|
+
"--state-dir",
|
|
575
|
+
context.stateDir
|
|
576
|
+
];
|
|
577
|
+
const verdictDir = mkdtempSync(join(tmpdir(), "ankh-restart-request-"));
|
|
578
|
+
const verdictFile = join(verdictDir, "verdict.json");
|
|
579
|
+
const env = {
|
|
580
|
+
...process.env,
|
|
581
|
+
DSH_ANKH_VERDICT_FILE: verdictFile
|
|
582
|
+
};
|
|
583
|
+
delete env.DSH_ANKH_RESTART_DRIVER;
|
|
584
|
+
for (const key of Object.keys(env)) if (key.startsWith("WD_")) delete env[key];
|
|
585
|
+
try {
|
|
586
|
+
const { code, output } = await new Promise((resolvePromise, rejectPromise) => {
|
|
587
|
+
let captured = "";
|
|
588
|
+
const child = spawn(process.execPath, cliEntryArgs(argv), {
|
|
589
|
+
stdio: [
|
|
590
|
+
"ignore",
|
|
591
|
+
"pipe",
|
|
592
|
+
"pipe"
|
|
593
|
+
],
|
|
594
|
+
env
|
|
595
|
+
});
|
|
596
|
+
const append = (chunk) => {
|
|
597
|
+
if (captured.length < DETAIL_CAP) captured += chunk.toString("utf8");
|
|
598
|
+
};
|
|
599
|
+
child.stdout.on("data", append);
|
|
600
|
+
child.stderr.on("data", append);
|
|
601
|
+
child.once("error", rejectPromise);
|
|
602
|
+
child.once("exit", (exitCode) => {
|
|
603
|
+
resolvePromise({
|
|
604
|
+
code: exitCode,
|
|
605
|
+
output: captured
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
const detail = output.trim();
|
|
610
|
+
if (code === 0) return {
|
|
611
|
+
accepted: true,
|
|
612
|
+
via,
|
|
613
|
+
detail
|
|
614
|
+
};
|
|
615
|
+
const verdict = readVerdict(verdictFile);
|
|
616
|
+
return {
|
|
617
|
+
accepted: false,
|
|
618
|
+
stage: verdict?.stage ?? "unknown",
|
|
619
|
+
reason: verdict?.reason ?? detail.split("\n", 1)[0] ?? `guard CLI exited ${code ?? "signal"}`,
|
|
620
|
+
detail
|
|
621
|
+
};
|
|
622
|
+
} finally {
|
|
623
|
+
rmSync(verdictDir, {
|
|
624
|
+
recursive: true,
|
|
625
|
+
force: true
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
//#endregion
|
|
12
630
|
//#region lib/types/index.js
|
|
631
|
+
/**
|
|
632
|
+
* Which preset a session actually runs, newest selection winning. The rc host
|
|
633
|
+
* exports `resolveSessionPreset` for exactly this fold; the 0.1.2 host
|
|
634
|
+
* replaced it with `agentPresetProjectionDefinition` (init from the header,
|
|
635
|
+
* fold `agent-preset/selected` events) — same semantics, so the derivation
|
|
636
|
+
* feature-detects either surface and never touches the version string. A host
|
|
637
|
+
* with neither yields undefined: the resume falls back to the deployment's
|
|
638
|
+
* default preset, the same outcome a preset-less session had before.
|
|
639
|
+
* @param host - the agent-presets module namespace, structurally probed.
|
|
640
|
+
* @param session - the session's header and event log.
|
|
641
|
+
* @returns the preset id, or `undefined` when the session names none.
|
|
642
|
+
*/
|
|
643
|
+
function deriveSessionPreset(host, session) {
|
|
644
|
+
if (host.agentPresetProjectionDefinition !== void 0) {
|
|
645
|
+
const projection = host.agentPresetProjectionDefinition;
|
|
646
|
+
let state = projection.init(session.header);
|
|
647
|
+
for (const event of session.events) state = projection.apply(state, event);
|
|
648
|
+
return state ?? void 0;
|
|
649
|
+
}
|
|
650
|
+
return host.resolveSessionPreset?.(session);
|
|
651
|
+
}
|
|
652
|
+
/** Probe the session-persistence service's cold-read face. */
|
|
653
|
+
function probeColdReader(ctx) {
|
|
654
|
+
const service = ctx.get("sessionPersistence");
|
|
655
|
+
if (service === void 0 || service === null) return void 0;
|
|
656
|
+
return typeof service.open === "function" ? service : void 0;
|
|
657
|
+
}
|
|
658
|
+
/** Read one persisted session's header and full log through a read handle. */
|
|
659
|
+
async function readColdLog(persistence, id) {
|
|
660
|
+
const handle = await persistence.open(id, "read");
|
|
661
|
+
try {
|
|
662
|
+
const cold = await handle.read(0);
|
|
663
|
+
return {
|
|
664
|
+
meta: handle.header,
|
|
665
|
+
events: cold.events
|
|
666
|
+
};
|
|
667
|
+
} finally {
|
|
668
|
+
await handle.close();
|
|
669
|
+
}
|
|
670
|
+
}
|
|
13
671
|
const Config = z.object({
|
|
14
672
|
maxAgeMinutes: z.natural().min(1).default(10),
|
|
15
673
|
stateDir: z.string().default(""),
|
|
@@ -69,7 +727,8 @@ function registerRestartSkill(ctx, stateDir) {
|
|
|
69
727
|
name,
|
|
70
728
|
description,
|
|
71
729
|
content,
|
|
72
|
-
source: "runtime"
|
|
730
|
+
source: "runtime",
|
|
731
|
+
provider: "ankh-guard"
|
|
73
732
|
}));
|
|
74
733
|
writeSkillRegistration(stateDir, {
|
|
75
734
|
registered: true,
|
|
@@ -121,6 +780,7 @@ function apply(ctx, config) {
|
|
|
121
780
|
const resumeMaxSnapshotAgeMs = config.resumeMaxSnapshotAgeMs ?? 6e5;
|
|
122
781
|
const followupReport = reportMode === "followup";
|
|
123
782
|
registerRestartSkill(ctx, stateDir);
|
|
783
|
+
registerBrowserHandoff(ctx, stateDir);
|
|
124
784
|
if (followupReport || resumeInterrupted) {
|
|
125
785
|
const pluginMessage = (text) => createUserMessage({
|
|
126
786
|
content: [{
|
|
@@ -142,6 +802,24 @@ function apply(ctx, config) {
|
|
|
142
802
|
ctx.effect(() => () => {
|
|
143
803
|
disposed = true;
|
|
144
804
|
});
|
|
805
|
+
const parkedMemo = /* @__PURE__ */ new Map();
|
|
806
|
+
const checkParked = (id) => {
|
|
807
|
+
let probe = parkedMemo.get(id);
|
|
808
|
+
if (probe === void 0) {
|
|
809
|
+
probe = (async () => {
|
|
810
|
+
try {
|
|
811
|
+
const persistence = probeColdReader(ctx);
|
|
812
|
+
if (persistence === void 0) return false;
|
|
813
|
+
const { events } = await readColdLog(persistence, id);
|
|
814
|
+
return isParkedOnUserInput(events);
|
|
815
|
+
} catch {
|
|
816
|
+
return false;
|
|
817
|
+
}
|
|
818
|
+
})();
|
|
819
|
+
parkedMemo.set(id, probe);
|
|
820
|
+
}
|
|
821
|
+
return probe;
|
|
822
|
+
};
|
|
145
823
|
const claim = (agent, record) => {
|
|
146
824
|
const canaryPending = existsSync(stateFile(stateDir, "restartRequested"));
|
|
147
825
|
const text = restartContextText(record, canaryPending);
|
|
@@ -150,22 +828,43 @@ function apply(ctx, config) {
|
|
|
150
828
|
acknowledgeRestartRecord(stateDir, record, Date.now());
|
|
151
829
|
};
|
|
152
830
|
const deliver = (agent) => {
|
|
831
|
+
if (cutoverBlocksWake(stateDir)) return;
|
|
153
832
|
const id = agent.id;
|
|
154
833
|
const exitAt = pendingContinue.get(id);
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (text !== "") {
|
|
160
|
-
agent.followup(pluginMessage(text));
|
|
161
|
-
pendingContinue.delete(id);
|
|
162
|
-
acknowledgeRestartRecord(stateDir, record, Date.now());
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
834
|
+
let record = followupReport ? pendingRestartRecord(stateDir) : null;
|
|
835
|
+
if (record !== null && record.initiator === void 0 && record.unexpected !== true && record.compositionRecovered !== true && record.error === void 0) {
|
|
836
|
+
acknowledgeRestartRecord(stateDir, record, Date.now());
|
|
837
|
+
record = null;
|
|
165
838
|
}
|
|
839
|
+
const owesReport = record !== null && (record.initiator === void 0 || id === record.initiator);
|
|
166
840
|
if (exitAt !== void 0) {
|
|
167
|
-
|
|
168
|
-
|
|
841
|
+
(async () => {
|
|
842
|
+
const parked = await checkParked(id);
|
|
843
|
+
if (disposed || pendingContinue.get(id) !== exitAt) return;
|
|
844
|
+
pendingContinue.delete(id);
|
|
845
|
+
if (parked) {
|
|
846
|
+
if (owesReport && record !== null) claim(agent, record);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (owesReport && record !== null) {
|
|
850
|
+
const canaryPending = existsSync(stateFile(stateDir, "restartRequested"));
|
|
851
|
+
const text = continueAndReportText(record, canaryPending);
|
|
852
|
+
if (text !== "") try {
|
|
853
|
+
agent.followup(pluginMessage(text));
|
|
854
|
+
acknowledgeRestartRecord(stateDir, record, Date.now());
|
|
855
|
+
return;
|
|
856
|
+
} catch {
|
|
857
|
+
pendingContinue.set(id, exitAt);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
try {
|
|
862
|
+
agent.followup(pluginMessage(continueInterruptedText(exitAt)));
|
|
863
|
+
} catch {
|
|
864
|
+
pendingContinue.set(id, exitAt);
|
|
865
|
+
}
|
|
866
|
+
})();
|
|
867
|
+
return;
|
|
169
868
|
}
|
|
170
869
|
if (record === null) return;
|
|
171
870
|
if (record.initiator !== void 0 && id !== record.initiator) return;
|
|
@@ -200,10 +899,10 @@ function apply(ctx, config) {
|
|
|
200
899
|
if (selection?.model !== void 0) agentOptions.model = selection.model;
|
|
201
900
|
let setup;
|
|
202
901
|
const presets = ctx.get("agentPresets");
|
|
203
|
-
const persistence = ctx
|
|
902
|
+
const persistence = probeColdReader(ctx);
|
|
204
903
|
if (presets !== void 0 && persistence !== void 0) {
|
|
205
|
-
const inspected = await persistence
|
|
206
|
-
const presetId =
|
|
904
|
+
const inspected = await readColdLog(persistence, id);
|
|
905
|
+
const presetId = deriveSessionPreset(agentPresetsHost, {
|
|
207
906
|
header: inspected.meta,
|
|
208
907
|
events: inspected.events
|
|
209
908
|
});
|
|
@@ -222,10 +921,16 @@ function apply(ctx, config) {
|
|
|
222
921
|
if (snapshot !== null && Date.now() - snapshot.exitAt <= resumeMaxSnapshotAgeMs) for (const id of snapshot.interrupted) pendingContinue.set(id, snapshot.exitAt);
|
|
223
922
|
}
|
|
224
923
|
const resumePass = async () => {
|
|
924
|
+
while (!disposed && cutoverBlocksWake(stateDir)) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
925
|
+
if (disposed) return;
|
|
225
926
|
const snapshot = readInterruptedSnapshot(stateDir);
|
|
226
927
|
if (snapshot === null) return;
|
|
227
928
|
if (Date.now() - snapshot.exitAt <= resumeMaxSnapshotAgeMs) for (const id of [.../* @__PURE__ */ new Set([...snapshot.resume, ...snapshot.interrupted])]) {
|
|
228
929
|
if (disposed) return;
|
|
930
|
+
if (await checkParked(id)) {
|
|
931
|
+
pendingContinue.delete(id);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
229
934
|
const live = ctx.agents.list().find((agent) => agent.id === id);
|
|
230
935
|
if (live !== void 0) {
|
|
231
936
|
deliver(live);
|
|
@@ -250,6 +955,16 @@ function apply(ctx, config) {
|
|
|
250
955
|
clearTimeout(timer);
|
|
251
956
|
});
|
|
252
957
|
}
|
|
958
|
+
if (cutoverBlocksWake(stateDir)) {
|
|
959
|
+
const releaseTimer = setInterval(() => {
|
|
960
|
+
if (disposed || cutoverBlocksWake(stateDir)) return;
|
|
961
|
+
clearInterval(releaseTimer);
|
|
962
|
+
for (const agent of ctx.agents.roots()) deliver(agent);
|
|
963
|
+
}, 250);
|
|
964
|
+
ctx.effect(() => () => {
|
|
965
|
+
clearInterval(releaseTimer);
|
|
966
|
+
});
|
|
967
|
+
}
|
|
253
968
|
ctx.on("agent/created", ({ agent }) => {
|
|
254
969
|
if (!ctx.agents.roots().includes(agent)) return;
|
|
255
970
|
deliver(agent);
|
|
@@ -260,6 +975,7 @@ function apply(ctx, config) {
|
|
|
260
975
|
if (decision.kind === "reject" || signal.aborted) return decision;
|
|
261
976
|
const record = pendingRestartRecord(stateDir);
|
|
262
977
|
if (record === null) return decision;
|
|
978
|
+
if (cutoverBlocksWake(stateDir)) return decision;
|
|
263
979
|
const canaryPending = existsSync(stateFile(stateDir, "restartRequested"));
|
|
264
980
|
const text = restartContextText(record, canaryPending);
|
|
265
981
|
if (text === "") return decision;
|
|
@@ -284,10 +1000,11 @@ function apply(ctx, config) {
|
|
|
284
1000
|
};
|
|
285
1001
|
}, { prepend: true });
|
|
286
1002
|
const service = {
|
|
287
|
-
verify: () => verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes),
|
|
1003
|
+
verify: () => verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes, isWorkingTreeClean(repoDir)),
|
|
288
1004
|
record: (scope, options) => {
|
|
289
1005
|
const head = currentHead(repoDir);
|
|
290
1006
|
if (head === null) throw new Error("ankh-guard: cannot record a credential outside a git repository");
|
|
1007
|
+
if (!isWorkingTreeClean(repoDir)) throw new Error("ankh-guard: cannot record a credential while the working tree is dirty");
|
|
291
1008
|
return recordCredential(stateDir, {
|
|
292
1009
|
scope,
|
|
293
1010
|
revision: head,
|
|
@@ -306,6 +1023,10 @@ function apply(ctx, config) {
|
|
|
306
1023
|
return result;
|
|
307
1024
|
},
|
|
308
1025
|
reset: (sha) => resetToCheckpoint(repoDir, sha),
|
|
1026
|
+
requestRestart: (request) => requestRestart(request, {
|
|
1027
|
+
stateDir,
|
|
1028
|
+
repoDir
|
|
1029
|
+
}),
|
|
309
1030
|
canary: async (options) => {
|
|
310
1031
|
const checks = [];
|
|
311
1032
|
const verdict = service.verify();
|
|
@@ -333,22 +1054,7 @@ function apply(ctx, config) {
|
|
|
333
1054
|
try {
|
|
334
1055
|
const env = {};
|
|
335
1056
|
for (const [key, value] of Object.entries(process.env)) if (key.startsWith("DSH_") && value !== void 0) env[key] = value;
|
|
336
|
-
|
|
337
|
-
try {
|
|
338
|
-
const out = execFileSync("lsof", [
|
|
339
|
-
"-a",
|
|
340
|
-
"-p",
|
|
341
|
-
String(process.pid),
|
|
342
|
-
"-iTCP",
|
|
343
|
-
"-sTCP:LISTEN",
|
|
344
|
-
"-P"
|
|
345
|
-
], {
|
|
346
|
-
encoding: "utf8",
|
|
347
|
-
stdio: "pipe"
|
|
348
|
-
});
|
|
349
|
-
const match = /:(\d+) \(LISTEN\)/.exec(out);
|
|
350
|
-
if (match !== null) port = Number(match[1]);
|
|
351
|
-
} catch {}
|
|
1057
|
+
const port = listeningPortsForPid(process.pid)[0];
|
|
352
1058
|
writeInstanceLaunch(stateDir, {
|
|
353
1059
|
command: buildLaunchCommand(process.execPath, process.execArgv, process.argv.slice(1), process.cwd(), env),
|
|
354
1060
|
source: "instance",
|
|
@@ -360,4 +1066,4 @@ function apply(ctx, config) {
|
|
|
360
1066
|
})();
|
|
361
1067
|
}
|
|
362
1068
|
//#endregion
|
|
363
|
-
export { Config, apply, inject, name };
|
|
1069
|
+
export { Config, apply, deriveSessionPreset, inject, name };
|