dsh-pause-resume 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/client.js +191 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +6 -0
- package/index.js +213 -0
- package/lib/engine.js +219 -0
- package/lib/heartbeat.js +95 -0
- package/lib/probe.js +238 -0
- package/lib/snapshot.js +133 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 regrevia
|
|
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
CHANGED
|
@@ -1,3 +1,89 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-pause-resume
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One button in the DeepSeek Harness session header. It stops everything that is running, and after a
|
|
4
|
+
restart the same button puts that work back.
|
|
5
|
+
|
|
6
|
+
* ⏸ — work is running. Press to stop it.
|
|
7
|
+
* ▶ — work is stopped, or a previous run ended mid-turn. Press to restore it.
|
|
8
|
+
* ⏸ dimmed — nothing to stop and nothing to restore.
|
|
9
|
+
|
|
10
|
+
The glyph is the only status. There is no counter, no panel and no toast, because a status badge next
|
|
11
|
+
to the button would be a second source of truth for the same fact.
|
|
12
|
+
|
|
13
|
+
## What it restores
|
|
14
|
+
|
|
15
|
+
| Work | Pause | Restore |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| A session with an unfinished turn | turn cancelled, session recorded | session resumed |
|
|
18
|
+
| An armed goal | paused, with its revision | resumed with the same revision; the goal driver continues on its own |
|
|
19
|
+
| A running background job | killed and recorded | recorded only: a job is a live process, not replayable |
|
|
20
|
+
| A terminal | untouched | untouched — that process is not the plugin's to end |
|
|
21
|
+
| An open tab / finished session | untouched | untouched |
|
|
22
|
+
|
|
23
|
+
A session that ended normally is never restored: the decision is made from the turn the session's log
|
|
24
|
+
ends with, not from how the session list happens to colour it.
|
|
25
|
+
|
|
26
|
+
## Crash, not just a press
|
|
27
|
+
|
|
28
|
+
A press writes a snapshot. A crash writes nothing, so the plugin keeps a heartbeat instead:
|
|
29
|
+
`running.json` holds the sessions whose turn is open right now and the id of the process that wrote
|
|
30
|
+
it. A new process finds the previous one's heartbeat and offers ▶ with no press needed. The same
|
|
31
|
+
process reloading the plugin (hot reload) sees its own id and knows there is nothing to restore.
|
|
32
|
+
|
|
33
|
+
## Installing
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
plugin_manager install_bundle dsh-pause-resume
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
or install the bundle from the Harness plugin manager with the package name. The plugin needs no
|
|
40
|
+
configuration and holds no credentials.
|
|
41
|
+
|
|
42
|
+
## Guarantees and limits
|
|
43
|
+
|
|
44
|
+
* **Exclusive.** The phase lives on the Host and is written before any work starts, so two windows,
|
|
45
|
+
a double press and a tool call cannot overlap. The second caller is told `busy`.
|
|
46
|
+
* **Idempotent.** A restore journals every item it finished. A crash in the middle of a restore
|
|
47
|
+
continues with the remainder instead of restoring the same session twice.
|
|
48
|
+
* **Honest.** Disk that cannot be written never reports `paused`. A session deleted by hand, a goal
|
|
49
|
+
changed by hand, a session already running: each is skipped and named in the outcome.
|
|
50
|
+
* **Schedules are not paused.** The schedule service has no disable operation, only delete, and
|
|
51
|
+
deleting a person's schedule to pause it is not a trade this plugin makes. A schedule that fires
|
|
52
|
+
while paused starts a turn; the button then shows ⏸ again, truthfully.
|
|
53
|
+
* **No third-party plugin state.** A plugin's in-process state belongs to that plugin's mount. There
|
|
54
|
+
is a seam for a plugin that owns durable state, but nothing is restored on someone else's behalf.
|
|
55
|
+
* **No synthetic prompts.** Nothing is typed into a person's session on their behalf. A goal
|
|
56
|
+
continues because the Harness has a mechanism for that; a plain session is put back at the turn it
|
|
57
|
+
was interrupted in.
|
|
58
|
+
|
|
59
|
+
## Files
|
|
60
|
+
|
|
61
|
+
All state lives under `$DSH_HOME/pause-resume/`:
|
|
62
|
+
|
|
63
|
+
| File | Purpose |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `snapshot.json` | the work a press stopped |
|
|
66
|
+
| `running.json` | sessions with an open turn, for crash residue |
|
|
67
|
+
| `restore.jsonl` | restore steps already done |
|
|
68
|
+
| `last-restore.json` | record of the last restore |
|
|
69
|
+
|
|
70
|
+
Every write is temp file + `fsync` + `rename`. State is never inside the package.
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
node --test --test-timeout=15000 'test/*.test.mjs'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`lib/engine.js` performs no I/O, `lib/snapshot.js` persists, `lib/probe.js` holds every Harness service
|
|
79
|
+
contract (listing shape, goal addressing, per-owner jobs), and `index.js` only wires them to the web
|
|
80
|
+
routes. All four are testable without a Harness: `test/apply.test.mjs` mounts the plugin against a fake
|
|
81
|
+
registry and fails if a service call is stale, which is how the `{ items }` listing bug was caught.
|
|
82
|
+
|
|
83
|
+
MIT.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
一行说明:会话头一个按钮,⏸ 停掉正在跑的回合、目标与后台任务;重启后同一个按钮变 ▶,把上次的工作
|
|
88
|
+
接回来。崩溃也能识别:靠心跳文件,不靠人按过按钮。日程不会被动(该服务只有删除,没有停用),
|
|
89
|
+
后台命令只记录不重跑。
|
package/client.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-pause-resume',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react')
|
|
5
|
+
const h = React.createElement
|
|
6
|
+
|
|
7
|
+
const zh = /^zh/i.test(navigator.language || '')
|
|
8
|
+
const COPY = zh
|
|
9
|
+
? { pause: '暂停', resume: '继续', busy: '正在处理' }
|
|
10
|
+
: { pause: 'Pause', resume: 'Resume', busy: 'Working' }
|
|
11
|
+
|
|
12
|
+
const HOST_BASE = '/dsh-pause-resume' // ui-copy-ignore
|
|
13
|
+
const POLL_MS = 2000 // ui-copy-ignore
|
|
14
|
+
const REQUEST_MS = 30000 // ui-copy-ignore
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The glyph is the whole status, so it may not be guessed from anything the page can see: the
|
|
18
|
+
* Host owns the phase and this asks for it. Until an answer arrives the button renders the // ui-copy-ignore
|
|
19
|
+
* settled state (`none`), never a spinner, so a slow Host cannot look like a running press. // ui-copy-ignore
|
|
20
|
+
*/
|
|
21
|
+
const EMPTY = { phase: 'idle', action: 'none', source: null, complete: null, counts: { sessions: 0, goals: 0, schedules: 0, jobs: 0 }, live: { sessions: 0, goals: 0, jobs: 0 }, error: null }
|
|
22
|
+
|
|
23
|
+
function withTimeout(promise, ms = REQUEST_MS) {
|
|
24
|
+
let id = null
|
|
25
|
+
const timer = new Promise((_resolve, reject) => {
|
|
26
|
+
id = setTimeout(() => reject(new Error('timeout')), ms) // ui-copy-ignore
|
|
27
|
+
})
|
|
28
|
+
return Promise.race([promise, timer]).finally(() => clearTimeout(id))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readState() {
|
|
32
|
+
try {
|
|
33
|
+
const response = await withTimeout(fetch(`${HOST_BASE}/state`, { cache: 'no-store', credentials: 'same-origin' }))
|
|
34
|
+
if (!response.ok) return null
|
|
35
|
+
const data = await withTimeout(response.json())
|
|
36
|
+
return data !== null && typeof data === 'object' ? data : null
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function press(action) {
|
|
43
|
+
try {
|
|
44
|
+
const response = await withTimeout(fetch(`${HOST_BASE}/${action}`, {
|
|
45
|
+
method: 'POST', // ui-copy-ignore
|
|
46
|
+
credentials: 'same-origin',
|
|
47
|
+
headers: { 'content-type': 'application/json', 'x-dsh-pause-resume': '1' }, // ui-copy-ignore
|
|
48
|
+
body: '{}', // ui-copy-ignore
|
|
49
|
+
}))
|
|
50
|
+
if (!response.ok) return null
|
|
51
|
+
const data = await withTimeout(response.json())
|
|
52
|
+
return data !== null && typeof data === 'object' ? data : null
|
|
53
|
+
} catch {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// One style element for the whole page: the in-place spinner is the only motion, and it is
|
|
59
|
+
// defined once instead of on every render.
|
|
60
|
+
const STYLE_ID = 'dsh-pause-resume-style' // ui-copy-ignore
|
|
61
|
+
function ensureStyle() {
|
|
62
|
+
try {
|
|
63
|
+
if (document.getElementById(STYLE_ID) !== null) return
|
|
64
|
+
const style = document.createElement('style')
|
|
65
|
+
style.id = STYLE_ID
|
|
66
|
+
style.textContent = '@keyframes dsh-pr-spin{to{transform:rotate(360deg)}}'
|
|
67
|
+
document.head.appendChild(style)
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const BUTTON_STYLE = {
|
|
72
|
+
display: 'inline-flex', // ui-copy-ignore
|
|
73
|
+
alignItems: 'center',
|
|
74
|
+
justifyContent: 'center',
|
|
75
|
+
width: 28, // ui-copy-ignore
|
|
76
|
+
height: 28, // ui-copy-ignore
|
|
77
|
+
padding: 0, // ui-copy-ignore
|
|
78
|
+
border: 'none', // ui-copy-ignore
|
|
79
|
+
borderRadius: 6,
|
|
80
|
+
background: 'transparent',
|
|
81
|
+
color: 'inherit', // ui-copy-ignore
|
|
82
|
+
cursor: 'pointer', // ui-copy-ignore
|
|
83
|
+
opacity: 0.75, // ui-copy-ignore
|
|
84
|
+
position: 'relative', // ui-copy-ignore
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const DOT_STYLE = {
|
|
88
|
+
position: 'absolute', // ui-copy-ignore
|
|
89
|
+
top: 4, // ui-copy-ignore
|
|
90
|
+
right: 4, // ui-copy-ignore
|
|
91
|
+
width: 5, // ui-copy-ignore
|
|
92
|
+
height: 5, // ui-copy-ignore
|
|
93
|
+
borderRadius: '50%',
|
|
94
|
+
background: 'var(--dsh-color-danger, #d1242f)',
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const SPIN_STYLE = { display: 'inline-flex', animation: 'dsh-pr-spin 900ms linear infinite' } // ui-copy-ignore
|
|
98
|
+
|
|
99
|
+
function icon(paths, extra) {
|
|
100
|
+
return h('svg', {
|
|
101
|
+
width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none',
|
|
102
|
+
stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
103
|
+
'aria-hidden': true, style: extra,
|
|
104
|
+
}, paths.map((d) => h('path', { key: d, d })))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const PAUSE_GLYPH = icon(['M6 3.5v9', 'M10 3.5v9'])
|
|
108
|
+
const PLAY_GLYPH = h('svg', {
|
|
109
|
+
width: 16, height: 16, viewBox: '0 0 16 16', fill: 'currentColor', 'aria-hidden': true,
|
|
110
|
+
}, h('path', { d: 'M5.2 3.4a.7.7 0 0 0-1.05.6v8a.7.7 0 0 0 1.05.6l6.9-4a.7.7 0 0 0 0-1.2l-6.9-4z' }))
|
|
111
|
+
const SPIN_GLYPH = icon(['M8 2.5a5.5 5.5 0 1 1-4.3 2.06'], SPIN_STYLE)
|
|
112
|
+
|
|
113
|
+
function Button() {
|
|
114
|
+
const [state, setState] = React.useState(EMPTY)
|
|
115
|
+
const [hover, setHover] = React.useState(false)
|
|
116
|
+
const mounted = React.useRef(true)
|
|
117
|
+
|
|
118
|
+
React.useEffect(() => {
|
|
119
|
+
ensureStyle()
|
|
120
|
+
mounted.current = true
|
|
121
|
+
let timer = null
|
|
122
|
+
const pull = async () => {
|
|
123
|
+
const next = await readState()
|
|
124
|
+
if (!mounted.current) return
|
|
125
|
+
if (next !== null) setState({ ...EMPTY, ...next })
|
|
126
|
+
}
|
|
127
|
+
pull()
|
|
128
|
+
timer = setInterval(() => {
|
|
129
|
+
if (document.visibilityState === 'visible') pull()
|
|
130
|
+
}, POLL_MS)
|
|
131
|
+
const onVisible = () => {
|
|
132
|
+
if (document.visibilityState === 'visible') pull()
|
|
133
|
+
}
|
|
134
|
+
document.addEventListener('visibilitychange', onVisible)
|
|
135
|
+
return () => {
|
|
136
|
+
mounted.current = false
|
|
137
|
+
if (timer !== null) clearInterval(timer)
|
|
138
|
+
document.removeEventListener('visibilitychange', onVisible)
|
|
139
|
+
}
|
|
140
|
+
}, [])
|
|
141
|
+
|
|
142
|
+
const busy = state.action === 'busy'
|
|
143
|
+
const action = state.action === 'pause' || state.action === 'resume' ? state.action : null
|
|
144
|
+
const disabled = action === null && !busy
|
|
145
|
+
|
|
146
|
+
const onClick = async () => {
|
|
147
|
+
if (busy || action === null) return
|
|
148
|
+
// The phase is set from the Host's answer, never optimistically: the spinner appears when // ui-copy-ignore
|
|
149
|
+
// the Host says it started, so two windows can never show different phases. // ui-copy-ignore
|
|
150
|
+
setState((current) => ({ ...current, action: 'busy' }))
|
|
151
|
+
const answer = await press(action)
|
|
152
|
+
const next = await readState()
|
|
153
|
+
if (!mounted.current) return
|
|
154
|
+
if (next !== null) setState({ ...EMPTY, ...next })
|
|
155
|
+
else if (answer === null) setState((current) => ({ ...current, action }))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const style = busy || disabled
|
|
159
|
+
? { ...BUTTON_STYLE, opacity: 0.4 }
|
|
160
|
+
: hover
|
|
161
|
+
? { ...BUTTON_STYLE, opacity: 1, background: 'var(--dsh-color-surface-hover, rgba(127,127,127,0.14))' }
|
|
162
|
+
: BUTTON_STYLE
|
|
163
|
+
|
|
164
|
+
const glyph = busy ? SPIN_GLYPH : action === 'resume' ? PLAY_GLYPH : PAUSE_GLYPH
|
|
165
|
+
const label = busy ? COPY.busy : action === 'resume' ? COPY.resume : COPY.pause
|
|
166
|
+
|
|
167
|
+
return h('button', {
|
|
168
|
+
type: 'button', // ui-copy-ignore
|
|
169
|
+
style,
|
|
170
|
+
onClick,
|
|
171
|
+
disabled,
|
|
172
|
+
'aria-label': label,
|
|
173
|
+
'aria-busy': busy,
|
|
174
|
+
onMouseEnter: () => setHover(true),
|
|
175
|
+
onMouseLeave: () => setHover(false),
|
|
176
|
+
}, glyph, state.error === null || state.error === undefined ? null : h('span', { style: DOT_STYLE, 'aria-hidden': true }))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
inject: ['slots'], // ui-copy-ignore
|
|
181
|
+
apply(ctx) {
|
|
182
|
+
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
|
|
183
|
+
name: 'conversation.session.header.utilities',
|
|
184
|
+
id: 'dsh-pause-resume', // ui-copy-ignore
|
|
185
|
+
order: 31, // ui-copy-ignore
|
|
186
|
+
label: () => COPY.pause, // ui-copy-ignore
|
|
187
|
+
}, Button))
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
})
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Pause and resume">
|
|
2
|
+
<rect x="1" y="1" width="22" height="22" rx="6" fill="#1f6feb"/>
|
|
3
|
+
<rect x="6.5" y="6" width="3.2" height="12" rx="1.2" fill="#ffffff"/>
|
|
4
|
+
<path d="M13.2 6.6v10.8a.6.6 0 0 0 .92.5l8.06-5.4a.7.7 0 0 0 0-1.16l-8.06-5.4a.6.6 0 0 0-.92.5z" fill="#ffffff" fill-opacity="0.55"/>
|
|
5
|
+
<path d="M12.3 6.4v11.2a.5.5 0 0 0 .77.42l5.6-5.6a.6.6 0 0 0 0-.85l-5.6-5.6a.5.5 0 0 0-.77.42z" fill="#ffffff"/>
|
|
6
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { createStore } from './lib/snapshot.js'
|
|
4
|
+
import { createEngine } from './lib/engine.js'
|
|
5
|
+
import { createHeartbeat } from './lib/heartbeat.js'
|
|
6
|
+
import { createProbe, listRunning } from './lib/probe.js'
|
|
7
|
+
|
|
8
|
+
export const name = 'dsh-pause-resume'
|
|
9
|
+
export const VERSION = '0.1.0'
|
|
10
|
+
|
|
11
|
+
const BASE_PATH = '/dsh-pause-resume'
|
|
12
|
+
const BUTTON_HEADER = 'x-dsh-pause-resume'
|
|
13
|
+
const BOOT_KEY = Symbol.for('dsh-pause-resume.boot')
|
|
14
|
+
|
|
15
|
+
/** A page this machine may have opened. A public name that resolves here is the rebinding case. */
|
|
16
|
+
const LOCAL_ADDRESS =
|
|
17
|
+
/^(127\.\d+\.\d+\.\d+|localhost|\[::1\]|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|169\.254\.\d+\.\d+|[a-z0-9-]+\.local)(:\d+)?$/u
|
|
18
|
+
|
|
19
|
+
function log(message) {
|
|
20
|
+
try {
|
|
21
|
+
console.log(`[dsh-pause-resume] ${message}`)
|
|
22
|
+
} catch {
|
|
23
|
+
/* a closed stdout is not worth a throw */
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function dataDir() {
|
|
28
|
+
const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME !== '' ? process.env.DSH_HOME : join(homedir(), '.dsh')
|
|
29
|
+
return join(home, 'pause-resume')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One id per process: a reload of this plugin is the same process and the same boot. */
|
|
33
|
+
function bootId() {
|
|
34
|
+
const existing = globalThis[BOOT_KEY]
|
|
35
|
+
if (typeof existing === 'string') return existing
|
|
36
|
+
const created = `${process.pid}-${Date.now().toString(36)}`
|
|
37
|
+
globalThis[BOOT_KEY] = created
|
|
38
|
+
return created
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
function service(ctx, key) {
|
|
43
|
+
try {
|
|
44
|
+
return ctx.get(key)
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sendJson(res, status, value) {
|
|
51
|
+
try {
|
|
52
|
+
const body = JSON.stringify(value ?? { status: 'error' })
|
|
53
|
+
if (res?.headersSent === true) return res.end()
|
|
54
|
+
res.writeHead(status, {
|
|
55
|
+
'content-type': 'application/json; charset=utf-8',
|
|
56
|
+
'cache-control': 'no-store',
|
|
57
|
+
'content-length': Buffer.byteLength(body),
|
|
58
|
+
})
|
|
59
|
+
res.end(body)
|
|
60
|
+
} catch {
|
|
61
|
+
try {
|
|
62
|
+
if (res?.writableEnded !== true && res?.destroyed !== true) res.end()
|
|
63
|
+
} catch {
|
|
64
|
+
/* the socket is already gone */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether one request may act for the button: a header a cross-site form cannot set, same origin. */
|
|
70
|
+
function buttonAllowed(req) {
|
|
71
|
+
try {
|
|
72
|
+
const header = req?.headers?.[BUTTON_HEADER]
|
|
73
|
+
if (typeof header !== 'string' || header === '') return false
|
|
74
|
+
const site = typeof req?.headers?.host === 'string' ? req.headers.host.trim().toLowerCase() : '' // ui-copy-ignore
|
|
75
|
+
if (site !== '' && LOCAL_ADDRESS.test(site) === false) return false
|
|
76
|
+
const origin = typeof req?.headers?.origin === 'string' ? req.headers.origin.trim().toLowerCase() : ''
|
|
77
|
+
if (origin === 'null') return false
|
|
78
|
+
if (origin !== '') {
|
|
79
|
+
let originSite = ''
|
|
80
|
+
try {
|
|
81
|
+
originSite = new URL(origin).host.toLowerCase() // ui-copy-ignore
|
|
82
|
+
} catch {
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
if (originSite !== site) return false
|
|
86
|
+
}
|
|
87
|
+
return true
|
|
88
|
+
} catch {
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function guard(handler) {
|
|
94
|
+
return (req, res) => {
|
|
95
|
+
const fallback = (error) => {
|
|
96
|
+
log(`route failed: ${error?.message ?? error}`)
|
|
97
|
+
try {
|
|
98
|
+
if (res?.headersSent !== true) sendJson(res, 503, { status: 'error' })
|
|
99
|
+
else if (res?.writableEnded !== true && res?.destroyed !== true) res.end()
|
|
100
|
+
} catch {
|
|
101
|
+
/* already answered */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const answer = handler(req, res)
|
|
106
|
+
if (answer !== null && typeof answer === 'object' && typeof answer.then === 'function') answer.then(undefined, fallback)
|
|
107
|
+
return answer
|
|
108
|
+
} catch (error) {
|
|
109
|
+
fallback(error)
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function serve(ctx, engine, heartbeat) {
|
|
116
|
+
const server = service(ctx, 'webServer')
|
|
117
|
+
if (server === undefined || server === null || typeof server.register !== 'function') {
|
|
118
|
+
log('no web server: the header button cannot ask for a pause')
|
|
119
|
+
return () => {}
|
|
120
|
+
}
|
|
121
|
+
const handlers = [
|
|
122
|
+
[
|
|
123
|
+
`${BASE_PATH}/state`,
|
|
124
|
+
async (req, res) => {
|
|
125
|
+
const method = String(req?.method ?? 'GET').toUpperCase()
|
|
126
|
+
if (method !== 'GET' && method !== 'HEAD') return sendJson(res, 405, { status: 'method' })
|
|
127
|
+
sendJson(res, 200, await engine.describe())
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
[
|
|
131
|
+
`${BASE_PATH}/pause`,
|
|
132
|
+
async (req, res) => {
|
|
133
|
+
const method = String(req?.method ?? 'GET').toUpperCase()
|
|
134
|
+
if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
|
|
135
|
+
if (buttonAllowed(req) === false) return sendJson(res, 403, { status: 'forbidden' })
|
|
136
|
+
const outcome = await engine.pause()
|
|
137
|
+
log(`pause: ${outcome.status}${outcome.status === 'busy' ? ` (${outcome.phase})` : ''}`)
|
|
138
|
+
if (outcome.status === 'paused') heartbeat.release()
|
|
139
|
+
sendJson(res, 200, { ...(await engine.describe()), outcome })
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
[
|
|
143
|
+
`${BASE_PATH}/resume`,
|
|
144
|
+
async (req, res) => {
|
|
145
|
+
const method = String(req?.method ?? 'GET').toUpperCase()
|
|
146
|
+
if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
|
|
147
|
+
if (buttonAllowed(req) === false) return sendJson(res, 403, { status: 'forbidden' })
|
|
148
|
+
const outcome = await engine.resume()
|
|
149
|
+
log(`resume: ${outcome.status}`)
|
|
150
|
+
if (outcome.status === 'resumed') heartbeat.release()
|
|
151
|
+
sendJson(res, 200, { ...(await engine.describe()), outcome })
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
]
|
|
155
|
+
const disposers = []
|
|
156
|
+
for (const [path, handler] of handlers) {
|
|
157
|
+
try {
|
|
158
|
+
const dispose = server.register({ kind: 'exact', path, handler: guard(handler) })
|
|
159
|
+
if (typeof dispose === 'function') disposers.push(dispose)
|
|
160
|
+
} catch (error) {
|
|
161
|
+
log(`cannot answer ${path}: ${error?.message ?? error}`)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return () => {
|
|
165
|
+
for (const dispose of disposers) {
|
|
166
|
+
try {
|
|
167
|
+
dispose()
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function apply(ctx, config = {}) {
|
|
174
|
+
const dir = dataDir()
|
|
175
|
+
const store = createStore(dir)
|
|
176
|
+
const heartbeat = createHeartbeat({
|
|
177
|
+
dir,
|
|
178
|
+
bootId: bootId(),
|
|
179
|
+
listRunning: () => listRunning(ctx),
|
|
180
|
+
log,
|
|
181
|
+
})
|
|
182
|
+
const engine = createEngine({
|
|
183
|
+
store,
|
|
184
|
+
probe: createProbe(ctx, heartbeat),
|
|
185
|
+
graceMs: Number.isFinite(config?.graceMs) ? config.graceMs : 2000,
|
|
186
|
+
gapMs: Number.isFinite(config?.gapMs) ? config.gapMs : 150,
|
|
187
|
+
log,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
let disposeRoutes = () => {}
|
|
191
|
+
const cleanup = () => {
|
|
192
|
+
try {
|
|
193
|
+
disposeRoutes()
|
|
194
|
+
} catch {}
|
|
195
|
+
heartbeat.stop()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => cleanup)
|
|
199
|
+
else if (typeof ctx.on === 'function') ctx.on('dispose', cleanup)
|
|
200
|
+
|
|
201
|
+
void (async () => {
|
|
202
|
+
try {
|
|
203
|
+
await engine.hydrate()
|
|
204
|
+
heartbeat.start()
|
|
205
|
+
disposeRoutes = serve(ctx, engine, heartbeat)
|
|
206
|
+
log(`ready (${engine.phase})`)
|
|
207
|
+
} catch (error) {
|
|
208
|
+
log(`cannot start: ${error?.message ?? error}`)
|
|
209
|
+
}
|
|
210
|
+
})()
|
|
211
|
+
|
|
212
|
+
return { store, engine, heartbeat }
|
|
213
|
+
}
|
package/lib/engine.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only place the button's shape comes from.
|
|
3
|
+
*
|
|
4
|
+
* One phase value, written before any work starts and after it ends, plus a durable snapshot. The
|
|
5
|
+
* two transient phases are exclusive by construction: `pause()` and `resume()` refuse while either
|
|
6
|
+
* is set, so two windows, a double press and a tool call can never overlap. The client is not
|
|
7
|
+
* allowed to infer a phase: it asks, and renders the answer.
|
|
8
|
+
*
|
|
9
|
+
* The engine performs no I/O of its own. `store` persists, `probe` reads and acts on the running
|
|
10
|
+
* Harness, so every transition here is testable without a Harness.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const TRANSIENT = new Set(['pausing', 'resuming'])
|
|
14
|
+
|
|
15
|
+
export function createEngine({ store, probe, now = () => Date.now(), graceMs = 2000, gapMs = 150, log = () => {} }) {
|
|
16
|
+
let phase = 'idle'
|
|
17
|
+
let lastError = null
|
|
18
|
+
let result = null
|
|
19
|
+
/** Set while a press runs, so a second caller joins the answer instead of starting work again. */
|
|
20
|
+
let inflight = null
|
|
21
|
+
|
|
22
|
+
const emptyLive = { sessions: [], goals: [], schedules: [], jobs: [], terminals: [] }
|
|
23
|
+
|
|
24
|
+
function pendingSnapshot() {
|
|
25
|
+
const snapshot = store.read()
|
|
26
|
+
// `pausing` is included on purpose: it means the last press stopped halfway, so the work in it
|
|
27
|
+
// still has to be restored. Reporting it as nothing to restore would hide a half-done pause.
|
|
28
|
+
return snapshot
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function live() {
|
|
32
|
+
try {
|
|
33
|
+
const value = await probe.live()
|
|
34
|
+
return { ...emptyLive, ...(value ?? {}) }
|
|
35
|
+
} catch (error) {
|
|
36
|
+
log(`cannot read live work: ${error?.message ?? error}`)
|
|
37
|
+
return emptyLive
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function crashPlan() {
|
|
42
|
+
try {
|
|
43
|
+
const plan = await probe.crashPlan()
|
|
44
|
+
return plan ?? null
|
|
45
|
+
} catch (error) {
|
|
46
|
+
log(`cannot read crash residue: ${error?.message ?? error}`)
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function countsOf(plan) {
|
|
52
|
+
if (plan === null || plan === undefined) return { sessions: 0, goals: 0, schedules: 0, jobs: 0 }
|
|
53
|
+
return {
|
|
54
|
+
sessions: Array.isArray(plan.sessions) ? plan.sessions.length : 0,
|
|
55
|
+
goals: Array.isArray(plan.goals) ? plan.goals.length : 0,
|
|
56
|
+
schedules: Array.isArray(plan.schedules) ? plan.schedules.length : 0,
|
|
57
|
+
jobs: Array.isArray(plan.jobs) ? plan.jobs.length : 0,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What the button shows. `action` is the one decision the client may not make itself:
|
|
63
|
+
* `none` (nothing to do), `pause`, `resume`, or `busy` (an operation is running).
|
|
64
|
+
*/
|
|
65
|
+
async function describe() {
|
|
66
|
+
const snapshot = pendingSnapshot()
|
|
67
|
+
const current = await live()
|
|
68
|
+
const plan = snapshot ?? (await crashPlan())
|
|
69
|
+
const busy = TRANSIENT.has(phase)
|
|
70
|
+
const canPause = !busy && current.sessions.length + current.goals.length > 0
|
|
71
|
+
const action = busy ? 'busy' : plan !== null ? 'resume' : canPause ? 'pause' : 'none'
|
|
72
|
+
return {
|
|
73
|
+
phase,
|
|
74
|
+
action,
|
|
75
|
+
source: plan === null ? null : plan.status === 'pausing' ? 'crash' : snapshot !== null ? 'pause' : plan.source ?? 'crash',
|
|
76
|
+
complete: plan === null ? null : plan.status === 'paused',
|
|
77
|
+
counts: countsOf(plan), // ui-copy-ignore
|
|
78
|
+
live: { // ui-copy-ignore
|
|
79
|
+
sessions: current.sessions.length,
|
|
80
|
+
goals: current.goals.length,
|
|
81
|
+
jobs: current.jobs.length,
|
|
82
|
+
},
|
|
83
|
+
error: lastError, // ui-copy-ignore
|
|
84
|
+
result,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function snapshotOf(current) {
|
|
89
|
+
return {
|
|
90
|
+
version: 1, // ui-copy-ignore
|
|
91
|
+
status: 'pausing', // ui-copy-ignore
|
|
92
|
+
createdAt: new Date(now()).toISOString(),
|
|
93
|
+
sessions: current.sessions, // ui-copy-ignore
|
|
94
|
+
goals: current.goals, // ui-copy-ignore
|
|
95
|
+
schedules: current.schedules,
|
|
96
|
+
jobs: current.jobs, // ui-copy-ignore
|
|
97
|
+
terminals: current.terminals,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function runPause() {
|
|
102
|
+
const current = await live()
|
|
103
|
+
if (current.sessions.length === 0 && current.goals.length === 0) {
|
|
104
|
+
phase = 'idle'
|
|
105
|
+
return { status: 'idle', action: 'none' }
|
|
106
|
+
}
|
|
107
|
+
lastError = null
|
|
108
|
+
// The snapshot exists before anything is stopped: a crash between the two is then visible as
|
|
109
|
+
// `pausing` on the next boot instead of being reported as a clean pause.
|
|
110
|
+
const draft = snapshotOf(current)
|
|
111
|
+
if (store.write(draft) === null) {
|
|
112
|
+
phase = 'idle'
|
|
113
|
+
lastError = 'store'
|
|
114
|
+
return { status: 'failed', reason: 'store' }
|
|
115
|
+
}
|
|
116
|
+
let stopped = null
|
|
117
|
+
try {
|
|
118
|
+
stopped = await probe.stop(current, { graceMs })
|
|
119
|
+
} catch (error) {
|
|
120
|
+
log(`stopping work failed: ${error?.message ?? error}`)
|
|
121
|
+
stopped = { canceled: [], unknown: [] }
|
|
122
|
+
}
|
|
123
|
+
draft.status = 'paused'
|
|
124
|
+
draft.stoppedAt = new Date(now()).toISOString()
|
|
125
|
+
draft.stopped = stopped
|
|
126
|
+
if (store.write(draft) === null) {
|
|
127
|
+
// The durable copy still says `pausing`, which is the truth: the next boot reports it.
|
|
128
|
+
lastError = 'store'
|
|
129
|
+
}
|
|
130
|
+
phase = 'paused'
|
|
131
|
+
const counts = countsOf(draft)
|
|
132
|
+
return { status: 'paused', counts }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function runResume(plan) {
|
|
136
|
+
lastError = null
|
|
137
|
+
const done = store.readJournal()
|
|
138
|
+
const summary = { restored: [], skipped: [], failed: [], jobs: countsOf(plan).jobs }
|
|
139
|
+
const mark = (key) => {
|
|
140
|
+
store.appendJournal(key)
|
|
141
|
+
done.add(key)
|
|
142
|
+
}
|
|
143
|
+
const step = async (key, label, run) => {
|
|
144
|
+
if (done.has(key)) return
|
|
145
|
+
try {
|
|
146
|
+
const outcome = await run()
|
|
147
|
+
if (outcome === 'restored') summary.restored.push(label)
|
|
148
|
+
else summary.skipped.push(label)
|
|
149
|
+
} catch (error) {
|
|
150
|
+
summary.failed.push(`${label}: ${String(error?.message ?? error).slice(0, 120)}`)
|
|
151
|
+
}
|
|
152
|
+
mark(key)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const session of plan.sessions ?? []) {
|
|
156
|
+
await step(`s:${session.sessionId}`, `session ${session.sessionId}`, () => probe.restoreSession(session, { plan }))
|
|
157
|
+
}
|
|
158
|
+
for (const goal of plan.goals ?? []) {
|
|
159
|
+
await step(`g:${goal.goalId ?? goal.sessionId}`, `goal ${goal.goalId ?? goal.sessionId}`, () => probe.restoreGoal(goal, { plan }))
|
|
160
|
+
}
|
|
161
|
+
for (const schedule of plan.schedules ?? []) {
|
|
162
|
+
await step(`c:${schedule.id}`, `schedule ${schedule.id}`, () => probe.restoreSchedule(schedule, { plan }))
|
|
163
|
+
}
|
|
164
|
+
store.consume(summary)
|
|
165
|
+
store.clearJournal()
|
|
166
|
+
result = summary
|
|
167
|
+
phase = 'idle'
|
|
168
|
+
return { status: 'resumed', summary }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Join a press already running: the second caller is told it is busy and never starts work again. */
|
|
172
|
+
function once(kind, run) {
|
|
173
|
+
if (inflight !== null) return Promise.resolve({ status: 'busy', phase })
|
|
174
|
+
const running = (async () => {
|
|
175
|
+
try {
|
|
176
|
+
return await run()
|
|
177
|
+
} catch (error) {
|
|
178
|
+
log(`${kind} failed: ${error?.message ?? error}`)
|
|
179
|
+
lastError = 'internal'
|
|
180
|
+
phase = 'idle'
|
|
181
|
+
return { status: 'failed', reason: 'internal' }
|
|
182
|
+
} finally {
|
|
183
|
+
inflight = null
|
|
184
|
+
}
|
|
185
|
+
})()
|
|
186
|
+
inflight = running
|
|
187
|
+
return running
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function pause() {
|
|
191
|
+
if (TRANSIENT.has(phase) || inflight !== null) return { status: 'busy', phase }
|
|
192
|
+
if (phase === 'paused') return { status: 'already' }
|
|
193
|
+
// Set synchronously, before the first await: two windows pressing at once are then exclusive
|
|
194
|
+
// in the same tick they were dispatched, not one microtask later.
|
|
195
|
+
phase = 'pausing'
|
|
196
|
+
return once('pause', runPause)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function resume() {
|
|
200
|
+
if (TRANSIENT.has(phase) || inflight !== null) return { status: 'busy', phase }
|
|
201
|
+
phase = 'resuming'
|
|
202
|
+
return once('resume', async () => {
|
|
203
|
+
const plan = pendingSnapshot() ?? (await crashPlan())
|
|
204
|
+
if (plan === null) {
|
|
205
|
+
phase = 'idle'
|
|
206
|
+
return { status: 'empty' }
|
|
207
|
+
}
|
|
208
|
+
return runResume(plan)
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A reload of this plugin must not invent a phase: the disk and the running Harness decide it. */
|
|
213
|
+
async function hydrate() {
|
|
214
|
+
// A reloaded plugin must not invent a phase: the disk decides whether work is still stopped.
|
|
215
|
+
phase = pendingSnapshot() !== null ? 'paused' : 'idle'
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { describe, pause, resume, hydrate, get phase() { return phase }, pendingSnapshot, gapMs }
|
|
219
|
+
}
|
package/lib/heartbeat.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { mkdirSync, openSync, closeSync, fsyncSync, readFileSync, renameSync, writeSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Crash residue without reading logs.
|
|
6
|
+
*
|
|
7
|
+
* A snapshot only exists because a person pressed the button. A crash has no press, so this file
|
|
8
|
+
* answers the other half: which sessions had a turn running a few seconds ago. It is written on a
|
|
9
|
+
* timer from the same list the button already reads, and only when the set changes.
|
|
10
|
+
*
|
|
11
|
+
* The file carries the id of the process that wrote it. The same process reloading the plugin (HMR)
|
|
12
|
+
* therefore sees its own heartbeat and nothing to restore, while a new process sees the previous
|
|
13
|
+
* one's and offers ▶.
|
|
14
|
+
*/
|
|
15
|
+
export function createHeartbeat({ dir, bootId, intervalMs = 5000, listRunning, now = () => Date.now(), log = () => {} }) {
|
|
16
|
+
const file = join(dir, 'running.json')
|
|
17
|
+
let timer = null
|
|
18
|
+
let last = ''
|
|
19
|
+
let residue = null
|
|
20
|
+
let stopped = false
|
|
21
|
+
|
|
22
|
+
function write(sessions) {
|
|
23
|
+
const body = JSON.stringify({ version: 1, bootId, updatedAt: now(), sessions })
|
|
24
|
+
try {
|
|
25
|
+
mkdirSync(dir, { recursive: true })
|
|
26
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
27
|
+
const fd = openSync(tmp, 'w')
|
|
28
|
+
try {
|
|
29
|
+
writeSync(fd, body)
|
|
30
|
+
fsyncSync(fd)
|
|
31
|
+
} finally {
|
|
32
|
+
closeSync(fd)
|
|
33
|
+
}
|
|
34
|
+
renameSync(tmp, file)
|
|
35
|
+
return true
|
|
36
|
+
} catch (error) {
|
|
37
|
+
log(`cannot record running sessions: ${error?.message ?? error}`)
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Claims the previous process's heartbeat once, at start, before this process overwrites it. */
|
|
43
|
+
function capture() {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
46
|
+
if (parsed === null || typeof parsed !== 'object' || parsed.version !== 1) return null
|
|
47
|
+
if (typeof parsed.bootId === 'string' && parsed.bootId === bootId) return null
|
|
48
|
+
const sessions = Array.isArray(parsed.sessions) ? parsed.sessions.filter((row) => typeof row?.sessionId === 'string') : []
|
|
49
|
+
if (sessions.length === 0) return null
|
|
50
|
+
residue = { sessions, at: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : null }
|
|
51
|
+
return residue
|
|
52
|
+
} catch {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function tick() {
|
|
58
|
+
if (stopped || residue !== null) return
|
|
59
|
+
let sessions
|
|
60
|
+
try {
|
|
61
|
+
sessions = await listRunning()
|
|
62
|
+
} catch (error) {
|
|
63
|
+
log(`cannot list running sessions: ${error?.message ?? error}`)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const body = JSON.stringify(sessions.map((sessionId) => ({ sessionId })))
|
|
67
|
+
if (body === last) return
|
|
68
|
+
last = body
|
|
69
|
+
write(sessions.map((sessionId) => ({ sessionId })))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function start() {
|
|
73
|
+
if (stopped || timer !== null) return
|
|
74
|
+
capture()
|
|
75
|
+
void tick()
|
|
76
|
+
timer = setInterval(() => void tick(), intervalMs)
|
|
77
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The residue was restored: from here on the heartbeat describes this process again. */
|
|
81
|
+
function release() {
|
|
82
|
+
residue = null
|
|
83
|
+
last = ''
|
|
84
|
+
write([])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function stop() {
|
|
88
|
+
stopped = true
|
|
89
|
+
if (timer !== null) clearInterval(timer)
|
|
90
|
+
timer = null
|
|
91
|
+
if (residue === null) write([])
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { start, stop, release, get residue() { return residue } }
|
|
95
|
+
}
|
package/lib/probe.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the Harness is doing right now, and how to stop one piece of it without losing the pieces
|
|
3
|
+
* behind it. Only service contracts are used here, and every service is reached through the
|
|
4
|
+
* registry this module is given: one that is missing, or that answers nothing, is a smaller set of
|
|
5
|
+
* work rather than a failed press.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function service(services, key) {
|
|
9
|
+
try {
|
|
10
|
+
return services.get(key)
|
|
11
|
+
} catch {
|
|
12
|
+
return undefined
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function agentOf(services, sessionId) {
|
|
17
|
+
const agents = service(services, 'agents')
|
|
18
|
+
if (agents === undefined || agents === null || typeof agents.get !== 'function') return undefined
|
|
19
|
+
try {
|
|
20
|
+
const handle = agents.get(sessionId)
|
|
21
|
+
if (handle === undefined || handle === null) return undefined
|
|
22
|
+
return handle.agent ?? handle
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Live agents are the cheap truth for "who is attached right now": a couple of dozen handles in
|
|
30
|
+
* memory, against a listing that walks every session ever recorded on disk. It answers `undefined`
|
|
31
|
+
* when the registry cannot be read, so the listing stays the fallback rather than a second opinion.
|
|
32
|
+
*/
|
|
33
|
+
function agentsOf(services) {
|
|
34
|
+
const agents = service(services, 'agents')
|
|
35
|
+
if (agents === undefined || agents === null || typeof agents.list !== 'function') return undefined
|
|
36
|
+
try {
|
|
37
|
+
const handles = agents.list()
|
|
38
|
+
if (!Array.isArray(handles)) return undefined
|
|
39
|
+
const live = handles.map((handle) => handle?.agent ?? handle).filter((agent) => agent !== null && typeof agent?.id === 'string')
|
|
40
|
+
return live
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One row per attached agent: running says whether its turn is open, and every one is attached. */
|
|
47
|
+
async function attachedRows(services) {
|
|
48
|
+
const agents = agentsOf(services)
|
|
49
|
+
if (agents !== undefined) {
|
|
50
|
+
return agents.map((agent) => ({ sessionId: agent.id, running: agent.status === 'running', agentAvailable: true }))
|
|
51
|
+
}
|
|
52
|
+
const rows = await listSessions(services)
|
|
53
|
+
return rows.filter((row) => row.running === true || row.agentAvailable === true)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The session ids whose turn is open right now: the heartbeat's only question. */
|
|
57
|
+
export async function listRunning(services) {
|
|
58
|
+
const agents = agentsOf(services)
|
|
59
|
+
if (agents !== undefined) return agents.filter((agent) => agent.status === 'running').map((agent) => agent.id)
|
|
60
|
+
const rows = await listSessions(services)
|
|
61
|
+
return rows.filter((row) => row.running === true).map((row) => row.sessionId)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The goal service addresses one live agent, and every agent it accepts names itself by session
|
|
66
|
+
* id, so a session id is the whole handle. It throws for an agent that is not live: that is the
|
|
67
|
+
* ordinary answer for a closed session, not an error worth surfacing.
|
|
68
|
+
*/
|
|
69
|
+
function goalAgent(sessionId) {
|
|
70
|
+
return { id: sessionId }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function goalOf(services, sessionId) {
|
|
74
|
+
const goals = service(services, 'goals')
|
|
75
|
+
if (goals === undefined || goals === null || typeof goals.get !== 'function') return undefined
|
|
76
|
+
try {
|
|
77
|
+
const view = await goals.get(goalAgent(sessionId))
|
|
78
|
+
return view === undefined || view === null ? undefined : view
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The listing service answers `{ items }` through its remote facade and a bare array in tests. */
|
|
85
|
+
async function listSessions(services) {
|
|
86
|
+
const controller = service(services, 'sessionController')
|
|
87
|
+
if (controller === undefined || controller === null || typeof controller.list !== 'function') return []
|
|
88
|
+
const value = await controller.list({})
|
|
89
|
+
const rows = Array.isArray(value) ? value : value?.items ?? value?.sessions ?? []
|
|
90
|
+
return rows.filter((row) => row !== null && typeof row?.sessionId === 'string')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Owned jobs are invisible to a caller-less `list`, so every attached session is asked for its own
|
|
95
|
+
* bucket as well. One job can only be owned by one session, and the id map keeps the union exact.
|
|
96
|
+
*/
|
|
97
|
+
function listJobs(services, owners = []) {
|
|
98
|
+
const jobs = service(services, 'jobs')
|
|
99
|
+
if (jobs === undefined || jobs === null || typeof jobs.list !== 'function') return []
|
|
100
|
+
const seen = new Map()
|
|
101
|
+
const collect = (rows) => {
|
|
102
|
+
if (!Array.isArray(rows)) return
|
|
103
|
+
for (const row of rows) if (row !== null && typeof row?.id === 'string' && !seen.has(row.id)) seen.set(row.id, row)
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
collect(jobs.list())
|
|
107
|
+
} catch {
|
|
108
|
+
// An unusable registry contributes nothing; the state shows no jobs.
|
|
109
|
+
}
|
|
110
|
+
for (const owner of owners) {
|
|
111
|
+
try {
|
|
112
|
+
collect(jobs.list(owner))
|
|
113
|
+
} catch {
|
|
114
|
+
// A bucket that cannot be read is not work this press can stop.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return [...seen.values()]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The Harness half of the plugin: what is running now, how to stop it, how to put it back. Every
|
|
122
|
+
* method answers `restored` / `skipped` rather than throwing, because one item that cannot be put
|
|
123
|
+
* back must not stop the items behind it.
|
|
124
|
+
*/
|
|
125
|
+
export function createProbe(services, heartbeat) {
|
|
126
|
+
const graceMs = 1500
|
|
127
|
+
|
|
128
|
+
async function live() {
|
|
129
|
+
const attached = await attachedRows(services)
|
|
130
|
+
const views = await Promise.all(attached.map((row) => goalOf(services, row.sessionId)))
|
|
131
|
+
const sessions = []
|
|
132
|
+
const goals = []
|
|
133
|
+
attached.forEach((row, index) => {
|
|
134
|
+
const running = row.running === true
|
|
135
|
+
const goal = views[index]
|
|
136
|
+
const active = goal !== undefined && goal.phase === 'active'
|
|
137
|
+
// A session is part of the work in two cases only: a turn is running, or a goal is armed and
|
|
138
|
+
// can start one. A session the person merely has open is a tab, not work.
|
|
139
|
+
if (running || active) sessions.push({ sessionId: row.sessionId, running })
|
|
140
|
+
if (active) goals.push({ sessionId: row.sessionId, goalId: goal.id, revision: goal.revision })
|
|
141
|
+
})
|
|
142
|
+
const jobs = listJobs(services, attached.map((row) => row.sessionId))
|
|
143
|
+
.filter((job) => job.status === 'running' || job.status === 'stopping')
|
|
144
|
+
.map((job) => ({ id: job.id, label: job.label ?? job.kind ?? '', owner: job.owner }))
|
|
145
|
+
return { sessions, goals, schedules: [], jobs, terminals: [] }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function stop(current, { graceMs: grace } = {}) {
|
|
149
|
+
const controller = service(services, 'sessionController')
|
|
150
|
+
const goals = service(services, 'goals')
|
|
151
|
+
const jobs = service(services, 'jobs')
|
|
152
|
+
const canceled = []
|
|
153
|
+
const unknown = []
|
|
154
|
+
|
|
155
|
+
for (const session of current.sessions ?? []) {
|
|
156
|
+
if (session.running !== true) continue
|
|
157
|
+
try {
|
|
158
|
+
controller?.cancel?.({ sessionId: session.sessionId })
|
|
159
|
+
canceled.push(session.sessionId)
|
|
160
|
+
} catch (error) {
|
|
161
|
+
unknown.push(`session ${session.sessionId}: ${String(error?.message ?? error).slice(0, 80)}`)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const goal of current.goals ?? []) {
|
|
165
|
+
try {
|
|
166
|
+
await goals?.pause?.(goalAgent(goal.sessionId), { id: goal.goalId, revision: goal.revision })
|
|
167
|
+
} catch (error) {
|
|
168
|
+
unknown.push(`goal ${goal.goalId}: ${String(error?.message ?? error).slice(0, 80)}`)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const job of current.jobs ?? []) {
|
|
172
|
+
try {
|
|
173
|
+
jobs?.kill?.(job.id, job.owner)
|
|
174
|
+
} catch {
|
|
175
|
+
try {
|
|
176
|
+
jobs?.kill?.(job.id)
|
|
177
|
+
} catch (error) {
|
|
178
|
+
unknown.push(`job ${job.id}: ${String(error?.message ?? error).slice(0, 80)}`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A turn ends when its tools return: the press waits a bounded moment for that instead of
|
|
184
|
+
// claiming a stop that has not happened yet.
|
|
185
|
+
const deadline = Date.now() + (grace ?? graceMs)
|
|
186
|
+
let pending = current.sessions?.filter((session) => session.running === true).map((session) => session.sessionId) ?? []
|
|
187
|
+
while (pending.length > 0 && Date.now() < deadline) {
|
|
188
|
+
await new Promise((resolve) => setTimeout(resolve, 80))
|
|
189
|
+
const stillRunning = new Set(await listRunning(services).catch(() => []))
|
|
190
|
+
pending = pending.filter((id) => stillRunning.has(id))
|
|
191
|
+
}
|
|
192
|
+
for (const id of pending) unknown.push(`session ${id}: still running`)
|
|
193
|
+
return { canceled, unknown }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function rearm(sessionId) {
|
|
197
|
+
const goals = service(services, 'goals')
|
|
198
|
+
const goal = await goalOf(services, sessionId)
|
|
199
|
+
if (goal === undefined) return false
|
|
200
|
+
if (goal.phase === 'active' && goal.activation !== 'armed') {
|
|
201
|
+
goals?.resume?.(goalAgent(sessionId), { id: goal.id, revision: goal.revision })
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function restoreSession(session) {
|
|
208
|
+
const agents = service(services, 'agents')
|
|
209
|
+
if (agents === undefined || agents === null || typeof agents.resume !== 'function') throw new Error('agents unavailable')
|
|
210
|
+
if (agentOf(services, session.sessionId) !== undefined) return 'skipped'
|
|
211
|
+
await agents.resume({ resumeSessionId: session.sessionId })
|
|
212
|
+
await rearm(session.sessionId)
|
|
213
|
+
return 'restored'
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function restoreGoal(goal) {
|
|
217
|
+
const goals = service(services, 'goals')
|
|
218
|
+
const view = await goalOf(services, goal.sessionId)
|
|
219
|
+
if (view === undefined || view.id !== goal.goalId) return 'skipped'
|
|
220
|
+
if (view.phase === 'active') return (await rearm(goal.sessionId)) ? 'restored' : 'skipped'
|
|
221
|
+
// The revision is the person's veto: a goal they changed by hand is not resurrected.
|
|
222
|
+
if (view.phase !== 'paused' || view.revision !== goal.revision) return 'skipped'
|
|
223
|
+
goals?.resume?.(goalAgent(goal.sessionId), { id: view.id, revision: view.revision })
|
|
224
|
+
return 'restored'
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function crashPlan() {
|
|
228
|
+
const captured = heartbeat.residue
|
|
229
|
+
if (captured === null) return null
|
|
230
|
+
const rows = await listSessions(services)
|
|
231
|
+
const existing = new Set(rows.map((row) => row.sessionId))
|
|
232
|
+
const sessions = captured.sessions.filter((row) => existing.has(row.sessionId)).map((row) => ({ sessionId: row.sessionId, running: true }))
|
|
233
|
+
if (sessions.length === 0) return null
|
|
234
|
+
return { version: 1, status: 'paused', source: 'crash', sessions, goals: [], schedules: [], jobs: [], terminals: [] }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { live, stop, restoreSession, restoreGoal, restoreSchedule: async () => 'skipped', crashPlan }
|
|
238
|
+
}
|
package/lib/snapshot.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one durable fact this plugin owns: a snapshot of the work a press stopped, plus a journal
|
|
6
|
+
* of what the following restore already put back.
|
|
7
|
+
*
|
|
8
|
+
* Every write is temp-file + fsync + rename, so a reader never sees half a snapshot and a crash
|
|
9
|
+
* leaves the previous complete one in place. Nothing here throws: a disk that cannot be written
|
|
10
|
+
* is reported as `null`, and the caller decides to stay in its current phase instead of claiming
|
|
11
|
+
* a state it could not record.
|
|
12
|
+
*/
|
|
13
|
+
export function createStore(dir) {
|
|
14
|
+
const file = join(dir, 'snapshot.json')
|
|
15
|
+
const journal = join(dir, 'restore.jsonl')
|
|
16
|
+
const consumed = join(dir, 'last-restore.json')
|
|
17
|
+
let ready = false
|
|
18
|
+
|
|
19
|
+
function ensure() {
|
|
20
|
+
if (ready) return true
|
|
21
|
+
try {
|
|
22
|
+
mkdirSync(dir, { recursive: true })
|
|
23
|
+
ready = true
|
|
24
|
+
return true
|
|
25
|
+
} catch {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function read() {
|
|
31
|
+
try {
|
|
32
|
+
const raw = readFileSync(file, 'utf8')
|
|
33
|
+
const parsed = JSON.parse(raw)
|
|
34
|
+
if (parsed === null || typeof parsed !== 'object') return null
|
|
35
|
+
if (parsed.version !== 1) return null
|
|
36
|
+
if (parsed.status !== 'paused' && parsed.status !== 'pausing') return null
|
|
37
|
+
return parsed
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function write(value) {
|
|
44
|
+
if (!ensure()) return null
|
|
45
|
+
const body = JSON.stringify({ ...value, version: 1 })
|
|
46
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
47
|
+
let fd = null
|
|
48
|
+
try {
|
|
49
|
+
fd = openSync(tmp, 'w')
|
|
50
|
+
writeSync(fd, body)
|
|
51
|
+
fsyncSync(fd)
|
|
52
|
+
closeSync(fd)
|
|
53
|
+
fd = null
|
|
54
|
+
renameSync(tmp, file)
|
|
55
|
+
return value
|
|
56
|
+
} catch {
|
|
57
|
+
if (fd !== null) {
|
|
58
|
+
try {
|
|
59
|
+
closeSync(fd)
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
rmSync(tmp, { force: true })
|
|
64
|
+
} catch {}
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The snapshot is spent: the same press must not restore the same work twice. */
|
|
70
|
+
function consume(summary) {
|
|
71
|
+
if (!ensure()) return null
|
|
72
|
+
try {
|
|
73
|
+
const current = read()
|
|
74
|
+
const record = current === null ? { status: 'restored' } : { ...current, status: 'restored', restoredAt: new Date().toISOString(), summary }
|
|
75
|
+
const tmp = `${consumed}.${process.pid}.tmp`
|
|
76
|
+
writeSync(openSync(tmp, 'w'), JSON.stringify(record))
|
|
77
|
+
renameSync(tmp, consumed)
|
|
78
|
+
} catch {}
|
|
79
|
+
try {
|
|
80
|
+
rmSync(file, { force: true })
|
|
81
|
+
} catch {}
|
|
82
|
+
return summary
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Journals are append-only lines of `{"k":"s:<id>"}`; a torn last line is dropped on read. */
|
|
86
|
+
function readJournal() {
|
|
87
|
+
const done = new Set()
|
|
88
|
+
let raw = ''
|
|
89
|
+
try {
|
|
90
|
+
raw = readFileSync(journal, 'utf8')
|
|
91
|
+
} catch {
|
|
92
|
+
return done
|
|
93
|
+
}
|
|
94
|
+
for (const line of raw.split('\n')) {
|
|
95
|
+
if (line === '') continue
|
|
96
|
+
try {
|
|
97
|
+
const row = JSON.parse(line)
|
|
98
|
+
if (typeof row?.k === 'string') done.add(row.k)
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
return done
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function appendJournal(key) {
|
|
105
|
+
if (!ensure()) return false
|
|
106
|
+
try {
|
|
107
|
+
writeSync(openSync(journal, 'a'), `${JSON.stringify({ k: key })}\n`)
|
|
108
|
+
return true
|
|
109
|
+
} catch {
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function clearJournal() {
|
|
115
|
+
try {
|
|
116
|
+
rmSync(journal, { force: true })
|
|
117
|
+
} catch {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function lastRestore() {
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(readFileSync(consumed, 'utf8'))
|
|
123
|
+
} catch {
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function hasSnapshot() {
|
|
129
|
+
return existsSync(file)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { dir, file, read, write, consume, readJournal, appendJournal, clearJournal, lastRestore, hasSnapshot }
|
|
133
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-pause-resume",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "One header button stops every running turn and goal, writes a snapshot, and puts the same work back on the next boot; the button's shape is the only status.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./client": "./client.js",
|
|
10
|
+
"./locale/*.json": "./locale/*.json",
|
|
11
|
+
"./icon": "./icon.svg"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"client.js",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
"lib/*.js",
|
|
18
|
+
"locale/*.json",
|
|
19
|
+
"icon.svg",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"dsh-plugin",
|
|
25
|
+
"deepseek-harness",
|
|
26
|
+
"cordis",
|
|
27
|
+
"pause",
|
|
28
|
+
"resume",
|
|
29
|
+
"snapshot",
|
|
30
|
+
"crash-recovery"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/regrevia/dsh-pause-resume.git"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/regrevia/dsh-pause-resume#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/regrevia/dsh-pause-resume/issues"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"dsh": ">=0.2.0-rc.1"
|
|
42
|
+
},
|
|
43
|
+
"dsh": {
|
|
44
|
+
"bundle": {
|
|
45
|
+
"patch": "./cordis.patch.yml"
|
|
46
|
+
},
|
|
47
|
+
"client": {
|
|
48
|
+
"platform": "web",
|
|
49
|
+
"immediately": true,
|
|
50
|
+
"inject": [
|
|
51
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"test": "node --test test/*.test.mjs",
|
|
57
|
+
"audit": "eslint --no-config-lookup --rule '{\"no-undef\":\"error\",\"no-unused-vars\":[\"error\",{\"args\":\"none\",\"caughtErrors\":\"none\"}]}' --global window --global performance --global document --global navigator --global location --global sessionStorage --global localStorage --global fetch --global Response --global Request --global URL --global URLSearchParams --global AbortController --global CustomEvent --global Event --global setTimeout --global clearTimeout --global setInterval --global clearInterval --global requestAnimationFrame --global queueMicrotask --global structuredClone --global Buffer --global process --global console index.js client.js lib/*.js"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"eslint": "9.39.0"
|
|
61
|
+
}
|
|
62
|
+
}
|