@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 +21 -0
- package/README.md +102 -0
- package/cordis.patch.yml +6 -0
- package/docs/restore-bar.png +0 -0
- package/icon.svg +4 -0
- package/index.js +503 -0
- package/lib/client.js +154 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +66 -0
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
|
+

|
|
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).
|
package/cordis.patch.yml
ADDED
|
@@ -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
package/locale/zh.json
ADDED
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
|
+
}
|