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 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
- # Temporary Holding Version
1
+ # dsh-pause-resume
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ })
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-pause-resume
3
+ name: 'dsh-pause-resume'
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
+ }
@@ -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
+ }
@@ -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
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Pause and Resume",
4
+ "description": "One header button stops every running turn and goal and puts the same work back on the next boot."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "暂停与恢复",
4
+ "description": "一个按钮停下所有在跑的回合与目标,下次启动把它们放回去。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "dsh-pause-resume",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }