@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.
- package/CHANGELOG.md +18 -0
- package/README.en.md +9 -4
- package/README.i18n.yaml +2 -2
- package/README.md +9 -4
- package/lib/cli.js +1 -1
- package/lib/client.js +104 -17
- package/lib/index.js +94 -12
- package/lib/preflight-runner.js +13 -2
- package/lib/{restart-context-DUyExi9O.js → restart-context-DlaDZWKq.js} +11 -6
- package/lib/types/browser-handoff.d.ts +6 -0
- package/lib/types/browser-handoff.js +39 -0
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.js +108 -24
- package/lib/types/index.d.ts +34 -8
- package/lib/types/index.js +70 -15
- package/lib/types/preflight-runner.d.ts +1 -1
- package/lib/types/preflight-runner.js +34 -7
- package/lib/types/restart-context.js +11 -6
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +14 -20
- package/lib/processes-BjZgJjQr.js +0 -344
- package/lib/restart-context-D6nISh28.js +0 -1245
- package/lib/state-4f7yny39.js +0 -286
- package/lib/test-seam-DnvLWTeO.js +0 -119
|
@@ -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
|
|
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
|
|
10
|
-
const
|
|
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
|
-
|
|
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 ? '
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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 >=
|
|
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 (
|
|
150
|
-
timer
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
|
133
|
+
export declare const Config: z;
|
|
108
134
|
/** One canary check line. */
|
|
109
135
|
export interface CanaryCheck {
|
|
110
136
|
name: string;
|
package/lib/types/index.js
CHANGED
|
@@ -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: '
|
|
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
|
|
219
|
+
const persistence = probeColdReader(ctx);
|
|
184
220
|
if (persistence === undefined)
|
|
185
221
|
return false;
|
|
186
|
-
const { events } = await persistence
|
|
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
|
-
|
|
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
|
|
377
|
+
const persistence = probeColdReader(ctx);
|
|
330
378
|
if (presets !== undefined && persistence !== undefined) {
|
|
331
|
-
const inspected = await persistence
|
|
332
|
-
const
|
|
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: '
|
|
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.
|
|
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.
|
|
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.
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
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)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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