@khorsheed/dsh-ankh-guard 0.1.0 → 0.2.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 (62) hide show
  1. package/CHANGELOG.md +28 -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 +2383 -209
  6. package/lib/client.js +257 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +722 -38
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/processes-BjZgJjQr.js +344 -0
  12. package/lib/restart-context-D6nISh28.js +1245 -0
  13. package/lib/restart-context-DUyExi9O.js +1245 -0
  14. package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
  15. package/lib/state-CZMypGkB.js +323 -0
  16. package/lib/test-seam-DnvLWTeO.js +119 -0
  17. package/lib/test-seam-cli.js +24 -0
  18. package/lib/test-seam-dwvaKjRp.js +459 -0
  19. package/lib/test-seam.js +2 -0
  20. package/lib/types/browser-handoff.d.ts +55 -0
  21. package/lib/types/browser-handoff.js +489 -0
  22. package/lib/types/cli.d.ts +34 -4
  23. package/lib/types/cli.js +1487 -225
  24. package/lib/types/client/index.d.ts +15 -0
  25. package/lib/types/client/index.js +264 -0
  26. package/lib/types/deployment-proof.d.ts +24 -0
  27. package/lib/types/deployment-proof.js +314 -0
  28. package/lib/types/exit-agent.js +2 -0
  29. package/lib/types/git.d.ts +12 -3
  30. package/lib/types/git.js +69 -7
  31. package/lib/types/index.d.ts +66 -3
  32. package/lib/types/index.js +157 -39
  33. package/lib/types/launch-spec.d.ts +263 -0
  34. package/lib/types/launch-spec.js +823 -0
  35. package/lib/types/preflight-runner.d.ts +23 -12
  36. package/lib/types/preflight-runner.js +152 -57
  37. package/lib/types/processes.d.ts +38 -6
  38. package/lib/types/processes.js +236 -10
  39. package/lib/types/restart-context.d.ts +50 -0
  40. package/lib/types/restart-context.js +106 -0
  41. package/lib/types/restart-request.d.ts +32 -0
  42. package/lib/types/restart-request.js +128 -0
  43. package/lib/types/state-files.d.ts +30 -0
  44. package/lib/types/state-files.js +55 -0
  45. package/lib/types/state.d.ts +29 -2
  46. package/lib/types/state.js +52 -7
  47. package/lib/types/temp-artifact.d.ts +15 -0
  48. package/lib/types/temp-artifact.js +17 -0
  49. package/lib/types/test-seam-cli.d.ts +3 -0
  50. package/lib/types/test-seam-cli.js +27 -0
  51. package/lib/types/test-seam.d.ts +55 -0
  52. package/lib/types/test-seam.js +112 -0
  53. package/lib/types/transition.d.ts +118 -0
  54. package/lib/types/transition.js +717 -0
  55. package/package.json +29 -9
  56. package/scripts/dsh-watchdog.sh +1388 -80
  57. package/scripts/install-launchd.sh +43 -5
  58. package/scripts/install-systemd.sh +43 -5
  59. package/scripts/on-install.js +1 -1
  60. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  61. package/lib/processes-hCAmwma-.js +0 -127
  62. package/lib/restart-context-DmnQXNf-.js +0 -421
