dsh-pause-resume 0.1.0 → 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/README.md CHANGED
@@ -1,34 +1,62 @@
1
1
  # dsh-pause-resume
2
2
 
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.
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
5
 
6
6
  * ⏸ — work is running. Press to stop it.
7
- * ▶ — work is stopped, or a previous run ended mid-turn. Press to restore it.
7
+ * ▶ — work is stopped, or a previous process died mid-turn. Press to restore it.
8
8
  * ⏸ dimmed — nothing to stop and nothing to restore.
9
9
 
10
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.
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.
12
13
 
13
14
  ## What it restores
14
15
 
15
16
  | Work | Pause | Restore |
16
17
  | --- | --- | --- |
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 |
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 |
19
21
  | 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
+ | 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 |
22
25
 
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.
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.
25
46
 
26
47
  ## Crash, not just a press
27
48
 
28
49
  A press writes a snapshot. A crash writes nothing, so the plugin keeps a heartbeat instead:
29
50
  `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.
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.
32
60
 
33
61
  ## Installing
34
62
 
@@ -37,24 +65,25 @@ plugin_manager install_bundle dsh-pause-resume
37
65
  ```
38
66
 
39
67
  or install the bundle from the Harness plugin manager with the package name. The plugin needs no
40
- configuration and holds no credentials.
68
+ configuration and holds no credentials. `allowRemote` exists for a Harness reached from another
69
+ machine; local-only is the default.
41
70
 
42
71
  ## Guarantees and limits
43
72
 
44
73
  * **Exclusive.** The phase lives on the Host and is written before any work starts, so two windows,
45
74
  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.
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.
58
87
 
59
88
  ## Files
60
89
 
@@ -62,28 +91,31 @@ All state lives under `$DSH_HOME/pause-resume/`:
62
91
 
63
92
  | File | Purpose |
64
93
  | --- | --- |
65
- | `snapshot.json` | the work a press stopped |
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 |
66
98
  | `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
99
 
70
- Every write is temp file + `fsync` + `rename`. State is never inside the package.
100
+ Every write is a unique temp file, `fsync`, `rename`, then a directory `fsync`. State is never inside
101
+ the package.
71
102
 
72
103
  ## Development
73
104
 
74
105
  ```bash
75
- node --test --test-timeout=15000 'test/*.test.mjs'
106
+ npm test
107
+ npm run audit
76
108
  ```
77
109
 
78
110
  `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.
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.
82
114
 
83
115
  MIT.
84
116
 
85
117
  ---
86
118
 
87
119
  一行说明:会话头一个按钮,⏸ 停掉正在跑的回合、目标与后台任务;重启后同一个按钮变 ▶,把上次的工作
