@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/types/git.js CHANGED
@@ -4,6 +4,9 @@
4
4
  * are synchronous child-process invocations scoped to the repo directory.
5
5
  */
6
6
  import { execFileSync } from 'node:child_process';
7
+ import { copyFileSync, mkdtempSync, rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join, resolve } from 'node:path';
7
10
  /**
8
11
  * The repository's current HEAD, or null when the directory is not inside a
9
12
  * git repository (or git itself is unavailable).
@@ -23,8 +26,27 @@ export function currentHead(repoDir) {
23
26
  }
24
27
  }
25
28
  /**
26
- * Commit the whole working tree as a checkpoint snapshot (empty commits
27
- * allowed — a clean tree still records a rollback point).
29
+ * Porcelain entries for every tracked or untracked working-tree change.
30
+ * `null` means git could not inspect the checkout; callers must fail closed.
31
+ */
32
+ export function workingTreeChanges(repoDir) {
33
+ try {
34
+ const out = execFileSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], { cwd: repoDir, encoding: 'utf8', stdio: 'pipe' });
35
+ return out.split('\0').filter(entry => entry !== '');
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** Whether the checkout has no staged, unstaged, or untracked changes. */
42
+ export function isWorkingTreeClean(repoDir) {
43
+ const changes = workingTreeChanges(repoDir);
44
+ return changes !== null && changes.length === 0;
45
+ }
46
+ /**
47
+ * Record a clean HEAD directly, or commit the whole dirty working tree as an
48
+ * explicitly approved checkpoint snapshot. Dirty commits use a temporary git
49
+ * index: a hook/commit failure leaves the caller's real index untouched.
28
50
  * @param repoDir - repository directory.
29
51
  * @param message - checkpoint commit message.
30
52
  * @param artifactPattern - staged paths matching this are reported as
@@ -32,22 +54,62 @@ export function currentHead(repoDir) {
32
54
  * SRC_ARTIFACT_PATTERN in defaults.ts; omit for none).
33
55
  * @returns the new HEAD sha, or a failure reason.
34
56
  */
35
- export function commitCheckpoint(repoDir, message, artifactPattern) {
57
+ export function commitCheckpoint(repoDir, message, artifactPattern, includeDirty = false) {
58
+ let tempDir;
36
59
  try {
37
- execFileSync('git', ['add', '-A'], { cwd: repoDir, stdio: 'pipe' });
38
- const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: repoDir, encoding: 'utf8' });
60
+ const changes = workingTreeChanges(repoDir);
61
+ if (changes === null)
62
+ return { ok: false, error: 'git checkpoint failed: working tree status is unavailable' };
63
+ if (changes.length > 0 && !includeDirty) {
64
+ const shown = changes.slice(0, 10).map(entry => ` ${entry}`).join('\n');
65
+ const more = changes.length > 10 ? `\n … (${changes.length - 10} more)` : '';
66
+ return {
67
+ ok: false,
68
+ error: `checkpoint refused: working tree has ${changes.length} change(s); review them, then rerun with --include-dirty to commit the complete snapshot:\n${shown}${more}`,
69
+ };
70
+ }
71
+ const previousHead = currentHead(repoDir);
72
+ if (previousHead === null)
73
+ return { ok: false, error: 'git checkpoint failed: current HEAD is unavailable' };
74
+ if (changes.length === 0)
75
+ return { ok: true, sha: previousHead, artifacts: [], createdCommit: false };
76
+ const indexPathRaw = execFileSync('git', ['rev-parse', '--git-path', 'index'], { cwd: repoDir, encoding: 'utf8', stdio: 'pipe' }).trim();
77
+ const indexPath = resolve(repoDir, indexPathRaw);
78
+ tempDir = mkdtempSync(join(tmpdir(), 'ankh-guard-index-'));
79
+ const tempIndex = join(tempDir, 'index');
80
+ copyFileSync(indexPath, tempIndex);
81
+ const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
82
+ execFileSync('git', ['add', '-A'], { cwd: repoDir, env, stdio: 'pipe' });
83
+ const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: repoDir, env, encoding: 'utf8' });
39
84
  const artifacts = artifactPattern === undefined
