@maci0/dsh-restore 0.8.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcel W. Wysocki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # dsh-restore
2
+
3
+ A browser-style restore bar for DeepSeek Harness: after a reboot or crash, one
4
+ click resumes the goals and continues every session that was mid-flight. After
5
+ a restart the harness closes each cut-off turn and disarms every goal, then
6
+ waits; nothing tells you which of dozens of sessions were working.
7
+
8
+ ![restore bar](docs/restore-bar.png)
9
+
10
+ ## What you get
11
+
12
+ - A bar at the top of the app, shown once after a restart that cut sessions
13
+ off: "DeepSeek Harness stopped while N sessions were still working."
14
+ - **Show** lists them by title, marked goal or turn; click one to open it.
15
+ - **Restore** resumes every goal that is still `active` and sends `continue`
16
+ to every other session. **Dismiss** forgets the set.
17
+
18
+ ## Install
19
+
20
+ > **Install it as a bundle.** `dsh plugin add …` mounts the row from the
21
+ > package's own patch layer, which is what the settings editor can write to. A
22
+ > row added with `--patch` is an overlay: it disappears at the next start.
23
+
24
+ ```sh
25
+ dsh plugin --profile web add @maci0/dsh-restore@0.8.2
26
+ ```
27
+
28
+ This installs the public npm package; no GitHub token or `~/.secrets` setup is needed.
29
+ The version is pinned. To upgrade, run the same command with a newer version,
30
+ then restart `dsh web` (bundle layers compose at boot).
31
+
32
+ Needs the Web/desktop bundle (`webServer`, `sessionController`); headless
33
+ profiles do not mount them.
34
+
35
+ ## How it works
36
+
37
+ Two sources feed the pending set. State lives in
38
+ `$DSH_HOME/storages/dsh-restore/` (default `~/.dsh/storages/`): a shared
39
+ `pending.json`, and one `live-<pid>.json` per running harness process. Shared
40
+ edits hold the native SQLite writer lock in `pending.lock.sqlite`, which is
41
+ released automatically if a process dies. A dead live record is retired only
42
+ after its recovery entries have been saved; failed saves leave the bar intact.
43
+
44
+ - **The live record.** While dsh runs, the plugin lists every session with a
45
+ turn in progress or a goal armed, rewritten atomically on each `turn/start`,
46
+ `turn/end`, and goal activation change. A turn aborted as `disposed` (a
47
+ shutdown) stays listed, so a clean reboot counts the same as a crash; a turn
48
+ you stopped yourself does not. Each process writes only its own record, so
49
+ two harness processes on one home never overwrite each other. At the next
50
+ start, records whose process is gone (another boot id, a pid no longer
51
+ running, or a pid whose process started at another time) move to pending; a
52
+ running process keeps its own.
53
+ - **The boot scan.** For hard crashes, including ones from before the plugin
54
+ was installed, it finds logs whose last turn no process closed: an open
55
+ `turn/start`, or the `interrupted` closer the harness writes when such a
56
+ session is reopened. It stats the JSONL store (the `root` of the
57
+ `session-persistence-jsonl` row, so a moved store is found) and opens only logs
58
+ changed in the last 3 days, and remembers how far it has offered, so a
59
+ dismissed crash stays dismissed. Titles and goal state come from the same
60
+ read.
61
+
62
+ **Restore** opens each pending session through the session controller and:
63
+
64
+ - resumes its goal when the goal is still `active` (the goal round driver then
65
+ queues the round, exactly like `/goal resume`);
66
+ - otherwise sends `continue` as a user message;
67
+ - skips a session that is already running.
68
+
69
+ A session whose writer lock is held by another process, or that hit a gateway
70
+ error, stays in the bar for another Restore. Deleted sessions and subagent
71
+ children drop out; a parent's resume reaches its children. Paused, blocked, and
72
+ completed goals are never touched. Two Restore clicks at once (two tabs) run
73
+ one restore.
74
+
75
+ The browser half talks to the host over `GET`/`POST /restore`, behind the
76
+ web server's connection fence; a POST must carry a JSON body. The bar
77
+ re-reads the set when its tab comes back into view, so a Restore or Dismiss
78
+ in another tab is reflected.
79
+
80
+ ## Limits
81
+
82
+ - A profile that persists sessions without the JSONL store gets no boot scan,
83
+ only the live record, and the plugin logs that.
84
+ - Crashes older than 3 days are treated as history and not offered.
85
+ - Off Linux (no procfs start times or boot id), a pid reused by another
86
+ process reads as a running owner, so that record is offered once the pid's
87
+ new holder exits.
88
+
89
+ ## Development
90
+
91
+ ```sh
92
+ bun test # unit suite, the restore bar, and a real Cordis composition mount
93
+ ```
94
+
95
+ For local development, install the checkout into a profile with
96
+ `dsh plugin --profile <name> add <path-to-checkout>`.
97
+
98
+ dsh loads plugins on Node `^22.19.0 || >=24.0.0`; development and tests run on bun.
99
+
100
+ ## Licence
101
+
102
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,6 @@
1
+ # Bundle layer: applied when a profile lists this bundle in dsh.profile.bundles
2
+ # (dsh plugin add does that from dsh.bundle). Do not also paste this row into
3
+ # the profile's cordis.patch.yml: insert does not dedupe ids.
4
+ - insert:
5
+ - id: restore
6
+ name: '@maci0/dsh-restore'
Binary file
package/icon.svg ADDED
@@ -0,0 +1,4 @@
1
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M13 10.5v15l11-7.5z" stroke="#145AF3" stroke-width="2.4" stroke-linejoin="round"/>
3
+ <path d="M9 9v18" stroke="#145AF3" stroke-width="2.4" stroke-linecap="round"/>
4
+ </svg>
package/index.js ADDED
@@ -0,0 +1,503 @@
1
+ /**
2
+ * dsh-restore: offer to resume every session that was mid-flight when the
3
+ * harness last went down (reboot, crash, kill), the way a browser offers to
4
+ * restore its tabs.
5
+ *
6
+ * While running, each harness process keeps its own live record listing every
7
+ * session with a turn in progress or a goal armed, rewritten on every change,
8
+ * so whatever is on disk when the process dies is its in-flight set. At the
9
+ * next start, records of processes that are gone move to the shared `pending`
10
+ * set. A boot scan of the stored logs adds every session whose last turn no
11
+ * process closed (a hard crash, including one from before this plugin was
12
+ * installed), and the browser half shows a restore bar.
13
+ * Restore resumes a session's goal when it is still `active` (the harness
14
+ * disarms every goal on restart) and sends `continue` to any other session.
15
+ *
16
+ * Route `/restore`:
17
+ * GET → { pending: [{ id, cwd, since, turn, goal }] }
18
+ * POST { "action": "restore" } → { results: [{ id, outcome }], pending }
19
+ * POST { "action": "dismiss" } → { results: [], pending: [] }
20
+ * `pending` after a POST is what the host could not settle (a writer lock
21
+ * held by another process, a gateway fault), kept for the next restore.
22
+ */
23
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
24
+ import { homedir } from 'node:os'
25
+ import { dirname, join, resolve } from 'node:path'
26
+ import { DatabaseSync } from 'node:sqlite'
27
+ import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
28
+
29
+ export const name = 'restore'
30
+ export const inject = ['webServer', 'sessionController', 'goals', 'sessions', 'sessionPersistence']
31
+
32
+ /** The route the browser half reads and posts to. */
33
+ export const ROUTE = '/restore'
34
+ const CONTINUE_MESSAGE = 'continue'
35
+ const STATE_VERSION = 2
36
+ /** Cut-off turns older than this are history, not a crash to recover from. */
37
+ const MAX_CRASH_AGE_MS = 3 * 24 * 60 * 60 * 1000
38
+ /** Largest POST body accepted; the real ones are a few dozen bytes. */
39
+ const MAX_BODY_BYTES = 1024
40
+
41
+ /** The harness home: `$DSH_HOME`, else `~/.dsh`, resolved like the harness resolves it. */
42
+ function dshHome(env = process.env) {
43
+ return resolve(env.DSH_HOME?.trim() ? env.DSH_HOME : join(homedir(), '.dsh'))
44
+ }
45
+
46
+ /**
47
+ * `$DSH_HOME/storages/dsh-restore/`: `pending.json`, shared by every
48
+ * harness process on this home, and one `live-<pid>.json` per process, which
49
+ * only that process writes, so two processes never overwrite each other.
50
+ */
51
+ export function stateDir(env = process.env) {
52
+ return join(dshHome(env), 'storages', 'dsh-restore')
53
+ }
54
+
55
+ /**
56
+ * Read one state file. Missing is `undefined`; a malformed file is reported
57
+ * and treated as missing, since it only ever holds this plugin's own record.
58
+ */
59
+ function readRecord(path, warn) {
60
+ let raw
61
+ try {
62
+ raw = readFileSync(path, 'utf8')
63
+ } catch (error) {
64
+ if (error.code === 'ENOENT') return undefined
65
+ throw error
66
+ }
67
+ try {
68
+ const parsed = JSON.parse(raw)
69
+ if (parsed?.version !== STATE_VERSION) throw new Error(`unsupported version ${parsed?.version}`)
70
+ return parsed
71
+ } catch (error) {
72
+ warn(`dsh-restore: ignoring unreadable ${path}: ${error.message}`)
73
+ return undefined
74
+ }
75
+ }
76
+
77
+ /** Atomic write: a crash mid-write leaves the previous file, never a torn one. */
78
+ function writeRecord(path, value) {
79
+ mkdirSync(dirname(path), { recursive: true })
80
+ const tmp = `${path}.${process.pid}.tmp`
81
+ writeFileSync(tmp, JSON.stringify({ version: STATE_VERSION, ...value }, null, 2))
82
+ renameSync(tmp, path)
83
+ }
84
+
85
+ /**
86
+ * The shared pending set. `scannedThrough` is the newest log time already
87
+ * offered, so a dismissed crash is not offered again.
88
+ * @returns {{ pending: Record<string, Entry>, scannedThrough: number }}
89
+ */
90
+ export function readPending(dir, warn = () => {}) {
91
+ const record = readRecord(join(dir, 'pending.json'), warn)
92
+ return { pending: record?.pending ?? {}, scannedThrough: record?.scannedThrough ?? 0 }
93
+ }
94
+
95
+ /** Serialize shared JSON edits across processes using SQLite's crash-safe writer lock. */
96
+ function withPendingLock(dir, change) {
97
+ mkdirSync(dir, { recursive: true })
98
+ const lock = new DatabaseSync(join(dir, 'pending.lock.sqlite'))
99
+ try {
100
+ lock.exec('PRAGMA busy_timeout = 5000; BEGIN IMMEDIATE')
101
+ const result = change()
102
+ lock.exec('COMMIT')
103
+ return result
104
+ } finally {
105
+ lock.close() // also rolls back and releases the lock if change throws
106
+ }
107
+ }
108
+
109
+ /** Read, change and publish the pending set under the same cross-process writer lock. */
110
+ export function updatePending(dir, change, warn = () => {}) {
111
+ return withPendingLock(dir, () => {
112
+ const state = readPending(dir, warn)
113
+ change(state)
114
+ writeRecord(join(dir, 'pending.json'), state)
115
+ return state
116
+ })
117
+ }
118
+
119
+ /** This machine boot, so a record written before a reboot is known dead; undefined off Linux. */
120
+ function currentBootId() {
121
+ try {
122
+ return readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim()
123
+ } catch {
124
+ return undefined
125
+ }
126
+ }
127
+
128
+ /** Whether a process exists (EPERM means it does, owned by someone else). */
129
+ function pidAlive(pid) {
130
+ try {
131
+ process.kill(pid, 0)
132
+ return true
133
+ } catch (error) {
134
+ return error.code === 'EPERM'
135
+ }
136
+ }
137
+
138
+ /**
139
+ * A process's start time in clock ticks since boot (field 22 of
140
+ * `/proc/<pid>/stat`), which tells a reused pid from the process that first
141
+ * held it; undefined off Linux or when the process is gone. The fields are read
142
+ * after the last `)`, since the command name in parentheses may hold spaces.
143
+ */
144
+ function processStart(pid) {
145
+ try {
146
+ const stat = readFileSync(`/proc/${String(pid)}/stat`, 'utf8')
147
+ return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]
148
+ } catch {
149
+ return undefined
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Whether the process that wrote a live record is still running: never when it
155
+ * carries this process's own pid or another boot's id; else, where procfs
156
+ * gives start times, only when the pid's process started when the writer did
157
+ * (a reused pid is another process); otherwise when the pid exists.
158
+ * @param {{ pid: number, bootId?: string, start?: string }} owner - the record's writer.
159
+ * @param here - this process's identity and liveness probes.
160
+ */
161
+ export function ownerAlive(owner, here = { pid: process.pid, bootId: currentBootId(), alive: pidAlive, startOf: processStart }) {
162
+ if (owner.pid === here.pid) return false
163
+ if (owner.bootId !== undefined && here.bootId !== undefined && owner.bootId !== here.bootId) return false
164
+ if (owner.start !== undefined) {
165
+ const start = here.startOf(owner.pid)
166
+ if (start !== undefined) return start === owner.start
167
+ }
168
+ return here.alive(owner.pid)
169
+ }
170
+
171
+ /**
172
+ * Boot step: fold every live record whose process is gone into the shared
173
+ * pending set (a newer live entry wins) and delete that record. Records of
174
+ * processes still running are left to them.
175
+ */
176
+ export function adoptDeadRecords(dir, warn = () => {}, here = undefined) {
177
+ return withPendingLock(dir, () => {
178
+ const state = readPending(dir, warn)
179
+ const adopted = []
180
+ for (const file of readdirSync(dir)) {
181
+ if (!/^live-\d+\.json$/u.test(file)) continue
182
+ const record = readRecord(join(dir, file), warn)
183
+ if (record !== undefined && ownerAlive(record, here)) continue
184
+ for (const [id, entry] of Object.entries(record?.live ?? {})) {
185
+ if ((entry.since ?? 0) >= (state.pending[id]?.since ?? 0)) state.pending[id] = entry
186
+ }
187
+ adopted.push(join(dir, file))
188
+ }
189
+ writeRecord(join(dir, 'pending.json'), state)
190
+ // Keep the only recovery record until its replacement has been published.
191
+ for (const path of adopted) rmSync(path, { force: true })
192
+ return state
193
+ })
194
+ }
195
+
196
+ /**
197
+ * The JSONL store root, read from its loader row so a moved store is scanned
198
+ * where it lives. Undefined when the profile persists sessions another way.
199
+ */
200
+ function jsonlRoot(ctx) {
201
+ const loader = ctx.get('loader')
202
+ const row = loader === undefined ? undefined : [...loader.entries()].find(entry => entry.options?.id === 'session-persistence-jsonl')
203
+ const root = row?.fiber?.config?.root
204
+ return typeof root === 'string' ? root : undefined
205
+ }
206
+
207
+ /**
208
+ * True when a log tail ends in a turn no process closed: the last turn edge is
209
+ * an open `turn/start`, or the `interrupted` closer the harness writes when a
210
+ * crashed session is next opened. A clean shutdown closes turns as
211
+ * `aborted/disposed` instead; the live record covers those.
212
+ */
213
+ export function endsCutOff(events) {
214
+ for (let i = events.length - 1; i >= 0; i--) {
215
+ const { type, data } = events[i]
216
+ if (type === 'turn/start') return true
217
+ if (type === 'turn/end') return data?.reason?.kind === 'interrupted'
218
+ }
219
+ return false
220
+ }
221
+
222
+ /** A directory name that is a session id verbatim (the store's path encoding leaves these characters alone). */
223
+ const PLAIN_ID = /^[A-Za-z0-9._-]+$/
224
+
225
+ /**
226
+ * Ids of sessions whose stored files changed after `floor`, from file mtimes
227
+ * under the JSONL store (`<root>/<project>/<session id>/…`). A stat walk costs
228
+ * milliseconds; opening a log costs a full decode, and an old-format log is
229
+ * migrated on open, so only these are opened. A missing root lists nothing.
230
+ */
231
+ export function changedSessionIds(root, floor) {
232
+ const ids = []
233
+ let projects
234
+ try {
235
+ projects = readdirSync(root, { withFileTypes: true })
236
+ } catch (error) {
237
+ if (error.code === 'ENOENT') return ids
238
+ throw error
239
+ }
240
+ for (const project of projects) {
241
+ if (!project.isDirectory()) continue
242
+ for (const session of readdirSync(join(root, project.name), { withFileTypes: true })) {
243
+ if (!session.isDirectory() || !PLAIN_ID.test(session.name)) continue
244
+ const dir = join(root, project.name, session.name)
245
+ const newest = Math.max(0, ...readdirSync(dir).map((file) => statSync(join(dir, file)).mtimeMs))
246
+ if (newest > floor) ids.push(session.name)
247
+ }
248
+ }
249
+ return ids
250
+ }
251
+
252
+ /**
253
+ * What the bar shows about a scanned session: its latest title, and whether
254
+ * its latest goal is still `active` (a clear leaves no goal).
255
+ */
256
+ export function logFacts(events) {
257
+ let title
258
+ let goal = false
259
+ for (const { type, data } of events) {
260
+ if (type === 'session/title' && typeof data?.title === 'string') title = data.title
261
+ if (type === 'goal/change') goal = data?.operation !== 'clear' && data?.goal?.phase === 'active'
262
+ }
263
+ return { title, goal }
264
+ }
265
+
266
+ /** Oldest log time still worth offering: newer than both the last scan and `MAX_CRASH_AGE_MS`. */
267
+ export function crashFloor(after, now = Date.now()) {
268
+ return Math.max(after, now - MAX_CRASH_AGE_MS)
269
+ }
270
+
271
+ /**
272
+ * Pick the cut-off sessions worth offering: newer than what was already
273
+ * offered (`after`) and than `MAX_CRASH_AGE_MS`. `found` is
274
+ * `[{ id, cwd, time, title, goal }]` with `time` the log's last event.
275
+ * @returns {{ entries: Record<string, Entry>, through: number }}
276
+ */
277
+ export function crashedEntries(found, after, now = Date.now()) {
278
+ const floor = crashFloor(after, now)
279
+ const entries = {}
280
+ let through = after
281
+ for (const { id, cwd, time, title, goal } of found) {
282
+ if (time <= floor) continue
283
+ entries[id] = { cwd, since: time, turn: true, goal, ...(title === undefined ? {} : { title }) }
284
+ through = Math.max(through, time)
285
+ }
286
+ return { entries, through }
287
+ }
288
+
289
+ /**
290
+ * Resume one session. Returns the outcome for the bar and whether the entry is
291
+ * settled (resumed, or gone for good) and can leave the pending set.
292
+ */
293
+ async function resumeOne(ctx, id) {
294
+ const resolved = await ctx.sessionController.resolveAgent(id)
295
+ if ('error' in resolved) {
296
+ const code = resolved.error?.code ?? 'unknown'
297
+ // A deleted session or one owned by a subagent runtime never resumes here;
298
+ // anything else (writer lock held elsewhere, gateway fault) can retry.
299
+ const settled = code === 'session/not-found' || code === 'session/agent-busy'
300
+ return { settled, outcome: `skipped: ${code}` }
301
+ }
302
+ const { agent } = resolved
303
+ if (agent.status === 'running') return { settled: true, outcome: 'already running' }
304
+ const goal = ctx.goals.get(agent)
305
+ if (goal?.phase === 'active') {
306
+ try {
307
+ ctx.goals.resume(agent, { id: goal.id, revision: goal.revision })
308
+ return { settled: true, outcome: 'goal resumed' }
309
+ } catch (error) {
310
+ // Round budget spent, or already armed: fall through to a plain continue.
311
+ ctx.logger.warn(`dsh-restore: goal resume failed for ${id}: ${error.message}`)
312
+ }
313
+ }
314
+ agent.followup(createUserMessage({
315
+ content: [{ type: 'text', text: CONTINUE_MESSAGE }],
316
+ source: { kind: 'restore', form: 'relay' },
317
+ }))
318
+ return { settled: true, outcome: 'continued' }
319
+ }
320
+
321
+ function sendJson(res, status, body) {
322
+ res.statusCode = status
323
+ res.setHeader('content-type', 'application/json')
324
+ res.end(JSON.stringify(body))
325
+ }
326
+
327
+ /** Read a small JSON body; `undefined` for anything oversized or unparsable. */
328
+ async function readJson(req) {
329
+ let raw = ''
330
+ for await (const chunk of req) {
331
+ raw += chunk
332
+ if (raw.length > MAX_BODY_BYTES) return undefined
333
+ }
334
+ try {
335
+ return JSON.parse(raw)
336
+ } catch {
337
+ return undefined
338
+ }
339
+ }
340
+
341
+ export function apply(ctx) {
342
+ const dir = stateDir()
343
+ const warn = (text) => ctx.logger.warn(text)
344
+ adoptDeadRecords(dir, warn)
345
+
346
+ // This process's own record: only it writes the file, so another harness on
347
+ // the same home cannot overwrite it.
348
+ const livePath = join(dir, `live-${process.pid}.json`)
349
+ const live = {}
350
+ const bootId = currentBootId()
351
+ const start = processStart(process.pid)
352
+ const saveLive = () => {
353
+ try {
354
+ writeRecord(livePath, { pid: process.pid, ...(bootId === undefined ? {} : { bootId }), ...(start === undefined ? {} : { start }), live })
355
+ } catch (error) {
356
+ warn(`dsh-restore: cannot write ${livePath}: ${error.message}`)
357
+ }
358
+ }
359
+ saveLive()
360
+
361
+ /** Read the shared pending set fresh, apply `change`, write it back. */
362
+ const changePending = (change) => updatePending(dir, change, warn)
363
+
364
+ /**
365
+ * Find sessions a hard crash cut off, including crashes from before this
366
+ * plugin was loaded, by reading the tail of every stored log. Recorded
367
+ * entries win over scanned ones: they know about goals.
368
+ */
369
+ const scanLogs = async () => {
370
+ const found = []
371
+ let unreadable = 0
372
+ const root = jsonlRoot(ctx)
373
+ if (root === undefined) {
374
+ warn('dsh-restore: no session-persistence-jsonl row, so no boot scan; only the live record is offered')
375
+ return
376
+ }
377
+ const { scannedThrough } = readPending(dir, warn)
378
+ for (const id of changedSessionIds(root, crashFloor(scannedThrough))) {
379
+ try {
380
+ const handle = await ctx.sessionPersistence.open(id, 'read')
381
+ try {
382
+ if (handle.header.origin === 'subagent') continue
383
+ const { events } = await handle.read()
384
+ if (endsCutOff(events)) found.push({ id, cwd: handle.header.cwd, time: events.at(-1).time, ...logFacts(events) })
385
+ } finally {
386
+ await handle.close()
387
+ }
388
+ } catch {
389
+ unreadable += 1
390
+ }
391
+ }
392
+ if (unreadable > 0) warn(`dsh-restore: skipped ${unreadable} session log(s) that could not be read`)
393
+ const { entries, through } = crashedEntries(found, scannedThrough)
394
+ changePending((state) => {
395
+ state.pending = { ...entries, ...state.pending }
396
+ state.scannedThrough = Math.max(state.scannedThrough, through)
397
+ })
398
+ }
399
+ const scanned = scanLogs().catch((error) => warn(`dsh-restore: log scan failed: ${error.message}`))
400
+
401
+ /** Set one flag on a session's live entry; drop the entry once nothing is in flight. */
402
+ const mark = (session, flag, on) => {
403
+ if (session.header?.origin === 'subagent') return // the parent resumes its children
404
+ const entry = live[session.id]
405
+ if (!on) {
406
+ if (!entry?.[flag]) return
407
+ entry[flag] = false
408
+ if (!entry.turn && !entry.goal) delete live[session.id]
409
+ } else {
410
+ if (entry?.[flag]) return
411
+ live[session.id] = { ...(entry ?? { cwd: session.header?.cwd, since: Date.now() }), [flag]: true }
412
+ }
413
+ saveLive()
414
+ }
415
+
416
+ const restore = async () => {
417
+ const results = []
418
+ const settled = []
419
+ for (const id of Object.keys(readPending(dir, warn).pending)) {
420
+ let result
421
+ try {
422
+ result = await resumeOne(ctx, id)
423
+ } catch (error) {
424
+ result = { settled: false, outcome: `failed: ${error.message}` }
425
+ }
426
+ if (result.settled) settled.push(id)
427
+ results.push({ id, outcome: result.outcome })
428
+ }
429
+ changePending((state) => { for (const id of settled) delete state.pending[id] })
430
+ return results
431
+ }
432
+
433
+ let restoring
434
+ const pendingList = () => Object.entries(readPending(dir, warn).pending).map(([id, entry]) => ({ id, ...entry }))
435
+
436
+ const handler = async (req, res) => {
437
+ const rejection = ctx.get('connection')?.requestRejection(req)
438
+ if (rejection !== undefined) {
439
+ res.statusCode = rejection
440
+ res.end()
441
+ return
442
+ }
443
+ // The bar asks once per page load, often before the boot scan finishes.
444
+ await scanned
445
+ const method = (req.method ?? 'GET').toUpperCase()
446
+ if (method === 'GET') {
447
+ sendJson(res, 200, { pending: pendingList() })
448
+ return
449
+ }
450
+ if (method !== 'POST') {
451
+ res.setHeader('allow', 'GET, POST')
452
+ sendJson(res, 405, { message: 'this route answers GET and POST only' })
453
+ return
454
+ }
455
+ // A JSON content type forces a CORS preflight, so another origin cannot
456
+ // fire a restore with a plain form post.
457
+ if (!String(req.headers?.['content-type'] ?? '').startsWith('application/json')) {
458
+ sendJson(res, 415, { message: 'POST a JSON body' })
459
+ return
460
+ }
461
+ const body = await readJson(req)
462
+ try {
463
+ if (body?.action === 'dismiss') {
464
+ changePending((state) => { state.pending = {} })
465
+ sendJson(res, 200, { results: [], pending: [] })
466
+ return
467
+ }
468
+ if (body?.action === 'restore') {
469
+ // One restore at a time: a second tab's click joins the running one
470
+ // instead of sending every session a second `continue`.
471
+ restoring ??= restore().finally(() => { restoring = undefined })
472
+ const results = await restoring
473
+ sendJson(res, 200, { results, pending: pendingList() })
474
+ return
475
+ }
476
+ } catch (error) {
477
+ sendJson(res, 500, { message: `cannot save recovery state: ${error.message}` })
478
+ return
479
+ }
480
+ sendJson(res, 400, { message: 'action must be "restore" or "dismiss"' })
481
+ }
482
+
483
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: ROUTE, handler }), `restore: ${ROUTE}`)
484
+
485
+ ctx.effect(() => {
486
+ const offEvent = ctx.on('session/event', (session, event) => {
487
+ if (event?.type === 'turn/start') mark(session, 'turn', true)
488
+ if (event?.type !== 'turn/end') return
489
+ // A shutdown disposes running turns; that is exactly the state to keep.
490
+ const reason = event.data?.reason
491
+ if (reason?.kind === 'aborted' && reason.reason?.kind === 'disposed') return
492
+ mark(session, 'turn', false)
493
+ })
494
+ const offGoal = ctx.on('goal/activation-changed', ({ sessionId, goal }) => {
495
+ const session = ctx.sessions.get(sessionId) ?? { id: sessionId }
496
+ mark(session, 'goal', goal?.activation === 'armed')
497
+ })
498
+ return () => {
499
+ offEvent?.()
500
+ offGoal?.()
501
+ }
502
+ })
503
+ }
package/lib/client.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * dsh-restore: browser half.
3
+ *
4
+ * A restore bar at the top of the app, like a browser's "didn't shut down
5
+ * correctly" prompt. It reads the host's pending set once on load from
6
+ * `GET /restore`; Restore and Dismiss post to the same route. Each listed
7
+ * session opens on click.
8
+ *
9
+ * Plain JavaScript on purpose: the client module system serves this file as a
10
+ * lazy-CJS factory on `window.__ModuleLoader__`; `react` is provided.
11
+ */
12
+
13
+ window.__ModuleLoader__.load({
14
+ id: '@maci0/dsh-restore',
15
+
16
+ factory: (require) => {
17
+ var module = { exports: {} }
18
+ var exports = module.exports
19
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
20
+
21
+ const React = require('react')
22
+ const h = React.createElement
23
+
24
+ const ROUTE = '/restore'
25
+
26
+ // The overlay layer covers the whole window with pointer-events off and
27
+ // turns them back on for its direct children, so the bar itself is the
28
+ // child and sizes to its content: a full-width wrapper would eat clicks.
29
+ const CSS = [
30
+ '.restore-bar{position:absolute;top:12px;left:50%;transform:translateX(-50%);box-sizing:border-box;max-width:min(640px,calc(100% - 32px));display:flex;flex-direction:column;gap:8px;padding:10px 12px 10px 14px;border:0.5px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-specific-tip);box-shadow:0 4px 16px rgba(0,0,0,.12);font-size:13px;color:var(--dsw-alias-label-primary)}',
31
+ '.restore-row{display:flex;align-items:center;gap:10px}',
32
+ '.restore-text{flex:1;min-width:0}',
33
+ '.restore-btn{flex:none;appearance:none;font:inherit;font-size:12px;padding:3px 12px;cursor:pointer;color:var(--dsw-alias-label-primary);background:none;border:1px solid var(--dsw-alias-border-l2);border-radius:999px}',
34
+ '.restore-btn-primary{border-color:var(--dsw-alias-label-primary);font-weight:500}',
35
+ '.restore-btn:disabled{cursor:default;opacity:.5}',
36
+ '.restore-list{margin:0;padding:0;list-style:none;max-height:180px;overflow:auto}',
37
+ '.restore-item{appearance:none;font:inherit;font-size:12px;background:none;border:0;padding:2px 0;cursor:pointer;color:var(--dsw-alias-label-secondary);text-align:left;width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
38
+ '.restore-item:hover{color:var(--dsw-alias-label-primary);text-decoration:underline}',
39
+ '.restore-item:focus-visible,.restore-btn:focus-visible{outline:2px solid var(--dsw-alias-border-l2);outline-offset:2px}',
40
+ ].join('')
41
+
42
+ if (typeof document !== 'undefined') {
43
+ const style = document.createElement('style')
44
+ style.textContent = CSS
45
+ document.head.append(style)
46
+ }
47
+
48
+ /** POST one action and return the parsed body; throws on a non-2xx answer. */
49
+ const post = async (action) => {
50
+ const res = await fetch(ROUTE, {
51
+ method: 'POST',
52
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
53
+ body: JSON.stringify({ action }),
54
+ })
55
+ if (!res.ok) throw new Error(`${ROUTE} answered ${res.status}`)
56
+ return res.json()
57
+ }
58
+
59
+ const basename = (path) => (path ? path.split(/[\\/]/).filter(Boolean).pop() ?? path : '')
60
+
61
+ /** What a pending session is called in the list: its title, else its folder. */
62
+ const labelOf = (entry, summary) => {
63
+ const what = entry.goal ? 'goal' : 'turn'
64
+ return `${entry.title || summary?.title || basename(entry.cwd) || entry.id} · ${what}`
65
+ }
66
+
67
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
68
+
69
+ function RestoreBar({ useSessions, openSession }) {
70
+ const byId = useSessions((s) => s.byId)
71
+ const [pending, setPending] = React.useState([])
72
+ const [busy, setBusy] = React.useState(false)
73
+ const [open, setOpen] = React.useState(false)
74
+ const [note, setNote] = React.useState(null)
75
+ const generation = React.useRef(0)
76
+
77
+ React.useEffect(() => {
78
+ const controller = new AbortController()
79
+ const load = () => {
80
+ const started = ++generation.current
81
+ fetch(ROUTE, { headers: { accept: 'application/json' }, signal: controller.signal })
82
+ .then((res) => { if (!res.ok) throw new Error(`${ROUTE} answered ${res.status}`); return res.json() })
83
+ .then((body) => { if (started === generation.current) setPending(Array.isArray(body.pending) ? body.pending : []) })
84
+ .catch(() => {})
85
+ }
86
+ // Re-read when the tab comes back into view: another tab may have
87
+ // restored or dismissed the set meanwhile.
88
+ const onVisible = () => { if (document.visibilityState === 'visible') load() }
89
+ load()
90
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisible)
91
+ return () => {
92
+ generation.current += 1
93
+ controller.abort()
94
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisible)
95
+ }
96
+ }, [])
97
+
98
+ if (pending.length === 0) return null
99
+
100
+ const act = async (action) => {
101
+ if (busy) return
102
+ generation.current += 1
103
+ setBusy(true)
104
+ setNote(null)
105
+ try {
106
+ // The host answers with whatever it could not settle (a lock held
107
+ // elsewhere); that stays listed so Restore can be tried again.
108
+ const { pending: left } = await post(action)
109
+ generation.current += 1
110
+ setPending(left)
111
+ if (left.length > 0) setNote(`${plural(left.length, 'session')} could not be resumed yet.`)
112
+ } catch (error) {
113
+ setNote(String(error.message ?? error))
114
+ } finally {
115
+ setBusy(false)
116
+ }
117
+ }
118
+
119
+ return h('div', { className: 'restore-bar', role: 'status' },
120
+ h('div', { className: 'restore-row' },
121
+ h('span', { className: 'restore-text' },
122
+ note ?? `DeepSeek Harness stopped while ${plural(pending.length, 'session')} ${pending.length === 1 ? 'was' : 'were'} still working.`),
123
+ h('button', {
124
+ className: 'restore-btn',
125
+ onClick: () => setOpen(!open),
126
+ 'aria-expanded': open,
127
+ }, open ? 'Hide' : 'Show'),
128
+ h('button', { className: 'restore-btn restore-btn-primary', onClick: () => act('restore'), disabled: busy },
129
+ busy ? '…' : 'Restore'),
130
+ h('button', { className: 'restore-btn', onClick: () => act('dismiss'), disabled: busy }, 'Dismiss'),
131
+ ),
132
+ open && h('ul', { className: 'restore-list' },
133
+ pending.map((entry) => h('li', { key: entry.id },
134
+ h('button', { className: 'restore-item', title: entry.cwd, onClick: () => openSession(entry.id) },
135
+ labelOf(entry, byId?.[entry.id])),
136
+ )),
137
+ ),
138
+ )
139
+ }
140
+
141
+ exports.inject = ['slots', 'uiWorkspace']
142
+
143
+ function apply(ctx) {
144
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
145
+ name: 'shell.overlay',
146
+ id: 'restore',
147
+ inject: () => ({ openSession: (id) => ctx.uiWorkspace.openSession(id) }),
148
+ }, RestoreBar))
149
+ }
150
+
151
+ exports.apply = apply
152
+ return module.exports
153
+ },
154
+ })
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Restore",
4
+ "description": "After a reboot or crash, a bar offers to restore every session that was mid-flight: active goals resume, interrupted turns continue."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "恢复会话",
4
+ "description": "重启或崩溃后,顶部提示栏可一键恢复所有进行中的会话:继续活跃目标,并让中断的回合继续。"
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@maci0/dsh-restore",
3
+ "version": "0.8.2",
4
+ "publishConfig": {
5
+ "access": "public",
6
+ "registry": "https://registry.npmjs.org/"
7
+ },
8
+ "type": "module",
9
+ "main": "index.js",
10
+ "description": "Browser-style restore bar for DeepSeek Harness: after a reboot or crash, resume goals and continue every session that was mid-flight.",
11
+ "license": "MIT",
12
+ "exports": {
13
+ ".": "./index.js",
14
+ "./client": {
15
+ "default": "./lib/client.js"
16
+ },
17
+ "./cordis.patch.yml": "./cordis.patch.yml",
18
+ "./locale/*.json": "./locale/*.json",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "dsh": {
22
+ "bundle": {
23
+ "patch": "./cordis.patch.yml"
24
+ },
25
+ "client": {
26
+ "platform": "web",
27
+ "inject": [
28
+ "@deepseek-ai/dsh-client-ui-renderer",
29
+ "@deepseek-ai/dsh-client-ui-layout",
30
+ "@deepseek-ai/dsh-client-ui-workspace",
31
+ "@deepseek-ai/dsh-api-session-controller"
32
+ ]
33
+ },
34
+ "compatibility": {
35
+ "dsh": ">=0.2.0-rc.2 <0.3.0"
36
+ }
37
+ },
38
+ "files": [
39
+ "icon.svg",
40
+ "locale/*.json",
41
+ "index.js",
42
+ "lib/client.js",
43
+ "cordis.patch.yml",
44
+ "README.md",
45
+ "docs/restore-bar.png",
46
+ "LICENSE"
47
+ ],
48
+ "scripts": {
49
+ "test": "bun test",
50
+ "test:node": "node --test tests/*.test.*"
51
+ },
52
+ "engines": {
53
+ "node": "^22.19.0 || >=24.0.0"
54
+ },
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "https://github.com/maci0/dsh-restore.git"
58
+ },
59
+ "icon": "./icon.svg",
60
+ "dependencies": {
61
+ "@deepseek-ai/dsh-llm": "0.2.1-alpha.1"
62
+ },
63
+ "devDependencies": {
64
+ "@deepseek-ai/cordis": "4.0.5-alpha.1"
65
+ }
66
+ }