@khorsheed/dsh-ankh-guard 0.2.0 → 0.3.1

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.
@@ -16,6 +16,14 @@
16
16
  * means the process it knew is gone — answer ready/reload. A cutover receipt
17
17
  * always wins over the generation check: during a cutover the old tab's known
18
18
  * id is already stale, and the receipt protocol owns the pacing.
19
+ *
20
+ * The generation answer waits for the successor's composition to settle. The
21
+ * shared Web-server seat answers as soon as it listens — well before sibling
22
+ * rows mount — and a page that loads then calls into a half-mounted /api
23
+ * surface (the Web app gates its own browser handoff on the same Loader
24
+ * settlement for that reason). Until then the poll is answered `waiting`
25
+ * WITHOUT the boot id: teaching the tab the successor's id would consume its
26
+ * one-shot generation check and strand it on an unusable page.
19
27
  */
20
28
  import { createHash, randomBytes } from 'node:crypto';
21
29
  import { chmodSync, linkSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
@@ -313,6 +321,13 @@ export function createBrowserHandoffHandler(dependencies) {
313
321
  // would race the registration/ack dance.
314
322
  if (bootId !== undefined && message.knownBootId !== undefined && message.knownBootId !== bootId
315
323
  && activeCutover(dependencies.stateDir) === null) {
324
+ if (dependencies.applicationReady?.() === false) {
325
+ // The successor's composition is still mounting. Answer WITHOUT the
326
+ // boot id (not through `respond`): the tab must keep its stale id so
327
+ // this one-shot generation check survives until readiness lands.
328
+ json(res, 200, { state: 'waiting' });
329
+ return;
330
+ }
316
331
  respond(200, { state: 'ready', action: 'reload', authentication: 'existing-cookie' });
317
332
  return;
318
333
  }
@@ -483,7 +498,31 @@ export function registerBrowserHandoff(ctx, stateDir) {
483
498
  const handler = createBrowserHandoffHandler({
484
499
  stateDir,
485
500
  connectionProvider: () => webCtx.get('connection'),
501
+ applicationReady: applicationReadyProbe(webCtx),
486
502
  });
487
503
  webCtx.effect(() => webServer.register({ kind: 'exact', path: BROWSER_HANDOFF_ROUTE, handler }), 'ankh-guard: browser handoff route');
488
504
  });
489
505
  }
506
+ /**
507
+ * Build the generation channel's readiness probe from the Loader tree.
508
+ *
509
+ * The Web-server seat is shared: it answers as soon as it listens, while
510
+ * sibling rows — including the /api route owner a reloaded page immediately
511
+ * calls into — are still mounting. The Web app gates its own URL line and
512
+ * browser handoff on this exact settlement, so the guard's automatic reload
513
+ * must wait for it too. A composition without a Loader is already complete.
514
+ *
515
+ * Only settlement counts as ready: a failed tree leaves the tab on its old
516
+ * page (the watchdog's crash recovery owns that outcome), and the next healthy
517
+ * process answers the still-stale poll.
518
+ * @param ctx - context owning the Loader service.
519
+ * @returns probe consulted by every boot-generation poll.
520
+ */
521
+ function applicationReadyProbe(ctx) {
522
+ const settled = ctx.get('loader')?.await();
523
+ if (settled === undefined)
524
+ return () => true;
525
+ let ready = false;
526
+ void settled.then(() => { ready = true; }, () => { });
527
+ return () => ready;
528
+ }
@@ -2,7 +2,7 @@
2
2
  * Browser half of the original-tab launch-cutover handoff, plus the
3
3
  * boot-generation reload loop: polls carry the last-seen boot id, a stale id
4
4
  * with no cutover in flight reloads the tab once, and a sustained disconnection
5
- * (not a transient blip) raises a neutral overlay.
5
+ * (not a transient blip) raises a non-blocking connection notice.
6
6
  */
7
7
  import type { Context as ClientContext } from '@deepseek-ai/cordis';
8
8
  /** No Cordis services are needed; this is a same-origin browser lifecycle observer. */
@@ -6,8 +6,10 @@ const FALLBACK_HASH_KEY = 'ankh-guard-handoff';
6
6
  const ACTIVE_RETRY_MS = 250;
7
7
  const ERROR_RETRY_MIN_MS = 1_000;
8
8
  const ERROR_RETRY_MAX_MS = 30_000;