40
85
  ? []
41
86
  : staged.split('\n').filter(file => artifactPattern.test(file));
42
- execFileSync('git', ['commit', '--allow-empty', '-m', message], { cwd: repoDir, stdio: 'pipe' });
87
+ execFileSync('git', ['commit', '-m', message], { cwd: repoDir, env, stdio: 'pipe' });
43
88
  const sha = currentHead(repoDir);
44
89
  if (sha === null)
45
90
  return { ok: false, error: 'checkpoint commit succeeded but HEAD became unreadable' };
46
- return { ok: true, sha, artifacts };
91
+ try {
92
+ // The complete snapshot is now HEAD; make the real index match it. This
93
+ // is the successful-path equivalent of the old `git add -A && commit`,
94
+ // while the temporary index protected the failure path.
95
+ execFileSync('git', ['reset', '--mixed', 'HEAD'], { cwd: repoDir, stdio: 'pipe' });
96
+ }
97
+ catch (error) {
98
+ try {
99
+ execFileSync('git', ['update-ref', 'HEAD', previousHead, sha], { cwd: repoDir, stdio: 'pipe' });
100
+ }
101
+ catch { /* best effort */ }
102
+ return { ok: false, error: `checkpoint commit could not settle the real index: ${String(error)}` };
103
+ }
104
+ return { ok: true, sha, artifacts, createdCommit: true };
47
105
  }
48
106
  catch (error) {
49
107
  return { ok: false, error: `git checkpoint failed: ${String(error)}` };
50
108
  }
109
+ finally {
110
+ if (tempDir !== undefined)
111
+ rmSync(tempDir, { recursive: true, force: true });
112
+ }
51
113
  }
52
114
  /**
53
115
  * Roll the checkout back to a checkpoint commit WITHOUT losing work: the
@@ -18,7 +18,53 @@
18
18
  */
19
19
  import type { Context } from '@deepseek-ai/cordis';
20
20
  import z from '@deepseek-ai/schemastery';
21
+ import { type RestartRequest, type RestartRequestResult } from './restart-request.ts';
21
22
  import { type GuardState, type VerifyResult } from './state.ts';
23
+ /**
24
+ * The slice of a persisted session the preset derivation reads. Structural
25
+ * rather than the rc host's `PresetBearingSession`: the 0.1.2 host deleted
26
+ * that type together with `resolveSessionPreset`.
27
+ */
28
+ interface PersistedPresetSource {
29
+ header: {
30
+ agentPreset?: string | null;
31
+ };
32
+ events: readonly {
33
+ type: string;
34
+ data?: unknown;
35
+ }[];
36
+ }
37
+ /**
38
+ * The two preset-derivation surfaces a host may carry: the rc line exports
39
+ * `resolveSessionPreset`; the 0.1.2 line replaced it (and the
40
+ * `PresetBearingSession` type) with the `agentPresetProjectionDefinition`
41
+ * unit. Probed per call, never from a version string.
42
+ */
43
+ export interface PresetDerivationSurface {
44
+ agentPresetProjectionDefinition?: {
45
+ init(header: {
46
+ agentPreset?: string | null;
47
+ }): string | null;
48
+ apply(state: string | null, event: {
49
+ type: string;
50
+ data?: unknown;
51
+ }): string | null;
52
+ };
53
+ resolveSessionPreset?: (session: PersistedPresetSource) => string | undefined;
54
+ }
55
+ /**
56
+ * Which preset a session actually runs, newest selection winning. The rc host
57
+ * exports `resolveSessionPreset` for exactly this fold; the 0.1.2 host
58
+ * replaced it with `agentPresetProjectionDefinition` (init from the header,
59
+ * fold `agent-preset/selected` events) — same semantics, so the derivation
60
+ * feature-detects either surface and never touches the version string. A host
61
+ * with neither yields undefined: the resume falls back to the deployment's
62
+ * default preset, the same outcome a preset-less session had before.
63
+ * @param host - the agent-presets module namespace, structurally probed.
64
+ * @param session - the session's header and event log.
65
+ * @returns the preset id, or `undefined` when the session names none.
66
+ */
67
+ export declare function deriveSessionPreset(host: PresetDerivationSurface, session: PersistedPresetSource): string | undefined;
22
68
  /** Plugin configuration. */
