dsh-pause-resume 0.0.0-stage → 0.2.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,121 @@
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 what is running, writes a small plan to
4
+ disk, and after a restart the same button puts that work back.
5
+
6
+ * ⏸ — work is running. Press to stop it.
7
+ * ▶ — work is stopped, or a previous process died 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. A red dot is added only when the
12
+ last press left something for a person to look at.
13
+
14
+ ## What it restores
15
+
16
+ | Work | Pause | Restore |
17
+ | --- | --- | --- |
18
+ | A session with an unfinished turn | turn cancelled, session recorded | session mounted, then one visible continuation message |
19
+ | A goal that was armed | paused with the revision it had | `goals.resume` with that exact revision; the goal driver continues on its own |
20
+ | A session that only carried that goal | recorded, nothing stopped | mounted; the resumed goal takes over |
21
+ | A running background job | killed and recorded | recorded only: a job is a live process, not replayable |
22
+ | A session that ended normally | untouched | untouched, and never recorded in the first place |
23
+ | A terminal / finished session | untouched | untouched |
24
+ | A schedule | untouched | untouched |
25
+
26
+ Schedules are not paused: the schedule service has no disable operation, only delete, and deleting a
27
+ person's schedule to pause it is not a trade this plugin makes. A schedule that fires while paused
28
+ starts a turn; the button then shows ⏸ again, truthfully.
29
+
30
+ ## How resume works
31
+
32
+ A restore is not a claim that work continued. It is three honest steps per item:
33
+
34
+ 1. **Mount.** A stopped session is mounted through the Harness resume path, which preserves its agent
35
+ preset. If it is already mounted, that step is skipped.
36
+ 2. **Continue, only where the press actually cancelled a turn.** The continuation is one ordinary
37
+ user message sent through `sessionController.prompt`, with a request id derived from the plan and
38
+ the session. The id makes a repeated restore idempotent, and the message is visible in the
39
+ transcript, so the person can see exactly what was resumed.
40
+ 3. **Re-arm a goal** with the revision it had when paused. The revision is the person's veto: if the
41
+ goal changed while paused, it is not ours to resume and the item is `skipped`.
42
+
43
+ Every finished item is journaled before the plan is archived, so a crash in the middle of a restore
44
+ continues with the remainder instead of restoring the same session twice. A failure leaves the plan
45
+ and the journal on disk: the next press retries only the failed item.
46
+
47
+ ## Crash, not just a press
48
+
49
+ A press writes a snapshot. A crash writes nothing, so the plugin keeps a heartbeat instead:
50
+ `running.json` holds the sessions whose turn is open right now and the id of the process that wrote
51
+ it. A new process finds the previous process's record and offers ▶ with no press needed.
52
+
53
+ A crash record is deliberately weaker than a press: it proves a turn was open, not whether the person
54
+ wanted it continued. So restore mounts those sessions and lists them in the outcome; it does not
55
+ type a continuation message on their behalf. Sessions that ended normally never enter the file.
56
+
57
+ The same process reloading the plugin (hot reload) sees its own id and knows there is nothing to
58
+ restore. A `pausing` plan found on disk means the press itself was interrupted: those sessions are
59
+ mounted but never continued, and the button carries a dot until a person presses restore.
60
+
61
+ ## Installing
62
+
63
+ ```bash
64
+ plugin_manager install_bundle dsh-pause-resume
65
+ ```
66
+
67
+ or install the bundle from the Harness plugin manager with the package name. The plugin needs no
68
+ configuration and holds no credentials. `allowRemote` exists for a Harness reached from another
69
+ machine; local-only is the default.
70
+
71
+ ## Guarantees and limits
72
+
73
+ * **Exclusive.** The phase lives on the Host and is written before any work starts, so two windows,
74
+ a double press and a tool call cannot overlap. The second caller is told `busy`.
75
+ * **Durable before destructive.** No turn is cancelled until the plan is on disk. A failed write means
76
+ nothing was stopped.
77
+ * **Idempotent.** A restore journals every finished item; the continuation request id is derived from
78
+ the plan, so a retry cannot post the same message twice.
79
+ * **Honest.** Unreadable state never reports `paused`. A session deleted by hand, a goal changed by
80
+ hand, a session already running: each is skipped and named in the outcome.
81
+ * **No hidden takeover.** There is no public API to detach an agent, so the pause guarantee is
82
+ "turns cancelled, goals paused, jobs killed" — not "the process is frozen". A goal continues because
83
+ the Harness has a mechanism for that; a plain session continues through one visible user message.
84
+ * **No third-party plugin state.** A plugin's in-process state belongs to that plugin's mount.
85
+ * **Local by default.** The routes require a same-origin request and a plugin header. Remote use must
86
+ be opted into and still cannot skip the header.
87
+
88
+ ## Files
89
+
90
+ All state lives under `$DSH_HOME/pause-resume/`:
91
+
92
+ | File | Purpose |
93
+ | --- | --- |
94
+ | `snapshot.json` | the work a press stopped, `pausing` or `paused` |
95
+ | `journal/<planId>.jsonl` | restore items already finished |
96
+ | `restores/<planId>.json` | the archived plan, kept as a record of the last restore |
97
+ | `last-restore.json` | summary of the last restore |
98
+ | `running.json` | sessions with an open turn, for crash residue |
99
+
100
+ Every write is a unique temp file, `fsync`, `rename`, then a directory `fsync`. State is never inside
101
+ the package.
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ npm test
107
+ npm run audit
108
+ ```
109
+
110
+ `lib/engine.js` performs no I/O, `lib/snapshot.js` persists, `lib/probe.js` holds every Harness service
111
+ contract (agent identity, goal revision, per-owner jobs, continuation prompts), and `index.js` only
112
+ wires them to the web routes. All of them are testable without a Harness: `test/apply.test.mjs` mounts
113
+ the plugin against a fake registry.
114
+
115
+ MIT.
116
+
117
+ ---
118
+
119
+ 一行说明:会话头一个按钮,⏸ 停掉正在跑的回合、目标与后台任务;重启后同一个按钮变 ▶,把上次的工作
120
+ 接回来。正常结束的会话不记录、不恢复;崩溃靠心跳文件识别,但只挂载不代发消息;日程不会被动
121
+ (该服务只有删除,没有停用),后台命令只记录不重跑。
package/client.js ADDED
@@ -0,0 +1,264 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-pause-resume', // ui-copy-ignore
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
+ ? {
10
+ pause: '暂停', // ui-copy-ignore
11
+ resume: '继续', // ui-copy-ignore
12
+ busy: '正在处理', // ui-copy-ignore
13
+ error: '上次操作没有完成', // ui-copy-ignore
14
+ partial: '部分内容未能恢复,可以再按一次', // ui-copy-ignore
15
+ stopped: '上次暂停的内容还没有恢复', // ui-copy-ignore
16
+ attention: '有内容需要你处理',
17
+ }
18
+ : {
19
+ pause: 'Pause', // ui-copy-ignore
20
+ resume: 'Resume', // ui-copy-ignore
21
+ busy: 'Working', // ui-copy-ignore
22
+ error: 'The last action did not finish',
23
+ partial: 'Some items were not restored; press again to retry',
24
+ stopped: 'Work is still stopped',
25
+ attention: 'Some items need a look',
26
+ }
27
+
28
+ const HOST_BASE = '/dsh-pause-resume' // ui-copy-ignore
29
+ const POLL_MS = 2000 // ui-copy-ignore
30
+ const REQUEST_MS = 30000 // ui-copy-ignore
31
+
32
+ /**
33
+ * The glyph is the whole status, so it may not be guessed from anything the page can see: the
34
+ * Host owns the phase and this asks for it. Until an answer arrives the button renders the // ui-copy-ignore
35
+ * settled state (`none`), never a spinner, so a slow Host cannot look like a running press. // ui-copy-ignore
36
+ */
37
+ const EMPTY = {
38
+ phase: 'idle', // ui-copy-ignore
39
+ action: 'none', // ui-copy-ignore
40
+ source: null, // ui-copy-ignore
41
+ complete: null, // ui-copy-ignore
42
+ outstanding: 0,
43
+ counts: { sessions: 0, goals: 0, jobs: 0 },
44
+ live: { sessions: 0, goals: 0, jobs: 0 },
45
+ attention: 0,
46
+ error: null, // ui-copy-ignore
47
+ }
48
+
49
+ function withTimeout(promise, ms = REQUEST_MS) {
50
+ let id = null
51
+ const timer = new Promise((_resolve, reject) => {
52
+ id = setTimeout(() => reject(new Error('timeout')), ms) // ui-copy-ignore
53
+ })
54
+ return Promise.race([promise, timer]).finally(() => clearTimeout(id))
55
+ }
56
+
57
+ async function readState(signal) {
58
+ try {
59
+ const response = await withTimeout(fetch(`${HOST_BASE}/state`, { cache: 'no-store', credentials: 'same-origin', signal })) // ui-copy-ignore
60
+ if (!response.ok) return null
61
+ const data = await withTimeout(response.json())
62
+ return data !== null && typeof data === 'object' ? data : null
63
+ } catch {
64
+ return null
65
+ }
66
+ }
67
+
68
+ async function press(action, signal) {
69
+ try {
70
+ const response = await withTimeout(fetch(`${HOST_BASE}/${action}`, {
71
+ method: 'POST', // ui-copy-ignore
72
+ credentials: 'same-origin',
73
+ headers: { 'content-type': 'application/json', 'x-dsh-pause-resume': '1' }, // ui-copy-ignore
74
+ body: '{}', // ui-copy-ignore
75
+ signal,
76
+ }))
77
+ if (!response.ok) return null
78
+ const data = await withTimeout(response.json())
79
+ return data !== null && typeof data === 'object' ? data : null
80
+ } catch {
81
+ return null
82
+ }
83
+ }
84
+
85
+ // One style element for the whole page: the in-place spinner is the only motion, and it is
86
+ // defined once instead of on every render.
87
+ const STYLE_ID = 'dsh-pause-resume-style' // ui-copy-ignore
88
+ function ensureStyle() {
89
+ try {
90
+ if (document.getElementById(STYLE_ID) !== null) return
91
+ const style = document.createElement('style')
92
+ style.id = STYLE_ID
93
+ style.textContent = '@keyframes dsh-pr-spin{to{transform:rotate(360deg)}}'
94
+ document.head.appendChild(style)
95
+ } catch {}
96
+ }
97
+
98
+ const BUTTON_STYLE = {
99
+ display: 'inline-flex', // ui-copy-ignore
100
+ alignItems: 'center',
101
+ justifyContent: 'center',
102
+ width: 28, // ui-copy-ignore
103
+ height: 28, // ui-copy-ignore
104
+ padding: 0, // ui-copy-ignore
105
+ border: 'none', // ui-copy-ignore
106
+ borderRadius: 6,
107
+ background: 'transparent',
108
+ color: 'inherit', // ui-copy-ignore
109
+ cursor: 'pointer', // ui-copy-ignore
110
+ opacity: 0.75, // ui-copy-ignore
111
+ position: 'relative', // ui-copy-ignore
112
+ }
113
+
114
+ const DOT_STYLE = {
115
+ position: 'absolute', // ui-copy-ignore
116
+ top: 4, // ui-copy-ignore
117
+ right: 4, // ui-copy-ignore
118
+ width: 5, // ui-copy-ignore
119
+ height: 5, // ui-copy-ignore
120
+ borderRadius: '50%',
121
+ background: 'var(--dsh-color-danger, #d1242f)',
122
+ }
123
+
124
+ const SPIN_STYLE = { display: 'inline-flex', animation: 'dsh-pr-spin 900ms linear infinite' } // ui-copy-ignore
125
+
126
+ function icon(paths, extra) {
127
+ return h('svg', {
128
+ width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none',
129
+ stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round',
130
+ 'aria-hidden': true, style: extra,
131
+ }, paths.map((d) => h('path', { key: d, d })))
132
+ }
133
+
134
+ const PAUSE_GLYPH = icon(['M6 3.5v9', 'M10 3.5v9'])
135
+ const PLAY_GLYPH = h('svg', {
136
+ width: 16, height: 16, viewBox: '0 0 16 16', fill: 'currentColor', 'aria-hidden': true,
137
+ }, 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' }))
138
+ const SPIN_GLYPH = icon(['M8 2.5a5.5 5.5 0 1 1-4.3 2.06'], SPIN_STYLE)
139
+
140
+ /** The one sentence behind the dot, in the tooltip only: the button itself stays a glyph. */
141
+ function hintOf(state) {
142
+ if (state.error === 'restore-partial') return COPY.partial
143
+ if (state.error !== null && state.error !== undefined) return COPY.error
144
+ if ((state.attention ?? 0) > 0) return COPY.attention
145
+ if ((state.outstanding ?? 0) > 0) return COPY.stopped
146
+ return ''
147
+ }
148
+
149
+ function Button() {
150
+ const [state, setState] = React.useState(EMPTY)
151
+ const [hover, setHover] = React.useState(false)
152
+ const [pressing, setPressing] = React.useState(false)
153
+ const mounted = React.useRef(true)
154
+ /** Each answer carries a number: a slow reply may never overwrite a newer one. */
155
+ const seq = React.useRef(0)
156
+ const inflight = React.useRef(null)
157
+
158
+ const apply = React.useCallback((next) => {
159
+ if (!mounted.current || next === null) return
160
+ seq.current += 1
161
+ setState({ ...EMPTY, ...next })
162
+ }, [])
163
+
164
+ const pull = React.useCallback(async (signal) => {
165
+ const mine = seq.current
166
+ const next = await readState(signal)
167
+ if (!mounted.current || seq.current !== mine) return
168
+ if (next !== null) apply(next)
169
+ }, [apply])
170
+
171
+ React.useEffect(() => {
172
+ ensureStyle()
173
+ mounted.current = true
174
+ let timer = null
175
+ let current = null
176
+ const beat = () => {
177
+ if (document.visibilityState !== 'visible' || inflight.current !== null) return
178
+ try {
179
+ current?.abort()
180
+ } catch {}
181
+ current = typeof AbortController === 'function' ? new AbortController() : null
182
+ void pull(current?.signal)
183
+ }
184
+ beat()
185
+ timer = setInterval(beat, POLL_MS)
186
+ const onVisible = () => {
187
+ if (document.visibilityState === 'visible') beat()
188
+ }
189
+ document.addEventListener('visibilitychange', onVisible)
190
+ return () => {
191
+ mounted.current = false
192
+ if (timer !== null) clearInterval(timer)
193
+ try {
194
+ current?.abort()
195
+ } catch {}
196
+ document.removeEventListener('visibilitychange', onVisible)
197
+ }
198
+ }, [pull])
199
+
200
+ const busy = pressing || state.action === 'busy'
201
+ const action = state.action === 'pause' || state.action === 'resume' ? state.action : null
202
+ const disabled = action === null && !busy
203
+
204
+ const onClick = async () => {
205
+ if (busy || action === null || inflight.current !== null) return
206
+ // One press at a time, in this tab and across tabs: the Host also refuses an overlapping // ui-copy-ignore
207
+ // press, so a second window's button settles on the same answer instead of starting work.
208
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
209
+ inflight.current = controller
210
+ setPressing(true)
211
+ try {
212
+ const answer = await press(action, controller?.signal)
213
+ const next = answer ?? (await readState())
214
+ if (mounted.current) {
215
+ if (next !== null) {
216
+ seq.current += 1
217
+ setState({ ...EMPTY, ...next })
218
+ } else {
219
+ // The press may still have landed: ask again rather than guessing the phase.
220
+ void pull(controller?.signal)
221
+ }
222
+ }
223
+ } finally {
224
+ inflight.current = null
225
+ if (mounted.current) setPressing(false)
226
+ }
227
+ }
228
+
229
+ const style = busy || disabled
230
+ ? { ...BUTTON_STYLE, opacity: 0.4 }
231
+ : hover
232
+ ? { ...BUTTON_STYLE, opacity: 1, background: 'var(--dsh-color-surface-hover, rgba(127,127,127,0.14))' }
233
+ : BUTTON_STYLE
234
+
235
+ const glyph = busy ? SPIN_GLYPH : action === 'resume' ? PLAY_GLYPH : PAUSE_GLYPH
236
+ const label = busy ? COPY.busy : action === 'resume' ? COPY.resume : COPY.pause
237
+ const hint = hintOf(state)
238
+
239
+ return h('button', {
240
+ type: 'button', // ui-copy-ignore
241
+ style,
242
+ onClick,
243
+ disabled,
244
+ 'aria-label': label,
245
+ 'aria-busy': busy,
246
+ title: hint === '' ? label : `${label} · ${hint}`,
247
+ onMouseEnter: () => setHover(true),
248
+ onMouseLeave: () => setHover(false),
249
+ }, glyph, hint === '' ? null : h('span', { style: DOT_STYLE, 'aria-hidden': true }))
250
+ }
251
+
252
+ return {
253
+ inject: ['slots'], // ui-copy-ignore
254
+ apply(ctx) {
255
+ ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
256
+ name: 'conversation.session.header.utilities',
257
+ id: 'dsh-pause-resume', // ui-copy-ignore
258
+ order: 31, // ui-copy-ignore
259
+ label: () => COPY.pause, // ui-copy-ignore
260
+ }, Button))
261
+ },
262
+ }
263
+ },
264
+ })
@@ -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>