@khorsheed/dsh-ankh-guard 0.1.1 → 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 +24 -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 +257 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +721 -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 +1421 -166
  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 +30 -11
  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(""),
@@ -69,7 +708,8 @@ function registerRestartSkill(ctx, stateDir) {
69
708
  name,
70
709
  description,
71
710
  content,
72
- source: "runtime"
711
+ source: "runtime",
712
+ provider: "ankh-guard"
73
713
  }));
74
714
  writeSkillRegistration(stateDir, {
75
715
  registered: true,
@@ -121,6 +761,7 @@ function apply(ctx, config) {
121
761
  const resumeMaxSnapshotAgeMs = config.resumeMaxSnapshotAgeMs ?? 6e5;
122
762
  const followupReport = reportMode === "followup";
123
763
  registerRestartSkill(ctx, stateDir);
764
+ registerBrowserHandoff(ctx, stateDir);
124
765
  if (followupReport || resumeInterrupted) {
125
766
  const pluginMessage = (text) => createUserMessage({
126
767
  content: [{
@@ -142,6 +783,24 @@ function apply(ctx, config) {
142
783
  ctx.effect(() => () => {
143
784
  disposed = true;
144
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
+ };
145
804
  const claim = (agent, record) => {
146
805
  const canaryPending = existsSync(stateFile(stateDir, "restartRequested"));
147
806
  const text = restartContextText(record, canaryPending);
@@ -150,22 +809,39 @@ function apply(ctx, config) {
150
809
  acknowledgeRestartRecord(stateDir, record, Date.now());
151
810
  };
152
811
  const deliver = (agent) => {
812
+ if (cutoverBlocksWake(stateDir)) return;
153
813
  const id = agent.id;
154
814
  const exitAt = pendingContinue.get(id);
155
815
  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
- }
165
- }
816
+ const owesReport = record !== null && (record.initiator === void 0 || id === record.initiator);
166
817
  if (exitAt !== void 0) {
167
- agent.followup(pluginMessage(continueInterruptedText(exitAt)));
168
- 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;
169
845
  }
170
846
  if (record === null) return;
171
847
  if (record.initiator !== void 0 && id !== record.initiator) return;
@@ -203,7 +879,7 @@ function apply(ctx, config) {
203
879
  const persistence = ctx.get("sessionPersistence");
204
880
  if (presets !== void 0 && persistence !== void 0) {
205
881
  const inspected = await persistence.inspect(id);
206
- const presetId = resolveSessionPreset({
882
+ const presetId = deriveSessionPreset(agentPresetsHost, {
207
883
  header: inspected.meta,
208
884
  events: inspected.events
209
885
  });
@@ -222,10 +898,16 @@ function apply(ctx, config) {
222
898
  if (snapshot !== null && Date.now() - snapshot.exitAt <= resumeMaxSnapshotAgeMs) for (const id of snapshot.interrupted) pendingContinue.set(id, snapshot.exitAt);
223
899
  }
224
900
  const resumePass = async () => {
901
+ while (!disposed && cutoverBlocksWake(stateDir)) await new Promise((resolve) => setTimeout(resolve, 250));
902
+ if (disposed) return;
225
903
  const snapshot = readInterruptedSnapshot(stateDir);
226
904
  if (snapshot === null) return;
227
905
  if (Date.now() - snapshot.exitAt <= resumeMaxSnapshotAgeMs) for (const id of [.../* @__PURE__ */ new Set([...snapshot.resume, ...snapshot.interrupted])]) {
228
906
  if (disposed) return;
907
+ if (await checkParked(id)) {
908
+ pendingContinue.delete(id);
909
+ continue;
910
+ }
229
911
  const live = ctx.agents.list().find((agent) => agent.id === id);
230
912
  if (live !== void 0) {
231
913
  deliver(live);
@@ -250,6 +932,16 @@ function apply(ctx, config) {
250
932
  clearTimeout(timer);
251
933
  });
252
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
+ }
253
945
  ctx.on("agent/created", ({ agent }) => {
254
946
  if (!ctx.agents.roots().includes(agent)) return;
255
947
  deliver(agent);
@@ -260,6 +952,7 @@ function apply(ctx, config) {
260
952
  if (decision.kind === "reject" || signal.aborted) return decision;
261
953
  const record = pendingRestartRecord(stateDir);
262
954
  if (record === null) return decision;
955
+ if (cutoverBlocksWake(stateDir)) return decision;
263
956
  const canaryPending = existsSync(stateFile(stateDir, "restartRequested"));
264
957
  const text = restartContextText(record, canaryPending);
265
958
  if (text === "") return decision;
@@ -284,10 +977,11 @@ function apply(ctx, config) {
284
977
  };
285
978
  }, { prepend: true });
286
979
  const service = {
287
- verify: () => verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes),
980
+ verify: () => verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes, isWorkingTreeClean(repoDir)),
288
981
  record: (scope, options) => {
289
982
  const head = currentHead(repoDir);
290
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");
291
985
  return recordCredential(stateDir, {
292
986
  scope,
293
987
  revision: head,
@@ -306,6 +1000,10 @@ function apply(ctx, config) {
306
1000
  return result;
307
1001
  },
308
1002
  reset: (sha) => resetToCheckpoint(repoDir, sha),
1003
+ requestRestart: (request) => requestRestart(request, {
1004
+ stateDir,
1005
+ repoDir
1006
+ }),
309
1007
  canary: async (options) => {
310
1008
  const checks = [];
311
1009
  const verdict = service.verify();
@@ -333,22 +1031,7 @@ function apply(ctx, config) {
333
1031
  try {
334
1032
  const env = {};
335
1033
  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 {}
1034
+ const port = listeningPortsForPid(process.pid)[0];
352
1035
  writeInstanceLaunch(stateDir, {
353
1036
  command: buildLaunchCommand(process.execPath, process.execArgv, process.argv.slice(1), process.cwd(), env),
354
1037
  source: "instance",
@@ -360,4 +1043,4 @@ function apply(ctx, config) {
360
1043
  })();
361
1044
  }
362
1045
  //#endregion
363
- export { Config, apply, inject, name };
1046
+ export { Config, apply, deriveSessionPreset, inject, name };