23
69
  export interface SelfRestartGuardConfig {
24
70
  /** Credential freshness window in minutes (default 10). */
@@ -75,6 +121,7 @@ export type CheckpointResult = {
75
121
  ok: true;
76
122
  sha: string;
77
123
  artifacts: string[];
124
+ createdCommit: boolean;
78
125
  } | {
79
126
  ok: false;
80
127
  error: string;
@@ -90,11 +137,13 @@ export interface SelfRestartGuard {
90
137
  */
91
138
  verify(): VerifyResult;
92
139
  /**
93
- * Record a green credential for the current HEAD.
140
+ * Record an externally proven green credential for the current clean HEAD.
141
+ * This synchronous service is a trusted orchestrator seam; agent/CLI flows
142
+ * must use `record --run -- PROGRAM` so the guard observes the exit status.
94
143
  * @param scope - what passed, e.g. 'build+test'.
95
144
  * @param options - optional command that produced the green state.
96
145
  * @returns the persisted state including the new credential.
97
- * @throws outside a git repository (no HEAD to bind to).
146
+ * @throws outside a git repository or while the checkout is dirty.
98
147
  */
99
148
  record(scope: string, options?: {
100
149
  command?: string;
@@ -110,7 +159,9 @@ export interface SelfRestartGuard {
110
159
  */
111
160
  status(): GuardState;
112
161
  /**
113
- * Commit the whole tree as a pre-batch checkpoint and remember it.
162
+ * Remember the existing clean HEAD as a pre-batch checkpoint. Dirty trees
163
+ * are refused; the CLI's reviewed `--include-dirty` path is deliberately
164
+ * unavailable through this convenience service.
114
165
  * @param message - batch description; defaults to 'batch snapshot'.
115
166
  * @returns the checkpoint commit sha, or a failure reason.
116
167
  */
@@ -134,6 +185,17 @@ export interface SelfRestartGuard {
134
185
  canary(options?: {
135
186
  port?: number;
136
187
  }): Promise<CanaryResult>;
188
+ /**
189
+ * Trigger a guarded restart onto a new launch command (a UI-grade
190
+ * mode/profile switch). Dispatches on supervision: no live watchdog → the
191
+ * restart verb's stop→start→canary; supervised → reconfigure's transactional
192
+ * cutover (the only safe way to change the launch command under a live
193
+ * watchdog). The full gate chain applies — credential, composition
194
+ * preflight, marker/lock — and a refusal never stops the running instance.
195
+ * @param request - start: the successor launch command; profile: the dsh profile to preflight; initiator: the session id the restart report returns to (required — the UI caller knows the real session, never invent one).
196
+ * @returns the structured verdict; terminal hint text never crosses this seam.
197
+ */
198
+ requestRestart(request: RestartRequest): Promise<RestartRequestResult>;
137
199
  }
138
200
  declare module '@deepseek-ai/cordis' {
139
201
  interface Context {
@@ -150,4 +212,5 @@ export declare const inject: string[];
150
212
  * @param config - validated plugin config.
151
213
  */
152
214
  export declare function apply(ctx: Context, config: SelfRestartGuardConfig): void;
215
+ export {};
153
216
  //# sourceMappingURL=index.d.ts.map
@@ -1,16 +1,47 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
2
  import { connect } from 'node:net';
3
3
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
4
- import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets';
5
- import { execFileSync } from 'node:child_process';
4
+ // Namespace handle for runtime feature detection: the 0.1.2 host replaced
5
+ // the `resolveSessionPreset` free function (and the `PresetBearingSession`
6
+ // type) with the `agentPresetProjectionDefinition` unit, and a STATIC named
7
+ // import of a removed export is a SyntaxError at module load — exactly the
8
+ // failure this dual-host probing exists to survive. The derivation below
9
+ // reads both surfaces through one structural cast.
10
+ import * as agentPresetsHost from '@deepseek-ai/dsh-agent-presets';
6
11
  import { existsSync, readFileSync, unlinkSync } from 'node:fs';
7
12
  import { dirname, join } from 'node:path';
8
13
  import { fileURLToPath } from 'node:url';
9
14
  import { resolveRepoDir, resolveStateDir, SRC_ARTIFACT_PATTERN } from "./defaults.js";
10
- import { commitCheckpoint, currentHead, resetToCheckpoint } from "./git.js";
15
+ import { commitCheckpoint, currentHead, isWorkingTreeClean, resetToCheckpoint } from "./git.js";
16
+ import { listeningPortsForPid } from "./processes.js";
17
+ import { cutoverBlocksWake } from "./launch-spec.js";
11
18
  import { stateFile } from "./state-files.js";
12
- import { acknowledgeRestartRecord, buildLaunchCommand, continueAndReportText, continueInterruptedText, interruptedSnapshotFile, writeInstanceLaunch, writeSkillRegistration, pendingRestartRecord, readInterruptedSnapshot, restartContextText, writeInterruptedSnapshot, } from "./restart-context.js";
19
+ import { registerBrowserHandoff } from "./browser-handoff.js";
20
+ import { requestRestart } from "./restart-request.js";
21
+ import { acknowledgeRestartRecord, buildLaunchCommand, continueAndReportText, continueInterruptedText, interruptedSnapshotFile, isParkedOnUserInput, writeInstanceLaunch, writeSkillRegistration, pendingRestartRecord, readInterruptedSnapshot, restartContextText, writeInterruptedSnapshot, } from "./restart-context.js";
13
22
  import { clearCredential, loadState, recordCredential, setCheckpoint, verifyCredential, } from "./state.js";
23
+ /**
24
+ * Which preset a session actually runs, newest selection winning. The rc host
25
+ * exports `resolveSessionPreset` for exactly this fold; the 0.1.2 host
26
+ * replaced it with `agentPresetProjectionDefinition` (init from the header,
27
+ * fold `agent-preset/selected` events) — same semantics, so the derivation
28
+ * feature-detects either surface and never touches the version string. A host
29
+ * with neither yields undefined: the resume falls back to the deployment's
30
+ * default preset, the same outcome a preset-less session had before.
31
+ * @param host - the agent-presets module namespace, structurally probed.
32
+ * @param session - the session's header and event log.
33
+ * @returns the preset id, or `undefined` when the session names none.
34
+ */
35
+ export function deriveSessionPreset(host, session) {
36
+ if (host.agentPresetProjectionDefinition !== undefined) {
37
+ const projection = host.agentPresetProjectionDefinition;
38
+ let state = projection.init(session.header);
39
+ for (const event of session.events)
40
+ state = projection.apply(state, event);
41
+ return state ?? undefined;
42
+ }
43
+ return host.resolveSessionPreset?.(session);
44
+ }
14
45
  export const Config = z.object({
15
46
  maxAgeMinutes: z.natural().min(1).default(10),
16
47
  stateDir: z.string().default(''),
@@ -56,7 +87,13 @@ function registerRestartSkill(ctx, stateDir) {
56
87
  writeSkillRegistration(stateDir, { registered: false, reason: 'shipped SKILL.md malformed', at: Date.now() });
57
88
  return;
58
89
  }
59
- ctx.effect(() => skills.register({ name, description, content }));
90
+ ctx.effect(() => skills.register({
91
+ name,
92
+ description,
93
+ content,
94
+ source: 'runtime',
95
+ provider: 'ankh-guard',
96
+ }));
60
97
  writeSkillRegistration(stateDir, { registered: true, at: Date.now() });
61
98
  }
62
99
  catch (error) {
@@ -119,6 +156,7 @@ export function apply(ctx, config) {
119
156
  // (default-on!) with no warning — a misconfiguration failing silent.
120
157
  const followupReport = reportMode === 'followup';
121
158
  registerRestartSkill(ctx, stateDir);
159
+ registerBrowserHandoff(ctx, stateDir);
122
160
  if (followupReport || resumeInterrupted) {
123
161
  const pluginMessage = (text) => createUserMessage({
124
162
  content: [{ type: 'text', text }],
@@ -129,6 +167,33 @@ export function apply(ctx, config) {
129
167
  const pendingContinue = new Map();
130
168
  let disposed = false;
131
169
  ctx.effect(() => () => { disposed = true; });
170
+ // A turn parked on user input (an open ask_user_question call, or an
171
+ // undecided approval) is not interrupted WORK — the card persists in the
172
+ // log and the user answers whenever. Auto-continuing it replays the
173
+ // question and burns a turn for nothing (observed on prod 3080: sessions
174
+ // parked on question cards were woken on every restart of an upgrade
175
+ // day). The probe reads the session's repaired log tail; memoized per
176
+ // boot; a probe failure fails open to the pre-existing behavior.
177
+ const parkedMemo = new Map();
178
+ const checkParked = (id) => {
179
+ let probe = parkedMemo.get(id);
180
+ if (probe === undefined) {
181
+ probe = (async () => {
182
+ try {
183
+ const persistence = ctx.get('sessionPersistence');
184
+ if (persistence === undefined)
185
+ return false;
186
+ const { events } = await persistence.inspect(id);
187
+ return isParkedOnUserInput(events);
188
+ }
189
+ catch {
190
+ return false;
191
+ }
192
+ })();
193
+ parkedMemo.set(id, probe);
194
+ }
195
+ return probe;
196
+ };
132
197
  const claim = (agent, record) => {
133
198
  const canaryPending = existsSync(stateFile(stateDir, 'restartRequested'));
134
199
  const text = restartContextText(record, canaryPending);
@@ -142,33 +207,65 @@ export function apply(ctx, config) {
142
207
  };
143
208
  // The single delivery path for every resume trigger (this plugin's pass,
144
209
  // the UI, the schedule system): an interrupted session gets exactly one
145
- // "continue" injection; the restart's initiator gets the report — merged
146
- // into one message when it is both. The map makes repeat calls no-ops.
210
+ // "continue" injection — unless its interrupted turn was parked on user
211
+ // input, in which case the card in the log is the continuation and no
212
+ // injection fires; the restart's initiator gets the report — merged into
213
+ // one message when it is both. The map makes repeat calls no-ops.
147
214
  const deliver = (agent) => {
215
+ // Transport can be up before the watchdog has exchanged the launch URL,
216
+ // handed it to the browser, or run the canary. Do not let that early
217
+ // mount wake a session; the release poll below retries live roots once
218
+ // the durable receipt reaches a terminal phase.
219
+ if (cutoverBlocksWake(stateDir))
220
+ return;
148
221
  const id = agent.id;
149
222
  const exitAt = pendingContinue.get(id);
150
223
  const record = followupReport ? pendingRestartRecord(stateDir) : null;
151
- if (exitAt !== undefined && record !== null
152
- && (record.initiator === undefined || id === record.initiator)) {
153
- // The initiator was itself interrupted by its own restart: one
154
- // combined turn continues the work AND reports the outcome — two
155
- // separate injections would run two near-duplicate turns.
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
- // A record with nothing to report yet (no exitAt/error) must not
165
- // swallow the continue — fall through to the continue-only path.
166
- }
224
+ const owesReport = record !== null && (record.initiator === undefined || id === record.initiator);
167
225
  if (exitAt !== undefined) {
168
- agent.followup(pluginMessage(continueInterruptedText(exitAt)));
169
- // Delete only after a successful injection: a throwing followup keeps
170
- // the session eligible at its next creation (same rule as claim()).
171
- pendingContinue.delete(id);
226
+ void (async () => {
227
+ const parked = await checkParked(id);
228
+ if (disposed || pendingContinue.get(id) !== exitAt)
229
+ return;
230
+ // Delete before injecting: two resume triggers racing the memoized
231
+ // probe must not double-inject; a throwing followup re-arms the
232
+ // session for its next creation (same rule as claim()).
233
+ pendingContinue.delete(id);
234
+ if (parked) {
235
+ // Parked on user input: the card IS the continuation. An owed
236
+ // report still lands (report-only text, not the merged one).
237
+ if (owesReport && record !== null)
238
+ claim(agent, record);
239
+ return;
240
+ }
241
+ if (owesReport && record !== null) {
242
+ // The initiator was itself interrupted by its own restart: one
243
+ // combined turn continues the work AND reports the outcome — two
244
+ // separate injections would run two near-duplicate turns.
245
+ const canaryPending = existsSync(stateFile(stateDir, 'restartRequested'));
246
+ const text = continueAndReportText(record, canaryPending);
247
+ if (text !== '') {
248
+ try {
249
+ agent.followup(pluginMessage(text));
250
+ acknowledgeRestartRecord(stateDir, record, Date.now());
251
+ return;
252
+ }
253
+ catch {
254
+ pendingContinue.set(id, exitAt);
255
+ return;
256
+ }
257
+ }
258
+ // A record with nothing to report yet (no exitAt/error) must not
259
+ // swallow the continue — fall through to the continue-only path.
260
+ }
261
+ try {
262
+ agent.followup(pluginMessage(continueInterruptedText(exitAt)));
263
+ }
264
+ catch {
265
+ pendingContinue.set(id, exitAt);
266
+ }
267
+ })();
268
+ return;
172
269
  }
173
270
  if (record === null)
174
271
  return;
@@ -232,7 +329,7 @@ export function apply(ctx, config) {
232
329
  const persistence = ctx.get('sessionPersistence');
233
330
  if (presets !== undefined && persistence !== undefined) {
234
331
  const inspected = await persistence.inspect(id);
235
- const presetId = resolveSessionPreset({ header: inspected.meta, events: inspected.events });
332
+ const presetId = deriveSessionPreset(agentPresetsHost, { header: inspected.meta, events: inspected.events });
236
333
  setup = async (agentCtx) => { await presets.mount(agentCtx, (await presets.resolve(presetId)).id); };
237
334
  }
238
335
  return { resumeSessionId: id, agentOptions, ...(setup === undefined ? {} : { setup }) };
@@ -260,6 +357,11 @@ export function apply(ctx, config) {
260
357
  // happens through `deliver` below, from the `agent/created` listener or
261
358
  // the live branch here.
262
359
  const resumePass = async () => {
360
+ while (!disposed && cutoverBlocksWake(stateDir)) {
361
+ await new Promise(resolve => setTimeout(resolve, 250));
362
+ }
363
+ if (disposed)
364
+ return;
263
365
  const snapshot = readInterruptedSnapshot(stateDir);
264
366
  if (snapshot === null)
265
367
  return;
@@ -269,6 +371,13 @@ export function apply(ctx, config) {
269
371
  for (const id of [...new Set([...snapshot.resume, ...snapshot.interrupted])]) {
270
372
  if (disposed)
271
373
  return;
374
+ // Parked on user input: the card in the log is the continuation —
375
+ // do not recreate the agent at all (its creation would fire the
376
+ // delivery path; deliver() also filters, belt and suspenders).
377
+ if (await checkParked(id)) {
378
+ pendingContinue.delete(id);
379
+ continue;
380
+ }
272
381
  const live = ctx.agents.list().find(agent => agent.id === id);
273
382
  if (live !== undefined) {
274
383
  // Already live: its `agent/created` may have predated this plugin's
@@ -297,6 +406,19 @@ export function apply(ctx, config) {
297
406
  const timer = setTimeout(() => { void resumePass(); }, resumeDelayMs);
298
407
  ctx.effect(() => () => { clearTimeout(timer); });
299
408
  }
409
+ // The browser handoff can create an agent while the receipt still says
410
+ // canary-pending. agent/created is one-shot, so retry already-live roots
411
+ // after release; acknowledgement and pendingContinue preserve exact-once.
412
+ if (cutoverBlocksWake(stateDir)) {
413
+ const releaseTimer = setInterval(() => {
414
+ if (disposed || cutoverBlocksWake(stateDir))
415
+ return;
416
+ clearInterval(releaseTimer);
417
+ for (const agent of ctx.agents.roots())
418
+ deliver(agent);
419
+ }, 250);
420
+ ctx.effect(() => () => { clearInterval(releaseTimer); });
421
+ }
300
422
  ctx.on('agent/created', ({ agent }) => {
301
423
  if (!ctx.agents.roots().includes(agent))
302
424
  return;
@@ -313,6 +435,8 @@ export function apply(ctx, config) {
313
435
  const record = pendingRestartRecord(stateDir);
314
436
  if (record === null)
315
437
  return decision;
438
+ if (cutoverBlocksWake(stateDir))
439
+ return decision;
316
440
  const canaryPending = existsSync(stateFile(stateDir, 'restartRequested'));
317
441
  const text = restartContextText(record, canaryPending);
318
442
  if (text === '')
@@ -334,11 +458,13 @@ export function apply(ctx, config) {
334
458
  }, { prepend: true });
335
459
  }
336
460
  const service = {
337
- verify: () => verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes),
461
+ verify: () => verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes, isWorkingTreeClean(repoDir)),
338
462
  record: (scope, options) => {
339
463
  const head = currentHead(repoDir);
340
464
  if (head === null)
341
465
  throw new Error('ankh-guard: cannot record a credential outside a git repository');
466
+ if (!isWorkingTreeClean(repoDir))
467
+ throw new Error('ankh-guard: cannot record a credential while the working tree is dirty');
342
468
  return recordCredential(stateDir, { scope, revision: head, command: options?.command ?? '' }, Date.now());
343
469
  },
344
470
  clear: () => clearCredential(stateDir, Date.now()),
@@ -351,6 +477,7 @@ export function apply(ctx, config) {
351
477
  return result;
352
478
  },
353
479
  reset: sha => resetToCheckpoint(repoDir, sha),
480
+ requestRestart: request => requestRestart(request, { stateDir, repoDir }),
354
481
  canary: async (options) => {
355
482
  const checks = [];
356
483
  const verdict = service.verify();
@@ -378,16 +505,7 @@ export function apply(ctx, config) {
378
505
  if (key.startsWith('DSH_') && value !== undefined)
379
506
  env[key] = value;
380
507
  }
381
- let port;
382
- try {
383
- const out = execFileSync('lsof', ['-a', '-p', String(process.pid), '-iTCP', '-sTCP:LISTEN', '-P'], { encoding: 'utf8', stdio: 'pipe' });
384
- const match = /:(\d+) \(LISTEN\)/.exec(out);
385
- if (match !== null)
386
- port = Number(match[1]);
387
- }
388
- catch {
389
- // lsof unavailable or nothing listening yet — the record still helps.
390
- }
508
+ const port = listeningPortsForPid(process.pid)[0];
391
509
  writeInstanceLaunch(stateDir, {
392
510
  command: buildLaunchCommand(process.execPath, process.execArgv, process.argv.slice(1), process.cwd(), env),
393
511
  source: 'instance',