88
- 接回来。崩溃也能识别:靠心跳文件,不靠人按过按钮。日程不会被动(该服务只有删除,没有停用),
89
- 后台命令只记录不重跑。
120
+ 接回来。正常结束的会话不记录、不恢复;崩溃靠心跳文件识别,但只挂载不代发消息;日程不会被动
121
+ (该服务只有删除,没有停用),后台命令只记录不重跑。
package/client.js CHANGED
@@ -1,13 +1,29 @@
1
1
  window.__ModuleLoader__.load({
2
- id: 'dsh-pause-resume',
2
+ id: 'dsh-pause-resume', // ui-copy-ignore
3
3
  factory(require) {
4
4
  const React = require('react')
5
5
  const h = React.createElement
6
6
 
7
7
  const zh = /^zh/i.test(navigator.language || '')
8
8
  const COPY = zh
9
- ? { pause: '暂停', resume: '继续', busy: '正在处理' }
10
- : { pause: 'Pause', resume: 'Resume', busy: 'Working' }
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
+ }
11
27
 
12
28
  const HOST_BASE = '/dsh-pause-resume' // ui-copy-ignore
13
29
  const POLL_MS = 2000 // ui-copy-ignore
@@ -18,7 +34,17 @@ window.__ModuleLoader__.load({
18
34
  * Host owns the phase and this asks for it. Until an answer arrives the button renders the // ui-copy-ignore
19
35
  * settled state (`none`), never a spinner, so a slow Host cannot look like a running press. // ui-copy-ignore
20
36
  */
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 }
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
+ }
22
48
 
23
49
  function withTimeout(promise, ms = REQUEST_MS) {
24
50
  let id = null
@@ -28,9 +54,9 @@ window.__ModuleLoader__.load({
28
54
  return Promise.race([promise, timer]).finally(() => clearTimeout(id))
29
55
  }
30
56
 
31
- async function readState() {
57
+ async function readState(signal) {
32
58
  try {
33
- const response = await withTimeout(fetch(`${HOST_BASE}/state`, { cache: 'no-store', credentials: 'same-origin' }))
59
+ const response = await withTimeout(fetch(`${HOST_BASE}/state`, { cache: 'no-store', credentials: 'same-origin', signal })) // ui-copy-ignore
34
60
  if (!response.ok) return null
35
61
  const data = await withTimeout(response.json())
36
62
  return data !== null && typeof data === 'object' ? data : null
@@ -39,13 +65,14 @@ window.__ModuleLoader__.load({
39
65
  }
40
66
  }
41
67
 
42
- async function press(action) {
68
+ async function press(action, signal) {
43
69
  try {
44
70
  const response = await withTimeout(fetch(`${HOST_BASE}/${action}`, {
45
71
  method: 'POST', // ui-copy-ignore
46
72
  credentials: 'same-origin',
47
73
  headers: { 'content-type': 'application/json', 'x-dsh-pause-resume': '1' }, // ui-copy-ignore
48
74
  body: '{}', // ui-copy-ignore
75
+ signal,
49
76
  }))
50
77
  if (!response.ok) return null
51
78
  const data = await withTimeout(response.json())
@@ -110,49 +137,93 @@ window.__ModuleLoader__.load({
110
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' }))
111
138
  const SPIN_GLYPH = icon(['M8 2.5a5.5 5.5 0 1 1-4.3 2.06'], SPIN_STYLE)
112
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
+
113
149
  function Button() {
114
150
  const [state, setState] = React.useState(EMPTY)
115
151
  const [hover, setHover] = React.useState(false)
152
+ const [pressing, setPressing] = React.useState(false)
116
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])
117
170
 
118
171
  React.useEffect(() => {
119
172
  ensureStyle()
120
173
  mounted.current = true
121
174
  let timer = null
122
- const pull = async () => {
123
- const next = await readState()
124
- if (!mounted.current) return
125
- if (next !== null) setState({ ...EMPTY, ...next })
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)
126
183
  }
127
- pull()
128
- timer = setInterval(() => {
129
- if (document.visibilityState === 'visible') pull()
130
- }, POLL_MS)
184
+ beat()
185
+ timer = setInterval(beat, POLL_MS)
131
186
  const onVisible = () => {
132
- if (document.visibilityState === 'visible') pull()
187
+ if (document.visibilityState === 'visible') beat()
133
188
  }
134
189
  document.addEventListener('visibilitychange', onVisible)
135
190
  return () => {
136
191
  mounted.current = false
137
192
  if (timer !== null) clearInterval(timer)
193
+ try {
194
+ current?.abort()
195
+ } catch {}
138
196
  document.removeEventListener('visibilitychange', onVisible)
139
197
  }
140
- }, [])
198
+ }, [pull])
141
199
 
142
- const busy = state.action === 'busy'
200
+ const busy = pressing || state.action === 'busy'
143
201
  const action = state.action === 'pause' || state.action === 'resume' ? state.action : null
144
202
  const disabled = action === null && !busy
145
203
 
146
204
  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 }))
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
+ }
156
227
  }
157
228
 
158
229
  const style = busy || disabled
@@ -163,6 +234,7 @@ window.__ModuleLoader__.load({
163
234
 
164
235
  const glyph = busy ? SPIN_GLYPH : action === 'resume' ? PLAY_GLYPH : PAUSE_GLYPH
165
236
  const label = busy ? COPY.busy : action === 'resume' ? COPY.resume : COPY.pause
237
+ const hint = hintOf(state)
166
238
 
167
239
  return h('button', {
168
240
  type: 'button', // ui-copy-ignore
@@ -171,9 +243,10 @@ window.__ModuleLoader__.load({
171
243
  disabled,
172
244
  'aria-label': label,
173
245
  'aria-busy': busy,
246
+ title: hint === '' ? label : `${label} · ${hint}`,
174
247
  onMouseEnter: () => setHover(true),
175
248
  onMouseLeave: () => setHover(false),
176
- }, glyph, state.error === null || state.error === undefined ? null : h('span', { style: DOT_STYLE, 'aria-hidden': true }))
249
+ }, glyph, hint === '' ? null : h('span', { style: DOT_STYLE, 'aria-hidden': true }))
177
250
  }
178
251
 
179
252
  return {
package/index.js CHANGED
@@ -6,11 +6,12 @@ import { createHeartbeat } from './lib/heartbeat.js'
6
6
  import { createProbe, listRunning } from './lib/probe.js'
7
7
 
8
8
  export const name = 'dsh-pause-resume'
9
- export const VERSION = '0.1.0'
9
+ export const VERSION = '0.2.0'
10
10
 
11
11
  const BASE_PATH = '/dsh-pause-resume'
12
12
  const BUTTON_HEADER = 'x-dsh-pause-resume'
13
13
  const BOOT_KEY = Symbol.for('dsh-pause-resume.boot')
14
+ const LIVENESS_MS = 400
14
15
 
15
16
  /** A page this machine may have opened. A public name that resolves here is the rebinding case. */
16
17
  const LOCAL_ADDRESS =
@@ -38,7 +39,6 @@ function bootId() {
38
39
  return created
39
40
  }
40
41
 
41
-
42
42
  function service(ctx, key) {
43
43
  try {
44
44
  return ctx.get(key)
@@ -49,8 +49,8 @@ function service(ctx, key) {
49
49
 
50
50
  function sendJson(res, status, value) {
51
51
  try {
52
- const body = JSON.stringify(value ?? { status: 'error' })
53
52
  if (res?.headersSent === true) return res.end()
53
+ const body = JSON.stringify(value ?? { status: 'error' })
54
54
  res.writeHead(status, {
55
55
  'content-type': 'application/json; charset=utf-8',
56
56
  'cache-control': 'no-store',
@@ -66,13 +66,18 @@ function sendJson(res, status, value) {
66
66
  }
67
67
  }
68
68
 
69
- /** Whether one request may act for the button: a header a cross-site form cannot set, same origin. */
70
- function buttonAllowed(req) {
69
+ /**
70
+ * Whether one request may act for the button: a header a cross-site form cannot set, and the same
71
+ * origin that served this page. `allowRemote` widens the address check, never the header or origin
72
+ * check, so a hostile page cannot press the button even on a trusted network.
73
+ */
74
+ function buttonAllowed(req, allowRemote) {
71
75
  try {
72
76
  const header = req?.headers?.[BUTTON_HEADER]
73
77
  if (typeof header !== 'string' || header === '') return false
74
78
  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
79
+ if (site === '') return false
80
+ if (allowRemote !== true && LOCAL_ADDRESS.test(site) === false) return false
76
81
  const origin = typeof req?.headers?.origin === 'string' ? req.headers.origin.trim().toLowerCase() : ''
77
82
  if (origin === 'null') return false
78
83
  if (origin !== '') {
@@ -112,50 +117,53 @@ function guard(handler) {
112
117
  }
113
118
  }
114
119
 
115
- function serve(ctx, engine, heartbeat) {
120
+ function serve(ctx, engine, heartbeat, options) {
116
121
  const server = service(ctx, 'webServer')
117
122
  if (server === undefined || server === null || typeof server.register !== 'function') {
118
123
  log('no web server: the header button cannot ask for a pause')
119
124
  return () => {}
120
125
  }
126
+ const allowRemote = options.allowRemote === true
127
+ const route = (handler, read) => guard(async (req, res) => {
128
+ const method = String(req?.method ?? 'GET').toUpperCase()
129
+ if (read === true ? method !== 'GET' && method !== 'HEAD' : method !== 'POST') {
130
+ return sendJson(res, 405, { status: 'method' })
131
+ }
132
+ // A read still refuses another origin, but it does not need the button header.
133
+ if (read === true ? sameOrigin(req, allowRemote) === false : buttonAllowed(req, allowRemote) === false) {
134
+ return sendJson(res, 403, { status: 'forbidden' })
135
+ }
136
+ return handler(req, res)
137
+ })
121
138
  const handlers = [
122
139
  [
123
140
  `${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
- },
141
+ route(async (req, res) => {
142
+ sendJson(res, 200, engine.describe())
143
+ }, true),
129
144
  ],
130
145
  [
131
146
  `${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' })
147
+ route(async (req, res) => {
136
148
  const outcome = await engine.pause()
137
149
  log(`pause: ${outcome.status}${outcome.status === 'busy' ? ` (${outcome.phase})` : ''}`)
138
150
  if (outcome.status === 'paused') heartbeat.release()
139
- sendJson(res, 200, { ...(await engine.describe()), outcome })
140
- },
151
+ sendJson(res, 200, { ...engine.describe(), outcome })
152
+ }, false),
141
153
  ],
142
154
  [
143
155
  `${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' })
156
+ route(async (req, res) => {
148
157
  const outcome = await engine.resume()
149
158
  log(`resume: ${outcome.status}`)
150
- if (outcome.status === 'resumed') heartbeat.release()
151
- sendJson(res, 200, { ...(await engine.describe()), outcome })
152
- },
159
+ sendJson(res, 200, { ...engine.describe(), outcome })
160
+ }, false),
153
161
  ],
154
162
  ]
155
163
  const disposers = []
156
164
  for (const [path, handler] of handlers) {
157
165
  try {
158
- const dispose = server.register({ kind: 'exact', path, handler: guard(handler) })
166
+ const dispose = server.register({ kind: 'exact', path, handler })
159
167
  if (typeof dispose === 'function') disposers.push(dispose)
160
168
  } catch (error) {
161
169
  log(`cannot answer ${path}: ${error?.message ?? error}`)
@@ -170,39 +178,102 @@ function serve(ctx, engine, heartbeat) {
170
178
  }
171
179
  }
172
180
 
181
+ /** Same-origin read: a page on another host may not learn what is running here. */ // ui-copy-ignore
182
+ function sameOrigin(req, allowRemote) {
183
+ try {
184
+ const site = typeof req?.headers?.host === 'string' ? req.headers.host.trim().toLowerCase() : '' // ui-copy-ignore
185
+ if (site === '') return false
186
+ if (allowRemote !== true && LOCAL_ADDRESS.test(site) === false) return false
187
+ const origin = typeof req?.headers?.origin === 'string' ? req.headers.origin.trim().toLowerCase() : ''
188
+ if (origin === 'null') return false
189
+ if (origin === '') return true
190
+ try {
191
+ return new URL(origin).host.toLowerCase() === site // ui-copy-ignore
192
+ } catch {
193
+ return false
194
+ }
195
+ } catch {
196
+ return false
197
+ }
198
+ }
199
+
173
200
  export function apply(ctx, config = {}) {
174
201
  const dir = dataDir()
175
- const store = createStore(dir)
202
+ const allowRemote = config?.allowRemote === true
203
+ const store = createStore(dir, { log })
176
204
  const heartbeat = createHeartbeat({
177
205
  dir,
178
206
  bootId: bootId(),
207
+ intervalMs: Number.isFinite(config?.intervalMs) ? config.intervalMs : 5000,
179
208
  listRunning: () => listRunning(ctx),
180
209
  log,
181
210
  })
211
+ const probe = createProbe(ctx, {
212
+ graceMs: Number.isFinite(config?.graceMs) ? config.graceMs : 2000,
213
+ continueText: typeof config?.continueText === 'string' ? config.continueText : undefined,
214
+ })
182
215
  const engine = createEngine({
183
216
  store,
184
- probe: createProbe(ctx, heartbeat),
217
+ probe,
218
+ heartbeat,
185
219
  graceMs: Number.isFinite(config?.graceMs) ? config.graceMs : 2000,
186
- gapMs: Number.isFinite(config?.gapMs) ? config.gapMs : 150,
187
220
  log,
188
221
  })
189
222
 
223
+ let disposed = false
190
224
  let disposeRoutes = () => {}
191
225
  const cleanup = () => {
226
+ disposed = true
192
227
  try {
193
228
  disposeRoutes()
194
229
  } catch {}
230
+ // A reload is not a crash: keep the heartbeat's last write as evidence, but let go of timers.
195
231
  heartbeat.stop()
196
232
  }
197
233
 
198
234
  if (typeof ctx.effect === 'function') ctx.effect(() => cleanup)
199
235
  else if (typeof ctx.on === 'function') ctx.on('dispose', cleanup)
200
236
 
237
+ // Turn edges are what a crash loses first, so the heartbeat follows them instead of waiting for its
238
+ // own interval. The interval stays as the fallback for a registry that changes shape silently.
239
+ let liveness = null
240
+ const noteRunning = () => {
241
+ if (disposed) return
242
+ try {
243
+ heartbeat.note(listRunning(ctx))
244
+ } catch (error) {
245
+ log(`cannot note running work: ${error?.message ?? error}`)
246
+ }
247
+ }
248
+ const watchStatus = () => {
249
+ if (liveness !== null) clearTimeout(liveness)
250
+ liveness = setTimeout(() => {
251
+ liveness = null
252
+ noteRunning()
253
+ }, LIVENESS_MS)
254
+ if (typeof liveness?.unref === 'function') liveness.unref()
255
+ }
256
+ try {
257
+ if (typeof ctx.on === 'function') {
258
+ ctx.on('agent/status', watchStatus)
259
+ ctx.on('agent/dispose', watchStatus)
260
+ ctx.on('agent/register', watchStatus)
261
+ }
262
+ } catch (error) {
263
+ log(`cannot follow agent events: ${error?.message ?? error}`)
264
+ }
265
+
201
266
  void (async () => {
202
267
  try {
203
- await engine.hydrate()
268
+ // The heartbeat claims the previous process's record in `start`, and the engine's crash plan
269
+ // is derived from that claim: start first, or the residue is read as absent and lost.
204
270
  heartbeat.start()
205
- disposeRoutes = serve(ctx, engine, heartbeat)
271
+ if (disposed) return
272
+ await engine.hydrate()
273
+ if (disposed) return
274
+ const dispose = serve(ctx, engine, heartbeat, { allowRemote })
275
+ if (disposed) dispose()
276
+ else disposeRoutes = dispose
206
277
  log(`ready (${engine.phase})`)
207
278
  } catch (error) {
208
279
  log(`cannot start: ${error?.message ?? error}`)