@ddtcorex/dsh-maestro-supervisor 0.5.3 → 0.6.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.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * dsh-maestro-supervisor — host plugin for auto-resume inside DSH web.
3
+ * Runs inside the DSH host process (outside the daemon's tree) and
4
+ * auto-resumes sessions interrupted within the configured window after
5
+ * a restart. The standalone daemon (systemd) handles crash detection
6
+ * and web restart; this plugin handles the in-process resume.
7
+ */
8
+ import { findInterrupted as defaultFindInterrupted, findDanglingOpenTurns as defaultFindDanglingOpenTurns } from './resume.js';
9
+ export declare const inject: readonly ["sessions", "agents", "connection"];
10
+ export interface SupervisorPluginConfig {
11
+ autoResumeWithin?: number | string;
12
+ autoResumeEnabled?: boolean;
13
+ }
14
+ export declare function runAutoResume(ctx: any, opts?: {
15
+ findInterrupted?: typeof defaultFindInterrupted;
16
+ findDanglingOpenTurns?: typeof defaultFindDanglingOpenTurns;
17
+ resumeInterrupted?: typeof resumeInterrupted;
18
+ config?: SupervisorPluginConfig;
19
+ }): Promise<void>;
20
+ export declare function resumeInterrupted(ctx: any, ids: string[]): Promise<string[]>;
21
+ export declare function createResumeRpcHandler(ctx: any, opts?: {
22
+ resumeInterrupted?: typeof resumeInterrupted;
23
+ config?: SupervisorPluginConfig;
24
+ }): (endpoint: string, payload: unknown, _signal: AbortSignal) => Promise<{
25
+ ok: boolean;
26
+ value: import("./resume.js").ResumeResult;
27
+ error?: undefined;
28
+ } | {
29
+ ok: boolean;
30
+ error: {
31
+ code: string;
32
+ message: string;
33
+ };
34
+ value?: undefined;
35
+ } | {
36
+ ok: boolean;
37
+ value: {
38
+ resumed: string[];
39
+ };
40
+ error?: undefined;
41
+ }>;
42
+ export declare function apply(ctx: any, config?: SupervisorPluginConfig): void;
package/lib/plugin.js ADDED
@@ -0,0 +1,290 @@
1
+ /**
2
+ * dsh-maestro-supervisor — host plugin for auto-resume inside DSH web.
3
+ * Runs inside the DSH host process (outside the daemon's tree) and
4
+ * auto-resumes sessions interrupted within the configured window after
5
+ * a restart. The standalone daemon (systemd) handles crash detection
6
+ * and web restart; this plugin handles the in-process resume.
7
+ */
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import * as os from 'node:os';
11
+ import { findInterrupted as defaultFindInterrupted, findDanglingOpenTurns as defaultFindDanglingOpenTurns } from './resume.js';
12
+ export const inject = ['sessions', 'agents', 'connection'];
13
+ function parseDuration(s) {
14
+ if (!s)
15
+ return undefined;
16
+ const m = s.trim().match(/^(\d+)(s|m|h)?$/);
17
+ if (!m)
18
+ return undefined;
19
+ const n = parseInt(m[1], 10);
20
+ const unit = m[2] ?? 's';
21
+ if (unit === 's')
22
+ return n * 1000;
23
+ if (unit === 'm')
24
+ return n * 60 * 1000;
25
+ if (unit === 'h')
26
+ return n * 60 * 60 * 1000;
27
+ return undefined;
28
+ }
29
+ function getAutoResumeEnabled(config) {
30
+ // The Cordis-supplied config (cordis.patch.yml's `config:` block, or whatever
31
+ // the caller passes to apply()) is the highest-precedence source — it is an
32
+ // explicit, per-install decision and must win over ambient env/files.
33
+ if (typeof config?.autoResumeEnabled === 'boolean')
34
+ return config.autoResumeEnabled;
35
+ const env = process.env.DSH_SUPERVISOR_AUTO_RESUME;
36
+ if (env !== undefined) {
37
+ const v = env.trim().toLowerCase();
38
+ if (['1', 'true', 'yes', 'on', 'enabled'].includes(v))
39
+ return true;
40
+ if (['0', 'false', 'no', 'off', 'disabled'].includes(v))
41
+ return false;
42
+ }
43
+ try {
44
+ const cfgPath = path.join(os.homedir(), '.dsh/.supervisor/config.json');
45
+ if (fs.existsSync(cfgPath)) {
46
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
47
+ const raw = cfg.autoResumeEnabled ?? cfg.autoResume;
48
+ if (typeof raw === 'boolean')
49
+ return raw;
50
+ if (typeof raw === 'string') {
51
+ const v = raw.trim().toLowerCase();
52
+ if (['1', 'true', 'yes', 'on'].includes(v))
53
+ return true;
54
+ if (['0', 'false', 'no', 'off'].includes(v))
55
+ return false;
56
+ }
57
+ }
58
+ const maestroPath = path.join(os.homedir(), '.dsh/maestro/settings.json');
59
+ if (fs.existsSync(maestroPath)) {
60
+ const j = JSON.parse(fs.readFileSync(maestroPath, 'utf-8'));
61
+ const raw = j?.domains?.supervisor?.autoResumeEnabled ?? j?.supervisor?.autoResumeEnabled;
62
+ if (typeof raw === 'boolean')
63
+ return raw;
64
+ if (typeof raw === 'string') {
65
+ const v = raw.trim().toLowerCase();
66
+ if (['1', 'true', 'yes', 'on'].includes(v))
67
+ return true;
68
+ if (['0', 'false', 'no', 'off'].includes(v))
69
+ return false;
70
+ }
71
+ }
72
+ }
73
+ catch { }
74
+ return true;
75
+ }
76
+ function getResumeWithinMs(config) {
77
+ // Same precedence rule as getAutoResumeEnabled: explicit config wins first.
78
+ if (config?.autoResumeWithin !== undefined) {
79
+ const raw = config.autoResumeWithin;
80
+ if (typeof raw === 'number')
81
+ return raw * 60 * 1000;
82
+ if (typeof raw === 'string') {
83
+ if (/^\d+$/.test(raw.trim()))
84
+ return parseInt(raw.trim(), 10) * 60 * 1000;
85
+ const v = parseDuration(raw);
86
+ if (v !== undefined)
87
+ return v;
88
+ }
89
+ }
90
+ const env = process.env.DSH_SUPERVISOR_RESUME_WITHIN;
91
+ if (env) {
92
+ if (/^\d+$/.test(env.trim()))
93
+ return parseInt(env.trim(), 10) * 60 * 1000;
94
+ const v = parseDuration(env);
95
+ if (v !== undefined)
96
+ return v;
97
+ }
98
+ try {
99
+ const cfgPath = path.join(os.homedir(), '.dsh/.supervisor/config.json');
100
+ if (fs.existsSync(cfgPath)) {
101
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
102
+ const raw = cfg.autoResumeWithin;
103
+ if (typeof raw === 'string') {
104
+ if (/^\d+$/.test(raw.trim()))
105
+ return parseInt(raw.trim(), 10) * 60 * 1000;
106
+ const v = parseDuration(raw);
107
+ if (v !== undefined)
108
+ return v;
109
+ }
110
+ else if (typeof raw === 'number')
111
+ return raw * 60 * 1000;
112
+ }
113
+ const maestroPath = path.join(os.homedir(), '.dsh/maestro/settings.json');
114
+ if (fs.existsSync(maestroPath)) {
115
+ const j = JSON.parse(fs.readFileSync(maestroPath, 'utf-8'));
116
+ const raw = j?.domains?.supervisor?.autoResumeWithin ?? j?.supervisor?.autoResumeWithin;
117
+ if (typeof raw === 'string') {
118
+ if (/^\d+$/.test(raw.trim()))
119
+ return parseInt(raw.trim(), 10) * 60 * 1000;
120
+ const v = parseDuration(raw);
121
+ if (v !== undefined)
122
+ return v;
123
+ }
124
+ else if (typeof raw === 'number')
125
+ return raw * 60 * 1000;
126
+ }
127
+ }
128
+ catch { }
129
+ return 5 * 60 * 1000;
130
+ }
131
+ export async function runAutoResume(ctx, opts = {}) {
132
+ try {
133
+ const doFind = opts.findInterrupted ?? defaultFindInterrupted;
134
+ const doFindDangling = opts.findDanglingOpenTurns ?? defaultFindDanglingOpenTurns;
135
+ const doResume = opts.resumeInterrupted ?? resumeInterrupted;
136
+ if (!getAutoResumeEnabled(opts?.config)) {
137
+ ctx.logger?.info?.('[supervisor] auto-resume disabled — skip');
138
+ return;
139
+ }
140
+ const withinMs = getResumeWithinMs(opts?.config);
141
+ const { scanned, interrupted } = await doFind(undefined, { withinMs });
142
+ // Only safe to treat a dangling open turn as crashed right after a fresh
143
+ // boot, when this process is the sole live owner of these sessions —
144
+ // exactly the context runAutoResume runs in (called once, 8s after
145
+ // apply()). resumeInterrupted()'s own "already live" check additionally
146
+ // protects any id that happens to be live in *this* process already.
147
+ let dangling = [];
148
+ try {
149
+ dangling = (await doFindDangling(undefined, { withinMs })).interrupted;
150
+ }
151
+ catch (e) {
152
+ ctx.logger?.warn?.(`[supervisor] auto-resume: dangling-open-turn scan failed, continuing with closed-turn results only: ${e?.message ?? String(e)}`);
153
+ }
154
+ const merged = Array.from(new Set([...interrupted, ...dangling]));
155
+ if (!merged.length) {
156
+ ctx.logger?.info?.(`[supervisor] auto-resume: 0/${scanned} interrupted within ${withinMs}ms — nothing to do`);
157
+ return;
158
+ }
159
+ ctx.logger?.info?.(`[supervisor] auto-resume: ${merged.length}/${scanned} interrupted within ${withinMs}ms: ${merged.slice(0, 3).join(', ')}`);
160
+ await doResume(ctx, merged);
161
+ }
162
+ catch (e) {
163
+ try {
164
+ ctx.logger?.warn?.(`[supervisor] auto-resume error: ${e?.message ?? String(e)}`);
165
+ }
166
+ catch { }
167
+ }
168
+ }
169
+ export async function resumeInterrupted(ctx, ids) {
170
+ const resumed = [];
171
+ for (const id of ids) {
172
+ try {
173
+ const sessionId = id.split('/').pop();
174
+ const agents = ctx.get?.('agents') ?? ctx.agents;
175
+ let agent = agents?.get?.(sessionId);
176
+ if (agent === undefined) {
177
+ const { SessionId } = await import('@deepseek-ai/dsh-session').catch(() => ({ SessionId: (s) => s }));
178
+ const sid = SessionId ? SessionId(sessionId) : sessionId;
179
+ const persistence = ctx.get?.('sessionPersistence') ?? ctx.sessionPersistence;
180
+ let agentOptions;
181
+ try {
182
+ const loaded = await persistence?.load?.(sessionId);
183
+ const context = Array.isArray(loaded?.events)
184
+ ? [...loaded.events].reverse().find((event) => event?.type === 'request/context')?.data
185
+ : undefined;
186
+ if (typeof context?.provider === 'string' && typeof context?.model === 'string') {
187
+ agentOptions = { provider: context.provider, model: context.model };
188
+ }
189
+ }
190
+ catch (e) {
191
+ ctx.logger?.warn?.(`[supervisor] auto-resume: could not recover route for ${id}: ${e?.message ?? String(e)}`);
192
+ }
193
+ const handle = await agents?.resume?.({
194
+ resumeSessionId: sid,
195
+ ...(agentOptions === undefined ? {} : { agentOptions }),
196
+ });
197
+ agent = handle?.agent;
198
+ if (agent !== undefined)
199
+ ctx.logger?.info?.(`[supervisor] auto-resume: re-attached agent for ${id}`);
200
+ }
201
+ if (typeof agent?.followup !== 'function') {
202
+ ctx.logger?.warn?.(`[supervisor] auto-resume: no live agent available for ${id}`);
203
+ continue;
204
+ }
205
+ const { createUserMessage } = await import('@deepseek-ai/dsh-llm').catch(() => ({
206
+ createUserMessage: (input) => ({ ...input, role: 'user', id: crypto.randomUUID() }),
207
+ }));
208
+ agent.followup(createUserMessage({
209
+ content: [{ type: 'text', text: 'continue' }],
210
+ source: { kind: 'user' },
211
+ }));
212
+ resumed.push(id);
213
+ ctx.logger?.info?.(`[supervisor] auto-resume: sent continue trigger for ${id}`);
214
+ }
215
+ catch (e) {
216
+ ctx.logger?.warn?.(`[supervisor] auto-resume failed ${id}: ${e?.message ?? String(e)}`);
217
+ }
218
+ }
219
+ return resumed;
220
+ }
221
+ export function createResumeRpcHandler(ctx, opts = {}) {
222
+ const resume = opts.resumeInterrupted ?? resumeInterrupted;
223
+ return async (endpoint, payload, _signal) => {
224
+ if (endpoint === 'scan') {
225
+ const withinMs = typeof payload?.withinMs === 'number'
226
+ ? payload.withinMs
227
+ : getResumeWithinMs(opts.config);
228
+ return { ok: true, value: await defaultFindInterrupted(undefined, { withinMs }) };
229
+ }
230
+ if (endpoint !== 'resume') {
231
+ return { ok: false, error: { code: 'bad-request', message: `unsupported endpoint: ${endpoint}` } };
232
+ }
233
+ const ids = Array.isArray(payload?.ids)
234
+ ? payload.ids.filter((id) => typeof id === 'string' && id.length > 0)
235
+ : [];
236
+ if (!ids.length) {
237
+ return { ok: false, error: { code: 'bad-request', message: 'resume requires at least one session id' } };
238
+ }
239
+ return { ok: true, value: { resumed: await resume(ctx, ids) } };
240
+ };
241
+ }
242
+ export function apply(ctx, config = {}) {
243
+ // The whole body is wrapped: per AGENTS.md, apply() must never throw
244
+ // synchronously or let a rejected promise escape, no matter what fails
245
+ // (ctx.effect missing/throwing, RPC registration throwing, or even the
246
+ // error-reporting logger call itself throwing).
247
+ try {
248
+ ctx.effect(() => {
249
+ let disposed = false;
250
+ let timer = null;
251
+ timer = setTimeout(() => {
252
+ if (disposed)
253
+ return;
254
+ runAutoResume(ctx, { config }).catch(() => {
255
+ // runAutoResume already never rejects, but guard the .catch itself is a no-op safety net
256
+ });
257
+ }, 8000);
258
+ let disposeRpc;
259
+ try {
260
+ const conn = ctx.connection ?? ctx.get?.('connection');
261
+ if (conn?.rpc?.handle) {
262
+ disposeRpc = conn.rpc.handle('/dsh-maestro-supervisor-resume', createResumeRpcHandler(ctx, { config }), { authority: 'loopback' });
263
+ }
264
+ }
265
+ catch (e) {
266
+ try {
267
+ ctx.logger?.warn?.(`[supervisor] auto-resume RPC registration failed: ${e?.message ?? String(e)}`);
268
+ }
269
+ catch { }
270
+ }
271
+ return () => {
272
+ disposed = true;
273
+ if (timer)
274
+ clearTimeout(timer);
275
+ if (disposeRpc) {
276
+ try {
277
+ disposeRpc();
278
+ }
279
+ catch { }
280
+ }
281
+ };
282
+ }, 'supervisor:auto-resume');
283
+ }
284
+ catch (e) {
285
+ try {
286
+ ctx.logger?.warn?.(`[supervisor] auto-resume apply() failed: ${e?.message ?? String(e)}`);
287
+ }
288
+ catch { }
289
+ }
290
+ }
package/lib/resume.d.ts CHANGED
@@ -2,4 +2,23 @@ export interface ResumeResult {
2
2
  scanned: number;
3
3
  interrupted: string[];
4
4
  }
5
- export declare function findInterrupted(dshHome?: string): Promise<ResumeResult>;
5
+ export interface FindInterruptedOpts {
6
+ withinMs?: number;
7
+ sinceMs?: number;
8
+ }
9
+ export declare function findInterrupted(dshHome?: string, opts?: FindInterruptedOpts): Promise<ResumeResult>;
10
+ /**
11
+ * Detect sessions whose raw log ends with a `turn/start` that has no
12
+ * matching `turn/end` anywhere later in the scanned log — a dangling open
13
+ * turn. Unlike {@link findInterrupted}, this needs no prior `persistence.load()`
14
+ * call to have already synthesized a `turn/end interrupted` closer (DSH core
15
+ * only writes that closer when something loads/prepares the specific
16
+ * session — a genuinely fresh crash's raw log has no closer at all, only an
17
+ * open turn). Safe to call ONLY right after a fresh `dsh web` boot, when
18
+ * `dsh web` is the sole live process for these sessions — an open turn found
19
+ * at that moment cannot belong to a still-running generation anywhere else.
20
+ * Callers MUST additionally skip any id that is live in their own current
21
+ * process (e.g. `ctx.sessions.get(id)`) before treating a match as crashed.
22
+ */
23
+ export declare function findDanglingOpenTurns(dshHome?: string, opts?: FindInterruptedOpts): Promise<ResumeResult>;
24
+ export declare function parseDuration(s: string): number | undefined;
package/lib/resume.js CHANGED
@@ -1,11 +1,49 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as os from 'node:os';
4
- export async function findInterrupted(dshHome) {
4
+ /**
5
+ * Read the last ~100 lines of one session's raw log, applying the mtime
6
+ * pre-filter before any (potentially expensive) zstd decompression: a
7
+ * session log's mtime only advances when something is appended to it, so a
8
+ * file older than `sinceMs` cannot contain anything within the window.
9
+ * Shared by every raw-log scan below so the pre-filter (Critical: this
10
+ * scan previously blocked the host event loop ~5.5s across 412 sessions on
11
+ * a real machine before this filter existed) can't be accidentally
12
+ * bypassed by a future scan variant.
13
+ * @returns `undefined` when the session has no log file, or is filtered
14
+ * out by `sinceMs` — callers must treat that the same as "nothing found".
15
+ */
16
+ async function readSessionTailLines(zstdPath, jsonlPath, sinceMs) {
17
+ if (sinceMs !== undefined) {
18
+ try {
19
+ const statPath = fs.existsSync(zstdPath) ? zstdPath : (fs.existsSync(jsonlPath) ? jsonlPath : undefined);
20
+ if (statPath) {
21
+ const mtimeMs = fs.statSync(statPath).mtimeMs;
22
+ if (mtimeMs < sinceMs)
23
+ return undefined;
24
+ }
25
+ }
26
+ catch { }
27
+ }
28
+ if (fs.existsSync(zstdPath)) {
29
+ const { execSync } = await import('node:child_process');
30
+ const out = execSync(`zstd -d -c ${JSON.stringify(zstdPath)} 2>/dev/null | tail -100`, { encoding: 'utf-8' });
31
+ return out.split('\n').filter(Boolean);
32
+ }
33
+ if (fs.existsSync(jsonlPath)) {
34
+ const content = fs.readFileSync(jsonlPath, 'utf-8');
35
+ return content.trim().split('\n').slice(-100);
36
+ }
37
+ return undefined;
38
+ }
39
+ export async function findInterrupted(dshHome, opts) {
5
40
  const home = dshHome ?? path.join(os.homedir(), '.dsh');
6
41
  const sessionsRoot = path.join(home, 'sessions');
7
42
  let scanned = 0;
8
43
  const interrupted = [];
44
+ const now = Date.now();
45
+ const withinMs = opts?.withinMs;
46
+ const sinceMs = opts?.sinceMs ?? (withinMs !== undefined ? now - withinMs : undefined);
9
47
  try {
10
48
  const groups = fs.readdirSync(sessionsRoot, { withFileTypes: true });
11
49
  for (const g of groups) {
@@ -20,17 +58,34 @@ export async function findInterrupted(dshHome) {
20
58
  const zstdPath = path.join(groupPath, s.name, 'session.jsonl.zstd');
21
59
  const jsonlPath = path.join(groupPath, s.name, 'session.jsonl');
22
60
  try {
23
- let content = '';
24
- if (fs.existsSync(zstdPath)) {
25
- const { execSync } = await import('node:child_process');
26
- content = execSync(`zstd -d -c ${JSON.stringify(zstdPath)} 2>/dev/null | tail -5`, { encoding: 'utf-8' });
61
+ const lines = await readSessionTailLines(zstdPath, jsonlPath, sinceMs);
62
+ if (lines === undefined)
63
+ continue;
64
+ let found = false;
65
+ let foundTime;
66
+ for (let i = lines.length - 1; i >= 0; i--) {
67
+ const line = lines[i];
68
+ try {
69
+ const obj = JSON.parse(line);
70
+ foundTime = typeof obj.time === 'number' ? obj.time : undefined;
71
+ if (obj.type === 'turn/end' && obj.data?.reason?.kind === 'interrupted') {
72
+ found = true;
73
+ break;
74
+ }
75
+ }
76
+ catch { }
27
77
  }
28
- else if (fs.existsSync(jsonlPath)) {
29
- content = fs.readFileSync(jsonlPath, 'utf-8').slice(-5000);
78
+ if (!found)
79
+ continue;
80
+ if (sinceMs !== undefined && foundTime !== undefined) {
81
+ if (foundTime < sinceMs)
82
+ continue; // too old
30
83
  }
31
- if (content.toLowerCase().includes('interrupted')) {
32
- interrupted.push(`${g.name}/${s.name}`);
84
+ else if (sinceMs !== undefined && foundTime === undefined) {
85
+ // no timestamp, skip when filtering by time
86
+ continue;
33
87
  }
88
+ interrupted.push(`${g.name}/${s.name}`);
34
89
  }
35
90
  catch { }
36
91
  }
@@ -39,3 +94,120 @@ export async function findInterrupted(dshHome) {
39
94
  catch { }
40
95
  return { scanned, interrupted };
41
96
  }
97
+ /**
98
+ * Read the entire log for a recent session (mtime within window) to find an
99
+ * open turn that may be far from the tail. Used only by
100
+ * {@link findDanglingOpenTurns}, which must see the last `turn/start`
101
+ * even when the file is 1906 lines and the open turn is at line 2 (subagent
102
+ * `b6487e33` had its only `turn/start` at seq 6, missed by `tail -100`).
103
+ * The mtime pre-filter ensures this full decompression runs only for
104
+ * recent sessions (within 5m, typically 1-2 files), not for all 425.
105
+ */
106
+ async function readSessionAllLines(zstdPath, jsonlPath, sinceMs) {
107
+ if (sinceMs !== undefined) {
108
+ try {
109
+ const statPath = fs.existsSync(zstdPath) ? zstdPath : (fs.existsSync(jsonlPath) ? jsonlPath : undefined);
110
+ if (statPath) {
111
+ const mtimeMs = fs.statSync(statPath).mtimeMs;
112
+ if (mtimeMs < sinceMs)
113
+ return undefined;
114
+ }
115
+ }
116
+ catch { }
117
+ }
118
+ if (fs.existsSync(zstdPath)) {
119
+ const { execSync } = await import('node:child_process');
120
+ const out = execSync(`zstd -d -c ${JSON.stringify(zstdPath)} 2>/dev/null`, { encoding: 'utf-8' });
121
+ return out.split('\n').filter(Boolean);
122
+ }
123
+ if (fs.existsSync(jsonlPath)) {
124
+ const content = fs.readFileSync(jsonlPath, 'utf-8');
125
+ return content.trim().split('\n').filter(Boolean);
126
+ }
127
+ return undefined;
128
+ }
129
+ /**
130
+ * Detect sessions whose raw log ends with a `turn/start` that has no
131
+ * matching `turn/end` anywhere later in the scanned log — a dangling open
132
+ * turn. Unlike {@link findInterrupted}, this needs no prior `persistence.load()`
133
+ * call to have already synthesized a `turn/end interrupted` closer (DSH core
134
+ * only writes that closer when something loads/prepares the specific
135
+ * session — a genuinely fresh crash's raw log has no closer at all, only an
136
+ * open turn). Safe to call ONLY right after a fresh `dsh web` boot, when
137
+ * `dsh web` is the sole live process for these sessions — an open turn found
138
+ * at that moment cannot belong to a still-running generation anywhere else.
139
+ * Callers MUST additionally skip any id that is live in their own current
140
+ * process (e.g. `ctx.sessions.get(id)`) before treating a match as crashed.
141
+ */
142
+ export async function findDanglingOpenTurns(dshHome, opts) {
143
+ const home = dshHome ?? path.join(os.homedir(), '.dsh');
144
+ const sessionsRoot = path.join(home, 'sessions');
145
+ let scanned = 0;
146
+ const interrupted = [];
147
+ const now = Date.now();
148
+ const withinMs = opts?.withinMs;
149
+ const sinceMs = opts?.sinceMs ?? (withinMs !== undefined ? now - withinMs : undefined);
150
+ try {
151
+ const groups = fs.readdirSync(sessionsRoot, { withFileTypes: true });
152
+ for (const g of groups) {
153
+ if (!g.isDirectory())
154
+ continue;
155
+ const groupPath = path.join(sessionsRoot, g.name);
156
+ const sessions = fs.readdirSync(groupPath, { withFileTypes: true });
157
+ for (const s of sessions) {
158
+ if (!s.isDirectory())
159
+ continue;
160
+ scanned++;
161
+ const zstdPath = path.join(groupPath, s.name, 'session.jsonl.zstd');
162
+ const jsonlPath = path.join(groupPath, s.name, 'session.jsonl');
163
+ try {
164
+ const lines = await readSessionAllLines(zstdPath, jsonlPath, sinceMs);
165
+ if (lines === undefined)
166
+ continue;
167
+ let openTurn;
168
+ let openTurnTime;
169
+ for (const line of lines) {
170
+ try {
171
+ const obj = JSON.parse(line);
172
+ if (obj.type === 'turn/start' && typeof obj.data?.turn === 'number') {
173
+ openTurn = obj.data.turn;
174
+ openTurnTime = typeof obj.time === 'number' ? obj.time : undefined;
175
+ }
176
+ else if (obj.type === 'turn/end' && obj.data?.turn === openTurn) {
177
+ openTurn = undefined;
178
+ openTurnTime = undefined;
179
+ }
180
+ }
181
+ catch { }
182
+ }
183
+ if (openTurn === undefined)
184
+ continue;
185
+ if (sinceMs !== undefined) {
186
+ if (openTurnTime === undefined || openTurnTime < sinceMs)
187
+ continue;
188
+ }
189
+ interrupted.push(`${g.name}/${s.name}`);
190
+ }
191
+ catch { }
192
+ }
193
+ }
194
+ }
195
+ catch { }
196
+ return { scanned, interrupted };
197
+ }
198
+ export function parseDuration(s) {
199
+ if (!s)
200
+ return undefined;
201
+ const m = s.trim().match(/^(\d+)(s|m|h)?$/);
202
+ if (!m)
203
+ return undefined;
204
+ const n = parseInt(m[1], 10);
205
+ const unit = m[2] ?? 's';
206
+ if (unit === 's')
207
+ return n * 1000;
208
+ if (unit === 'm')
209
+ return n * 60 * 1000;
210
+ if (unit === 'h')
211
+ return n * 60 * 60 * 1000;
212
+ return undefined;
213
+ }
package/lib/snapshot.d.ts CHANGED
@@ -10,6 +10,9 @@ export declare function writeLKG(dshHome: string, lkgRoot: string): Promise<{
10
10
  ts: string;
11
11
  manifest: Manifest;
12
12
  }>;
13
+ export declare function pruneByAge(root: string, maxAgeMs: number): Promise<void>;
14
+ export declare function pruneBySize(root: string, maxBytes: number): Promise<void>;
15
+ export declare function isDuplicateLKG(dshHome: string, lkgRoot: string): Promise<boolean>;
13
16
  export declare function verifyLKG(lkgPath: string): Promise<boolean>;
14
17
  export declare function rotateLKG(lkgRoot: string, keep?: number): Promise<void>;
15
18
  export declare function writeFailed(dshHome: string, failedRoot: string): Promise<{