package/lib/index.js CHANGED
@@ -1,15 +1,654 @@
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-DUyExi9O.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
+ }
13
652
  const Config = z.object({
14
653
  maxAgeMinutes: z.natural().min(1).default(10),
15
654
  stateDir: z.string().default(""),
@@ -68,7 +707,9 @@ function registerRestartSkill(ctx, stateDir) {
68
707
  ctx.effect(() => skills.register({
69
708
  name,
70
709
  description,
71
- content
710
+ content,
711
+ source: "runtime",
712
+ provider: "ankh-guard"
72
713
  }));
73
714
  writeSkillRegistration(stateDir, {
74
715
  registered: true,
@@ -120,6 +761,7 @@ function apply(ctx, config) {
120
761
  const resumeMaxSnapshotAgeMs = config.resumeMaxSnapshotAgeMs ?? 6e5;
121
762
  const followupReport = reportMode === "followup";
122
763
  registerRestartSkill(ctx, stateDir);
764
+ registerBrowserHandoff(ctx, stateDir);
123
765
  if (followupReport || resumeInterrupted) {
124
766
  const pluginMessage = (text) => createUserMessage({
125
767
  content: [{
@@ -141,6 +783,24 @@ function apply(ctx, config) {
141
783
  ctx.effect(() => () => {
142
784
  disposed = true;
143
785
  });
786
+ const parkedMemo = /* @__PURE__ */ new Map();
787
+ const checkParked = (id) => {
788
+ let probe = parkedMemo.get(id);
789
+ if (probe === void 0) {
790
+ probe = (async () => {
791
+ try {
792
+ const persistence = ctx.get("sessionPersistence");
793
+ if (persistence === void 0) return false;
794
+ const { events } = await persistence.inspect(id);
795
+ return isParkedOnUserInput(events);
796
+ } catch {
797
+ return false;
798
+ }
799
+ })();
800
+ parkedMemo.set(id, probe);
801
+ }
802
+ return probe;
803
+ };
144
804
  const claim = (agent, record) => {
145
805
  const canaryPending = existsSync(stateFile(stateDir, "restartRequested"));
146
806
  const text = restartContextText(record, canaryPending);
@@ -149,22 +809,39 @@ function apply(ctx, config) {
149
809
  acknowledgeRestartRecord(stateDir, record, Date.now());
150
810
  };
151
811
  const deliver = (agent) => {
812
+ if (cutoverBlocksWake(stateDir)) return;
152
813
  const id = agent.id;
153
814
  const exitAt = pendingContinue.get(id);
154
815
  const record = followupReport ? pendingRestartRecord(stateDir) : null;
155
- if (exitAt !== void 0 && record !== null && (record.initiator === void 0 || id === record.initiator)) {
156
- const canaryPending = existsSync(stateFile(stateDir, "restartRequested"));
157
- const text = continueAndReportText(record, canaryPending);
158
- if (text !== "") {
159
- agent.followup(pluginMessage(text));
160
- pendingContinue.delete(id);
161
- acknowledgeRestartRecord(stateDir, record, Date.now());
162
- return;
163
- }
164
- }
816
+ const owesReport = record !== null && (record.initiator === void 0 || id === record.initiator);
165
817
  if (exitAt !== void 0) {
166
- agent.followup(pluginMessage(continueInterruptedText(exitAt)));
167
- pendingContinue.delete(id);
818
+ (async () => {
819
+ const parked = await checkParked(id);
820
+ if (disposed || pendingContinue.get(id) !== exitAt) return;
821
+ pendingContinue.delete(id);
822
+ if (parked) {
823
+ if (owesReport && record !== null) claim(agent, record);
824
+ return;
825
+ }
826
+ if (owesReport && record !== null) {
827
+ const canaryPending = existsSync(stateFile(stateDir, "restartRequested"));
828
+ const text = continueAndReportText(record, canaryPending);
829
+ if (text !== "") try {
830
+ agent.followup(pluginMessage(text));
831
+ acknowledgeRestartRecord(stateDir, record, Date.now());
832
+ return;
833
+ } catch {
834
+ pendingContinue.set(id, exitAt);
835
+ return;
836
+ }
837
+ }
838
+ try {
839
+ agent.followup(pluginMessage(continueInterruptedText(exitAt)));
840
+ } catch {
841
+ pendingContinue.set(id, exitAt);
842
+ }
843
+ })();
844
+ return;
168
845
  }
169
846
  if (record === null) return;
170
847
  if (record.initiator !== void 0 && id !== record.initiator) return;
@@ -202,7 +879,7 @@ function apply(ctx, config) {
202
879
  const persistence = ctx.get("sessionPersistence");
203
880
  if (presets !== void 0 && persistence !== void 0) {
204
881
  const inspected = await persistence.inspect(id);
205
- const presetId = resolveSessionPreset({
882
+ const presetId = deriveSessionPreset(agentPresetsHost, {
206
883
  header: inspected.meta,
207
884
  events: inspected.events
208
885
  });
@@ -221,10 +898,16 @@ function apply(ctx, config) {
221
898
  if (snapshot !== null && Date.now() - snapshot.exitAt <= resumeMaxSnapshotAgeMs) for (const id of snapshot.interrupted) pendingContinue.set(id, snapshot.exitAt);
222
899
  }
223
900
  const resumePass = async () => {
901
+ while (!disposed && cutoverBlocksWake(stateDir)) await new Promise((resolve) => setTimeout(resolve, 250));
902
+ if (disposed) return;
224
903
  const snapshot = readInterruptedSnapshot(stateDir);
225
904
  if (snapshot === null) return;
226
905
  if (Date.now() - snapshot.exitAt <= resumeMaxSnapshotAgeMs) for (const id of [.../* @__PURE__ */ new Set([...snapshot.resume, ...snapshot.interrupted])]) {
227
906
  if (disposed) return;
907
+ if (await checkParked(id)) {
908
+ pendingContinue.delete(id);
909
+ continue;
910
+ }
228
911
  const live = ctx.agents.list().find((agent) => agent.id === id);
229
912
  if (live !== void 0) {
230
913
  deliver(live);
@@ -249,6 +932,16 @@ function apply(ctx, config) {
249
932
  clearTimeout(timer);
250
933
  });
251
934
  }
935
+ if (cutoverBlocksWake(stateDir)) {
936
+ const releaseTimer = setInterval(() => {
937
+ if (disposed || cutoverBlocksWake(stateDir)) return;
938
+ clearInterval(releaseTimer);
939
+ for (const agent of ctx.agents.roots()) deliver(agent);
940
+ }, 250);
941
+ ctx.effect(() => () => {
942
+ clearInterval(releaseTimer);
943
+ });
944
+ }
252
945
  ctx.on("agent/created", ({ agent }) => {
253
946
  if (!ctx.agents.roots().includes(agent)) return;
254
947
  deliver(agent);
@@ -259,6 +952,7 @@ function apply(ctx, config) {
259
952
  if (decision.kind === "reject" || signal.aborted) return decision;
260
953
  const record = pendingRestartRecord(stateDir);
261
954
  if (record === null) return decision;
955
+ if (cutoverBlocksWake(stateDir)) return decision;
262
956
  const canaryPending = existsSync(stateFile(stateDir, "restartRequested"));
263
957
  const text = restartContextText(record, canaryPending);
264
958
  if (text === "") return decision;
@@ -283,10 +977,11 @@ function apply(ctx, config) {
283
977
  };
284
978
  }, { prepend: true });
285
979
  const service = {
286
- verify: () => verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes),
980
+ verify: () => verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes, isWorkingTreeClean(repoDir)),
287
981
  record: (scope, options) => {
288
982
  const head = currentHead(repoDir);
289
983
  if (head === null) throw new Error("ankh-guard: cannot record a credential outside a git repository");
984
+ if (!isWorkingTreeClean(repoDir)) throw new Error("ankh-guard: cannot record a credential while the working tree is dirty");
290
985
  return recordCredential(stateDir, {
291
986
  scope,
292
987
  revision: head,
@@ -305,6 +1000,10 @@ function apply(ctx, config) {
305
1000
  return result;
306
1001
  },
307
1002
  reset: (sha) => resetToCheckpoint(repoDir, sha),
1003
+ requestRestart: (request) => requestRestart(request, {
1004
+ stateDir,
1005
+ repoDir
1006
+ }),
308
1007
  canary: async (options) => {
309
1008
  const checks = [];
310
1009
  const verdict = service.verify();
@@ -332,22 +1031,7 @@ function apply(ctx, config) {
332
1031
  try {
333
1032
  const env = {};
334
1033
  for (const [key, value] of Object.entries(process.env)) if (key.startsWith("DSH_") && value !== void 0) env[key] = value;
335
- let port;
336
- try {
337
- const out = execFileSync("lsof", [
338
- "-a",
339
- "-p",
340
- String(process.pid),
341
- "-iTCP",
342
- "-sTCP:LISTEN",
343
- "-P"
344
- ], {
345
- encoding: "utf8",
346
- stdio: "pipe"
347
- });
348
- const match = /:(\d+) \(LISTEN\)/.exec(out);
349
- if (match !== null) port = Number(match[1]);
350
- } catch {}
1034
+ const port = listeningPortsForPid(process.pid)[0];
351
1035
  writeInstanceLaunch(stateDir, {
352
1036
  command: buildLaunchCommand(process.execPath, process.execArgv, process.argv.slice(1), process.cwd(), env),
353
1037
  source: "instance",
@@ -359,4 +1043,4 @@ function apply(ctx, config) {
359
1043
  })();
360
1044
  }
361
1045
  //#endregion
362
- export { Config, apply, inject, name };
1046
+ export { Config, apply, deriveSessionPreset, inject, name };