9
- /** Sustained-failure threshold for the disconnected overlay; transient blips never show it. */
10
- const DISCONNECTED_OVERLAY_MS = 5_000;
9
+ /** Sustained-failure threshold for the connection notice; transient blips never show it. */
10
+ const DISCONNECTED_NOTICE_MS = 5_000;
11
+ // Server idle polls hold for 25s; bound a stalled browser request beyond that.
12
+ const REQUEST_TIMEOUT_MS = 35_000;
11
13
  /** No Cordis services are needed; this is a same-origin browser lifecycle observer. */
12
14
  export const inject = [];
13
15
  function encodeBase64Url(bytes) {
@@ -88,38 +90,55 @@ function consumeFallbackFragment() {
88
90
  writePending(pending);
89
91
  return pending;
90
92
  }
91
- function waitingOverlay(kind = 'restarting') {
93
+ function waitingOverlay(kind = 'restarting', retry) {
92
94
  const existing = document.getElementById('ankh-guard-browser-handoff');
93
- if (existing !== null)
95
+ // A confirmed restart cannot be downgraded by a subsequent transport failure.
96
+ if (existing?.dataset.kind === 'restarting' && kind === 'disconnected')
94
97
  return existing;
98
+ const element = existing ?? document.createElement('div');
95
99
  const zh = navigator.language.toLowerCase().startsWith('zh');
96
- const element = document.createElement('div');
97
100
  element.id = 'ankh-guard-browser-handoff';
101
+ element.dataset.kind = kind;
98
102
  element.setAttribute('role', 'status');
99
103
  element.setAttribute('aria-live', 'polite');
100
- // 'disconnected' covers failures without a known restart in flight (a bare
101
- // exit may never come back) — neutral copy, no auto-recovery promise.
102
104
  element.textContent = kind === 'disconnected'
103
- ? (zh ? '连接已断开,等待服务恢复。' : 'Connection lost; waiting for the service to recover.')
105
+ ? (zh ? '连接暂时中断,正在重试…' : 'Connection interrupted; retrying…')
104
106
  : (zh ? 'dsh 正在重启;此标签页会自动恢复。' : 'dsh is restarting; this tab will recover automatically.');
105
- element.style.cssText = [
107
+ element.style.cssText = kind === 'restarting' ? [
106
108
  'position:fixed', 'inset:0', 'z-index:2147483647', 'display:grid', 'place-items:center',
107
109
  'padding:24px', 'background:rgba(15,18,24,.92)', 'color:#fff',
108
110
  'font:500 16px/1.5 system-ui,sans-serif', 'text-align:center',
111
+ ].join(';') : [
112
+ 'position:fixed', 'top:calc(env(safe-area-inset-top,0px) + 12px)', 'left:12px', 'right:12px',
113
+ 'max-width:520px', 'margin:0 auto', 'box-sizing:border-box', 'z-index:2147483647',
114
+ 'display:flex', 'align-items:center', 'justify-content:space-between', 'gap:12px',
115
+ 'padding:12px 16px', 'border:1px solid var(--dsw-alias-border-l2,#8885)', 'border-radius:16px',
116
+ 'background:var(--dsw-alias-bg-base,#fff)', 'color:var(--dsw-alias-label-primary,#181a20)',
117
+ 'box-shadow:0 4px 24px #0002', 'font:400 15px/1.5 system-ui,sans-serif',
109
118
  ].join(';');
110
- document.documentElement.append(element);
119
+ if (kind === 'disconnected' && retry) {
120
+ const button = document.createElement('button');
121
+ button.textContent = zh ? '重试' : 'Retry';
122
+ button.style.cssText = 'flex:none;border:0;background:transparent;color:var(--dsw-alias-state-business-primary,#4d6bfe);font:inherit;min-height:36px;cursor:pointer';
123
+ button.onclick = retry;
124
+ element.append(button);
125
+ }
126
+ // Official theme tokens live on body, so the notice must inherit from it.
127
+ if (!existing)
128
+ (document.body ?? document.documentElement).append(element);
111
129
  return element;
112
130
  }
113
- async function post(body) {
131
+ async function post(body, signal) {
114
132
  return fetch(ROUTE, {
115
133
  method: 'POST',
134
+ signal,
116
135
  credentials: 'same-origin',
117
136
  cache: 'no-store',
118
137
  headers: { 'content-type': 'application/json' },
119
138
  body: JSON.stringify(body),
120
139
  });
121
140
  }
122
- async function acknowledge(pending) {
141
+ async function acknowledge(pending, signal) {
123
142
  const response = await post({
124
143
  version: 1,
125
144
  operation: 'ack',
@@ -127,7 +146,7 @@ async function acknowledge(pending) {
127
146
  channel: pending.channel,
128
147
  authentication: pending.authentication,
129
148
  ...(pending.capability !== undefined ? { capability: pending.capability } : {}),
130
- });
149
+ }, signal);
131
150
  return response.status === 204;
132
151
  }
133
152
  /** Install the polling/reload loop for this tab's lifetime. */
@@ -138,24 +157,68 @@ export function apply(_ctx) {
138
157
  let cap;
139
158
  let errorRetryMs = ERROR_RETRY_MIN_MS;
140
159
  let firstFailureAt;
141
- // Fullscreen notice only for SUSTAINED disconnection (restart window, bare
142
- // exit): transient network blips and the backoff cadence never trip it.
160
+ let request;
161
+ let lastWakeAt = -Infinity;
162
+ const foreground = () => document.visibilityState !== 'hidden' && navigator.onLine !== false;
163
+ const clearConnectionNotice = () => {
164
+ const notice = document.getElementById('ankh-guard-browser-handoff');
165
+ if (notice?.dataset.kind === 'disconnected')
166
+ notice.remove();
167
+ };
168
+ const pause = () => {
169
+ if (timer !== undefined)
170
+ clearTimeout(timer);
171
+ timer = undefined;
172
+ const previous = request;
173
+ request = undefined;
174
+ previous?.abort();
175
+ firstFailureAt = undefined;
176
+ errorRetryMs = ERROR_RETRY_MIN_MS;
177
+ clearConnectionNotice();
178
+ };
179
+ const wake = () => {
180
+ if (disposed || !foreground() || Date.now() - lastWakeAt < 500)
181
+ return;
182
+ lastWakeAt = Date.now();
183
+ pause();
184
+ schedule();
185
+ };
186
+ const visibility = () => {
187
+ if (!foreground()) {
188
+ lastWakeAt = -Infinity;
189
+ pause();
190
+ }
191
+ else
192
+ wake();
193
+ };
194
+ const page = (event) => { if (event.persisted)
195
+ wake(); };
196
+ // Only visible failures count. Ordinary outages never block the page.
143
197
  const noteDisconnected = () => {
144
198
  firstFailureAt ??= Date.now();
145
- if (Date.now() - firstFailureAt >= DISCONNECTED_OVERLAY_MS)
146
- waitingOverlay('disconnected');
199
+ if (Date.now() - firstFailureAt >= DISCONNECTED_NOTICE_MS)
200
+ waitingOverlay('disconnected', wake);
147
201
  };
148
202
  const schedule = (delayMs = 0) => {
149
- if (!disposed)
150
- timer = setTimeout(() => { void tick(); }, delayMs);
203
+ if (timer !== undefined)
204
+ clearTimeout(timer);
205
+ if (!disposed && foreground())
206
+ timer = setTimeout(() => { timer = undefined; void tick(); }, delayMs);
151
207
  };
152
208
  const tick = async () => {
153
- if (disposed)
209
+ if (disposed || !foreground() || request)
154
210
  return;
211
+ const current = new AbortController();
212
+ request = current;
213
+ const stale = () => disposed || request !== current || !foreground();
214
+ const deadline = setTimeout(() => current.abort(), REQUEST_TIMEOUT_MS);
155
215
  try {
156
216
  if (pending !== null) {
157
217
  waitingOverlay();
158
- if (await acknowledge(pending)) {
218
+ const acknowledged = await acknowledge(pending, current.signal);
219
+ if (stale())
220
+ return;
221
+ if (acknowledged) {
159
222
  const returnPath = pending.returnPath;
160
223
  const originalTab = pending.channel === 'original-tab';
161
224
  sessionStorage.removeItem(PENDING_KEY);
@@ -178,7 +241,9 @@ export function apply(_ctx) {
178
241
  const response = await post({
179
242
  version: 1, operation: 'poll', capability: tabCapability,
180
243
  ...(knownBootId === null ? {} : { knownBootId }),
181
- });
244
+ }, current.signal);
245
+ if (stale())
246
+ return;
182
247
  if (!response.ok) {
183
248
  noteDisconnected();
184
249
  schedule(errorRetryMs);
@@ -188,6 +253,8 @@ export function apply(_ctx) {
188
253
  errorRetryMs = ERROR_RETRY_MIN_MS;
189
254
  firstFailureAt = undefined;
190
255
  const result = await response.json();
256
+ if (stale())
257
+ return;
191
258
  // Learn the serving boot id before acting on it — storing the successor's
192
259
  // id ahead of the reload is what makes the generation check one-shot.
193
260
  if (typeof result.bootId === 'string')
@@ -247,18 +314,35 @@ export function apply(_ctx) {
247
314
  }
248
315
  }
249
316
  catch {
317
+ if (stale())
318
+ return;
250
319
  // The expected restart interval rejects fetches; keep the old page and retry.
251
320
  noteDisconnected();
252
321
  schedule(errorRetryMs);
253
322
  errorRetryMs = Math.min(errorRetryMs * 2, ERROR_RETRY_MAX_MS);
254
323
  return;
255
324
  }
325
+ finally {
326
+ clearTimeout(deadline);
327
+ if (request === current)
328
+ request = undefined;
329
+ }
256
330
  schedule(ACTIVE_RETRY_MS);
257
331
  };
332
+ document.addEventListener('visibilitychange', visibility);
333
+ window.addEventListener('online', wake);
334
+ window.addEventListener('offline', visibility);
335
+ window.addEventListener('pageshow', page);
336
+ window.addEventListener('dsh-mobile-foreground', wake);
258
337
  void tick();
259
338
  return () => {
260
339
  disposed = true;
261
- if (timer !== undefined)
262
- clearTimeout(timer);
340
+ pause();
341
+ document.removeEventListener('visibilitychange', visibility);
342
+ window.removeEventListener('online', wake);
343
+ window.removeEventListener('offline', visibility);
344
+ window.removeEventListener('pageshow', page);
345
+ window.removeEventListener('dsh-mobile-foreground', wake);
346
+ document.getElementById('ankh-guard-browser-handoff')?.remove();
263
347
  };
264
348
  }
@@ -18,6 +18,15 @@
18
18
  */
19
19
  import type { Context } from '@deepseek-ai/cordis';
20
20
  import z from '@deepseek-ai/schemastery';
21
+ import type { ContextFormed } from '@deepseek-ai/dsh-llm';
22
+ declare module '@deepseek-ai/dsh-llm' {
23
+ interface MessageSourceMap {
24
+ 'ankh-guard': {
25
+ kind: 'ankh-guard';
26
+ plugin: string;
27
+ } & ContextFormed;
28
+ }
29
+ }
21
30
  import { type RestartRequest, type RestartRequestResult } from './restart-request.ts';
22
31
  import { type GuardState, type VerifyResult } from './state.ts';
23
32
  /**
@@ -52,6 +61,23 @@ export interface PresetDerivationSurface {
52
61
  };
53
62
  resolveSessionPreset?: (session: PersistedPresetSource) => string | undefined;
54
63
  }
64
+ /**
65
+ * Resolve the preset-registry module by name. The 0.1.7-rc.1 host renamed
66
+ * `@deepseek-ai/dsh-agent-presets` to `@deepseek-ai/dsh-agent-preset-registry`
67
+ * (the resolve/mount/livePresetMounts/agentPresetProjectionDefinition API
68
+ * carried over); 0.1.5/0.1.6 hosts install only the old name, and a host
69
+ * without preset composition installs neither — `null`, and the derivation
70
+ * then receives `{}`, the established no-preset degrade (the resume falls
71
+ * back to the deployment's default preset). A STATIC import of either name is
72
+ * a module-resolution failure at load on hosts installing only the other —
73
+ * pack-dist strips `dependencies`, so the specifier resolves against the
74
+ * host's install tree. Both specifiers stay static string literals so tsdown
75
+ * externalizes them and tsc type-checks them.
76
+ * @returns the module namespace, or null when neither name resolves.
77
+ */
78
+ export declare function loadPresetRegistry(): Promise<unknown>;
79
+ /** Test seam: drop the cached probe so a mocked resolution path re-runs. */
80
+ export declare function resetPresetRegistryProbeForTest(): void;
55
81
  /**
56
82
  * Which preset a session actually runs, newest selection winning. The rc host
57
83
  * exports `resolveSessionPreset` for exactly this fold; the 0.1.2 host
@@ -68,11 +94,11 @@ export declare function deriveSessionPreset(host: PresetDerivationSurface, sessi
68
94
  /** Plugin configuration. */
69
95
  export interface SelfRestartGuardConfig {
70
96
  /** Credential freshness window in minutes (default 10). */
71
- maxAgeMinutes?: number;
97
+ maxAgeMinutes?: number | undefined;
72
98
  /** State directory; defaults to $DSH_HOME/state, else `<cwd>/.dsh-guard-state`. */
73
- stateDir?: string;
99
+ stateDir?: string | undefined;
74
100
  /** Repository the credential binds to; defaults to the process cwd. */
75
- repoDir?: string;
101
+ repoDir?: string | undefined;
76
102
  /**
77
103
  * How to surface a scheduled restart's record to the agent (default
78
104
  * `followup` — fully autonomous: the plugin queues the report as the next
@@ -80,7 +106,7 @@ export interface SelfRestartGuardConfig {
80
106
  * system uses for reminders, so the agent reports without any user message).
81
107
  * `step` rides the first step of whatever turn comes next; `off` disables.
82
108
  */
83
- reportRestartContext?: 'followup' | 'step' | 'off';
109
+ reportRestartContext?: 'followup' | 'step' | 'off' | undefined;
84
110
  /**
85
111
  * Resume the sessions a restart interrupted (default true). At SIGTERM the
86
112
  * plugin snapshots which root sessions had a live turn (plus the restart's
@@ -90,21 +116,21 @@ export interface SelfRestartGuardConfig {
90
116
  * The pass only runs on a restart boot (restart marker or pending restart
91
117
  * record present); a cold start drops the snapshot without acting.
92
118
  */
93
- resumeInterrupted?: boolean;
119
+ resumeInterrupted?: boolean | undefined;
94
120
  /**
95
121
  * Delay before the interrupted-session resume pass runs after plugin load
96
122
  * (default 5000 ms), so the pass starts turns only after the app's services
97
123
  * are up.
98
124
  */
99
- resumeDelayMs?: number;
125
+ resumeDelayMs?: number | undefined;
100
126
  /**
101
127
  * Maximum age of the interrupted-session snapshot the resume pass honors
102
128
  * (default 600000 ms, ten minutes). A snapshot older than that comes from a
103
129
  * manual stop/start, not a restart, and is dropped without acting.
104
130
  */
105
- resumeMaxSnapshotAgeMs?: number;
131
+ resumeMaxSnapshotAgeMs?: number | undefined;
106
132
  }
107
- export declare const Config: z<SelfRestartGuardConfig>;
133
+ export declare const Config: z;
108
134
  /** One canary check line. */
109
135
  export interface CanaryCheck {
110
136
  name: string;
@@ -1,13 +1,6 @@
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
- // 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';
11
4
  import { existsSync, readFileSync, unlinkSync } from 'node:fs';
12
5
  import { dirname, join } from 'node:path';
13
6
  import { fileURLToPath } from 'node:url';
@@ -20,6 +13,31 @@ import { registerBrowserHandoff } from "./browser-handoff.js";
20
13
  import { requestRestart } from "./restart-request.js";
21
14
  import { acknowledgeRestartRecord, buildLaunchCommand, continueAndReportText, continueInterruptedText, interruptedSnapshotFile, isParkedOnUserInput, writeInstanceLaunch, writeSkillRegistration, pendingRestartRecord, readInterruptedSnapshot, restartContextText, writeInterruptedSnapshot, } from "./restart-context.js";
22
15
  import { clearCredential, loadState, recordCredential, setCheckpoint, verifyCredential, } from "./state.js";
16
+ let presetRegistryProbe;
17
+ /**
18
+ * Resolve the preset-registry module by name. The 0.1.7-rc.1 host renamed
19
+ * `@deepseek-ai/dsh-agent-presets` to `@deepseek-ai/dsh-agent-preset-registry`
20
+ * (the resolve/mount/livePresetMounts/agentPresetProjectionDefinition API
21
+ * carried over); 0.1.5/0.1.6 hosts install only the old name, and a host
22
+ * without preset composition installs neither — `null`, and the derivation
23
+ * then receives `{}`, the established no-preset degrade (the resume falls
24
+ * back to the deployment's default preset). A STATIC import of either name is
25
+ * a module-resolution failure at load on hosts installing only the other —
26
+ * pack-dist strips `dependencies`, so the specifier resolves against the
27
+ * host's install tree. Both specifiers stay static string literals so tsdown
28
+ * externalizes them and tsc type-checks them.
29
+ * @returns the module namespace, or null when neither name resolves.
30
+ */
31
+ export function loadPresetRegistry() {
32
+ presetRegistryProbe ??= import('@deepseek-ai/dsh-agent-preset-registry')
33
+ .catch(() => import('@deepseek-ai/dsh-agent-presets'))
34
+ .catch(() => null);
35
+ return presetRegistryProbe;
36
+ }
37
+ /** Test seam: drop the cached probe so a mocked resolution path re-runs. */
38
+ export function resetPresetRegistryProbeForTest() {
39
+ presetRegistryProbe = undefined;
40
+ }
23
41
  /**
24
42
  * Which preset a session actually runs, newest selection winning. The rc host
25
43
  * exports `resolveSessionPreset` for exactly this fold; the 0.1.2 host
@@ -42,6 +60,24 @@ export function deriveSessionPreset(host, session) {
42
60
  }
43
61
  return host.resolveSessionPreset?.(session);
44
62
  }
63
+ /** Probe the session-persistence service's cold-read face. */
64
+ function probeColdReader(ctx) {
65
+ const service = ctx.get('sessionPersistence');
66
+ if (service === undefined || service === null)
67
+ return undefined;
68
+ return typeof service.open === 'function' ? service : undefined;
69
+ }
70
+ /** Read one persisted session's header and full log through a read handle. */
71
+ async function readColdLog(persistence, id) {
72
+ const handle = await persistence.open(id, 'read');
73
+ try {
74
+ const cold = await handle.read(0);
75
+ return { meta: handle.header, events: cold.events };
76
+ }
77
+ finally {
78
+ await handle.close();
79
+ }
80
+ }
45
81
  export const Config = z.object({
46
82
  maxAgeMinutes: z.natural().min(1).default(10),
47
83
  stateDir: z.string().default(''),
@@ -160,7 +196,7 @@ export function apply(ctx, config) {
160
196
  if (followupReport || resumeInterrupted) {
161
197
  const pluginMessage = (text) => createUserMessage({
162
198
  content: [{ type: 'text', text }],
163
- source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name: 'restart', text }] },
199
+ source: { kind: 'ankh-guard', plugin: name, form: 'snapshot', sections: [{ name: 'restart', text }] },
164
200
  });
165
201
  // Interrupted sessions awaiting their `agent/created` to receive the
166
202
  // "continue" followup: session id → exitAt of the interrupting exit.
@@ -180,10 +216,10 @@ export function apply(ctx, config) {
180
216
  if (probe === undefined) {
181
217
  probe = (async () => {
182
218
  try {
183
- const persistence = ctx.get('sessionPersistence');
219
+ const persistence = probeColdReader(ctx);
184
220
  if (persistence === undefined)
185
221
  return false;
186
- const { events } = await persistence.inspect(id);
222
+ const { events } = await readColdLog(persistence, id);
187
223
  return isParkedOnUserInput(events);
188
224
  }
189
225
  catch {
@@ -220,7 +256,19 @@ export function apply(ctx, config) {
220
256
  return;
221
257
  const id = agent.id;
222
258
  const exitAt = pendingContinue.get(id);
223
- const record = followupReport ? pendingRestartRecord(stateDir) : null;
259
+ let record = followupReport ? pendingRestartRecord(stateDir) : null;
260
+ // A bare PLANNED outcome with no initiating session (the restart was
261
+ // driven from outside the host — the operator's terminal already has
262
+ // the announcement) has no in-host owner: settle the record instead of
263
+ // waking whichever root session happens to mount first. Records
264
+ // carrying diagnostics someone must hear about — an unplanned
265
+ // recovery, a composition rollback, a failed restart — keep the
266
+ // first-created claim.
267
+ if (record !== null && record.initiator === undefined
268
+ && record.unexpected !== true && record.compositionRecovered !== true && record.error === undefined) {
269
+ acknowledgeRestartRecord(stateDir, record, Date.now());
270
+ record = null;
271
+ }
224
272
  const owesReport = record !== null && (record.initiator === undefined || id === record.initiator);
225
273
  if (exitAt !== undefined) {
226
274
  void (async () => {
@@ -326,10 +374,14 @@ export function apply(ctx, config) {
326
374
  agentOptions.model = selection.model;
327
375
  let setup;
328
376
  const presets = ctx.get('agentPresets');
329
- const persistence = ctx.get('sessionPersistence');
377
+ const persistence = probeColdReader(ctx);
330
378
  if (presets !== undefined && persistence !== undefined) {
331
- const inspected = await persistence.inspect(id);
332
- const presetId = deriveSessionPreset(agentPresetsHost, { header: inspected.meta, events: inspected.events });
379
+ const inspected = await readColdLog(persistence, id);
380
+ const host = await loadPresetRegistry();
381
+ const presetId = deriveSessionPreset((host ?? {}), {
382
+ header: inspected.meta,
383
+ events: inspected.events,
384
+ });
333
385
  setup = async (agentCtx) => { await presets.mount(agentCtx, (await presets.resolve(presetId)).id); };
334
386
  }
335
387
  return { resumeSessionId: id, agentOptions, ...(setup === undefined ? {} : { setup }) };
@@ -419,6 +471,9 @@ export function apply(ctx, config) {
419
471
  }, 250);
420
472
  ctx.effect(() => () => { clearInterval(releaseTimer); });
421
473
  }
474
+ // agent/created went @mode serial in 0.1.6: the creation transaction
475
+ // awaits whatever a listener returns, so delivery stays fire-and-forget
476
+ // here — return nothing, and never await agent.whenIdle in this listener.
422
477
  ctx.on('agent/created', ({ agent }) => {
423
478
  if (!ctx.agents.roots().includes(agent))
424
479
  return;
@@ -449,7 +504,7 @@ export function apply(ctx, config) {
449
504
  createUserMessage({
450
505
  content: [{ type: 'text', text }],
451
506
  source: {
452
- kind: 'plugin', plugin: name, form: 'snapshot',
507
+ kind: 'ankh-guard', plugin: name, form: 'snapshot',
453
508
  sections: [{ name: 'restart', text }],
454
509
  },
455
510
  }),
@@ -58,7 +58,7 @@ export interface PreflightComposition {
58
58
  * bundle layers in `dsh.profile.bundles` order, the profile user layer, the
59
59
  * home-level user layer, `--patch` overlays, the agent-presets roots overlay
60
60
  * (rc host line only; the 0.1.2 line's preset package self-ships its root),
61
- * then the telemetry switch. Two host API generations are mirrored and
61
+ * then the telemetry switch. Three host API generations are mirrored and
62
62
  * feature-detected per run — see the `hostLine` branch below. Exported so
63
63
  * the drift tripwire can compare this assembly against the launcher's own
64
64
  * dump without booting anything.
@@ -131,7 +131,7 @@ class PreflightInfraError extends Error {
131
131
  * bundle layers in `dsh.profile.bundles` order, the profile user layer, the
132
132
  * home-level user layer, `--patch` overlays, the agent-presets roots overlay
133
133
  * (rc host line only; the 0.1.2 line's preset package self-ships its root),
134
- * then the telemetry switch. Two host API generations are mirrored and
134
+ * then the telemetry switch. Three host API generations are mirrored and
135
135
  * feature-detected per run — see the `hostLine` branch below. Exported so
136
136
  * the drift tripwire can compare this assembly against the launcher's own
137
137
  * dump without booting anything.
@@ -158,6 +158,8 @@ export async function composePreflightPatches(profile, patchFiles, root, home, b
158
158
  }
159
159
  const composeEntries = appBoot.composeEntries;
160
160
  const healProfilesModuleFallback = appBoot.healProfilesModuleFallback;
161
+ const createProfileResolutionGeneration = appBoot.createProfileResolutionGeneration;
162
+ const createRuntimeResolution = appBoot.createRuntimeResolution;
161
163
  const loadOptionalPatches = appBoot.loadOptionalPatches;
162
164
  const loadOverlayPatches = appBoot.loadOverlayPatches;
163
165
  const loadProfile = appBoot.loadProfile;
@@ -174,12 +176,21 @@ export async function composePreflightPatches(profile, patchFiles, root, home, b
174
176
  // the profile composition: the heal moved behind the async options API and
175
177
  // below the profile load (see HealProfilesModuleFallback), and the launcher
176
178
  // dropped its agent-presets shipped-root overlay because the preset package
177
- // now self-ships its root. DEFAULT_PROFILE_PATCH_RELOAD is a value export
178
- // only the new line carries, so it is the feature marker; a version parse
179
- // would break on exactly the unreleased builds this runner must dry-run.
180
- // Both lines stay supported: prod hosts run the rc line until 0.1.2 lands
181
- // on npm.
182
- const hostLine = 'DEFAULT_PROFILE_PATCH_RELOAD' in appBoot ? '0.1.2' : 'rc';
179
+ // now self-ships its root. The 0.1.6 line flipped the default resolution
180
+ // mode from link to runtime: its compose computes an immutable resolution
181
+ // generation instead of materializing fallback links, and it removed
182
+ // DEFAULT_PROFILE_PATCH_RELOAD — the 0.1.2 line's marker. The 0.1.7 line
183
+ // deleted the fallback projections outright (in-memory runtime resolution
184
+ // interception): its compose calls createRuntimeResolution and carries
185
+ // neither older marker, so the markers must be probed newest-first. Each
186
+ // marker is a value export only its line carries; a version parse would
187
+ // break on exactly the unreleased builds this runner must dry-run. All
188
+ // lines stay supported: prod hosts run 0.1.5 until the next npm line lands.
189
+ const hostLine = 'createRuntimeResolution' in appBoot
190
+ ? '0.1.7'
191
+ : 'createProfileResolutionGeneration' in appBoot
192
+ ? '0.1.6'
193
+ : 'DEFAULT_PROFILE_PATCH_RELOAD' in appBoot ? '0.1.2' : 'rc';
183
194
  if (hostLine === 'rc')
184
195
  healProfilesModuleFallback(anchor, resolvedHome);
185
196
  const composed = loadProfile(NAME, profile, anchor, resolvedHome, { userLayer: true });
@@ -192,6 +203,22 @@ export async function composePreflightPatches(profile, patchFiles, root, home, b
192
203
  if (hostLine === '0.1.2') {
193
204
  await healProfilesModuleFallback({ installAnchor: anchor, profile: composed, home: resolvedHome });
194
205
  }
206
+ else if (hostLine === '0.1.6') {
207
+ // The launcher compose's runtime-mode step (apps/cli composeProfile):
208
+ // compute the generation AFTER the profile load and root-config rewrite,
209
+ // materializing nothing. Awaited, so a resolution-graph failure rejects
210
+ // the compose instead of escaping as an unhandled rejection. The explicit
211
+ // home keeps the recorded profilesDir on the deployment under check.
212
+ await createProfileResolutionGeneration({ installAnchor: anchor, profile: composed, home: resolvedHome });
213
+ }
214
+ else if (hostLine === '0.1.7') {
215
+ // The 0.1.7 launcher compose (apps/cli composeProfile): the resolution is
216
+ // computed in memory right after the profile load and root-config rewrite
217
+ // — the older lines' fallback projections are gone for good. Awaited, so
218
+ // a resolution-graph failure rejects the compose instead of escaping as
219
+ // an unhandled rejection.
220
+ await createRuntimeResolution({ installAnchor: anchor, profile: composed });
221
+ }
195
222
  const homePatches = loadOptionalPatches(NAME, join(resolvedHome, HOME_PATCH_FILENAME)) ?? [];
196
223
  const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)));
197
224
  const bundlePatches = composed.layers.flatMap(layer => layer.patches);
@@ -2,12 +2,17 @@
2
2
  * Restart-record context injection and interrupted-session continuity. After
3
3
  * a scheduled restart, the FULL report waits for the initiating session's
4
4
  * root agent, whenever it resumes (session restore is lazy, so no other
5
- * session is ever woken for reporting); a record without an initiator is
6
- * claimed by the first root agent created. Separately, a snapshot written at
7
- * SIGTERM time (`interrupted-sessions.json`) records which root sessions had
8
- * a live turn when the process stopped, so the next restart boot can resume
9
- * those sessions and queue a "continue" turn. Pure logic reads the durable
10
- * files; the plugin wires them into `agent/created` and `agent.followup`.
5
+ * session is ever woken for reporting). A record without an initiator splits
6
+ * by kind: one carrying diagnostics someone must hear about (an unplanned
7
+ * recovery, a composition rollback, a failed restart) is claimed by the
8
+ * first root agent created, while a bare planned outcome — the restart was
9
+ * driven from outside the host and the operator's terminal already has the
10
+ * announcement — is settled without waking anyone. Separately, a snapshot
11
+ * written at SIGTERM time (`interrupted-sessions.json`) records which root
12
+ * sessions had a live turn when the process stopped, so the next restart
13
+ * boot can resume those sessions and queue a "continue" turn. Pure logic
14
+ * reads the durable files; the plugin wires them into `agent/created` and
15
+ * `agent.followup`.
11
16
  */
12
17
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
13
18
  import { stateFile } from "./state-files.js";
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Ankh Guard",
4
+ "description": "Gate self-modification restarts on a green build and test credential."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "重启守卫",
4
+ "description": "先证明构建与测试通过,才放行自我修改的重启。"
5
+ }
6
+ }