@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.en.md +75 -29
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +74 -29
  5. package/lib/cli.js +2349 -182
  6. package/lib/client.js +344 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +746 -40
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/restart-context-DUyExi9O.js +1245 -0
  12. package/lib/restart-context-DlaDZWKq.js +1250 -0
  13. package/lib/{state-Dhx9VG44.js → state-CZMypGkB.js} +97 -13
  14. package/lib/test-seam-cli.js +24 -0
  15. package/lib/test-seam-dwvaKjRp.js +459 -0
  16. package/lib/test-seam.js +2 -0
  17. package/lib/types/browser-handoff.d.ts +55 -0
  18. package/lib/types/browser-handoff.js +489 -0
  19. package/lib/types/cli.d.ts +34 -4
  20. package/lib/types/cli.js +1421 -166
  21. package/lib/types/client/index.d.ts +15 -0
  22. package/lib/types/client/index.js +348 -0
  23. package/lib/types/deployment-proof.d.ts +24 -0
  24. package/lib/types/deployment-proof.js +314 -0
  25. package/lib/types/exit-agent.js +2 -0
  26. package/lib/types/git.d.ts +12 -3
  27. package/lib/types/git.js +69 -7
  28. package/lib/types/index.d.ts +66 -3
  29. package/lib/types/index.js +192 -41
  30. package/lib/types/launch-spec.d.ts +263 -0
  31. package/lib/types/launch-spec.js +823 -0
  32. package/lib/types/preflight-runner.d.ts +23 -12
  33. package/lib/types/preflight-runner.js +152 -57
  34. package/lib/types/processes.d.ts +38 -6
  35. package/lib/types/processes.js +236 -10
  36. package/lib/types/restart-context.d.ts +50 -0
  37. package/lib/types/restart-context.js +117 -6
  38. package/lib/types/restart-request.d.ts +32 -0
  39. package/lib/types/restart-request.js +128 -0
  40. package/lib/types/state-files.d.ts +30 -0
  41. package/lib/types/state-files.js +55 -0
  42. package/lib/types/state.d.ts +29 -2
  43. package/lib/types/state.js +52 -7
  44. package/lib/types/temp-artifact.d.ts +15 -0
  45. package/lib/types/temp-artifact.js +17 -0
  46. package/lib/types/test-seam-cli.d.ts +3 -0
  47. package/lib/types/test-seam-cli.js +27 -0
  48. package/lib/types/test-seam.d.ts +55 -0
  49. package/lib/types/test-seam.js +112 -0
  50. package/lib/types/transition.d.ts +118 -0
  51. package/lib/types/transition.js +717 -0
  52. package/package.json +30 -11
  53. package/scripts/dsh-watchdog.sh +1388 -80
  54. package/scripts/install-launchd.sh +43 -5
  55. package/scripts/install-systemd.sh +43 -5
  56. package/scripts/on-install.js +1 -1
  57. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  58. package/lib/processes-hCAmwma-.js +0 -127
  59. 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 resetToCheckpoint, c as readInterruptedSnapshot, f as writeInstanceLaunch, g as writeSkillRegistration, i as continueInterruptedText, m as writeInterruptedSnapshot, n as buildLaunchCommand, o as pendingRestartRecord, r as continueAndReportText, t as acknowledgeRestartRecord, u as restartContextText, v as commitCheckpoint, y as currentHead } from "./restart-context-DmnQXNf-.js";
3
- import { c as stateFile, i as setCheckpoint, n as loadState, o as verifyCredential, r as recordCredential, t as clearCredential } from "./state-Dhx9VG44.js";
4
- import { execFileSync } from "node:child_process";
5
- import { existsSync, readFileSync, unlinkSync } from "node:fs";
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 { dirname, join } from "node:path";
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 { resolveSessionPreset } from "@deepseek-ai/dsh-agent-presets";
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
- const record = followupReport ? pendingRestartRecord(stateDir) : null;
156
- if (exitAt !== void 0 && record !== null && (record.initiator === void 0 || id === record.initiator)) {
157
- const canaryPending = existsSync(stateFile(stateDir, "restartRequested"));
158
- const text = continueAndReportText(record, canaryPending);
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
- agent.followup(pluginMessage(continueInterruptedText(exitAt)));
168
- pendingContinue.delete(id);
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.get("sessionPersistence");
902
+ const persistence = probeColdReader(ctx);
204
903
  if (presets !== void 0 && persistence !== void 0) {
205
- const inspected = await persistence.inspect(id);
206
- const presetId = resolveSessionPreset({
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
- let port;
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 };