@ray1270/dsh-restart 0.0.0-stage → 0.3.7

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/lib/index.js ADDED
@@ -0,0 +1,660 @@
1
+ /**
2
+ * dsh-restart — Host half.
3
+ *
4
+ * The Host runs as a child of the Electron shell, inside the application's own
5
+ * process tree. That tree does not survive the shell: whatever the Host starts
6
+ * dies when the application is terminated, so a helper spawned the ordinary way
7
+ * is killed at the exact moment it is needed — it performs the kill and is
8
+ * buried with the app before it can relaunch it. The helper is therefore
9
+ * created by the Windows WMI service instead ({@link ./helper.ps1} runs as a
10
+ * child of WmiPrvSE, outside the tree), which is what lets it outlive the kill.
11
+ *
12
+ * The division of labour:
13
+ *
14
+ * Host half pre-flight (active work), session hand-off, WMI launch + verify
15
+ * helper.ps1 sleep, kill the shell, wait for the Host, relaunch, report
16
+ * client half the header button, the two-click confirmation, session restore
17
+ *
18
+ * Routes (all under the same origin as the GUI):
19
+ *
20
+ * GET /api/dsh-restart/status readiness, PIDs, active work, last run
21
+ * POST /api/dsh-restart/restart schedule a restart ({ sessionId, force })
22
+ * GET /api/dsh-restart/pending the session to reopen after a restart
23
+ * POST /api/dsh-restart/pending/consume take that session exactly once
24
+ *
25
+ * @module dsh-restart
26
+ */
27
+
28
+ import { spawn } from 'node:child_process'
29
+ import { existsSync } from 'node:fs'
30
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
31
+ import { homedir } from 'node:os'
32
+ import { dirname, join } from 'node:path'
33
+ import { fileURLToPath } from 'node:url'
34
+
35
+ export const name = 'dsh-restart'
36
+
37
+ /** The routes need the Host web server; everything else is a soft dependency. */
38
+ export const inject = ['webServer']
39
+
40
+ const API_BASE = '/api/dsh-restart'
41
+
42
+ /** Marker left by a restart and consumed by the next page load. */
43
+ const PENDING_FILE = 'pending-restore.json'
44
+
45
+ /** Machine-readable outcome written by the helper. */
46
+ const STATE_FILE = 'restart-state.json'
47
+
48
+ /** Helper log; kept beside the state so a failure is explainable. */
49
+ const LOG_FILE = 'helper.log'
50
+
51
+ /** Outcome of the WMI create call, written by the short-lived launcher. */
52
+ const LAUNCH_FILE = 'launch-result.json'
53
+
54
+ /** Present when the Host gave up on a restart the helper has not killed for yet. */
55
+ const CANCEL_FILE = 'cancel-restart'
56
+
57
+ /**
58
+ * How long the Host waits for the helper's first state write before giving up.
59
+ *
60
+ * Much longer than a direct spawn needs: the process is created by the WMI
61
+ * service, so a provider lookup, the create call and the helper's own startup
62
+ * all happen before `boot` is written.
63
+ */
64
+ const HELPER_BOOT_TIMEOUT_MS = 15000
65
+
66
+ const DEFAULTS = Object.freeze({
67
+ /** Pause before the helper kills the shell, so the HTTP reply flushes first. */
68
+ delayMs: 1200,
69
+ /** Grace for the Host's own bounded graceful shutdown before it is force-killed. */
70
+ hostGraceMs: 8000,
71
+ /** How long a pending session marker stays believable. */
72
+ pendingTtlMinutes: 30,
73
+ /** Master switch; false hides the button and refuses the route. */
74
+ enabled: true,
75
+ /** Hard ceiling on the helper's wait for both processes to disappear. */
76
+ killTimeoutMs: 15000,
77
+ })
78
+
79
+ function readOptions(config) {
80
+ const source = config ?? {}
81
+ const number = (key, min, max) => {
82
+ const value = source[key]
83
+ return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max
84
+ ? value
85
+ : DEFAULTS[key]
86
+ }
87
+ return {
88
+ delayMs: number('delayMs', 0, 10000),
89
+ hostGraceMs: number('hostGraceMs', 1000, 60000),
90
+ pendingTtlMinutes: number('pendingTtlMinutes', 1, 24 * 60),
91
+ enabled: source.enabled === undefined ? DEFAULTS.enabled : source.enabled === true,
92
+ killTimeoutMs: number('killTimeoutMs', 1000, 120000),
93
+ }
94
+ }
95
+
96
+ /** Directory holding the marker, the state and the log. */
97
+ function resolveStateDir() {
98
+ const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim().length > 0
99
+ ? process.env.DSH_HOME.trim()
100
+ : join(homedir(), '.dsh')
101
+ return join(home, 'dsh-restart')
102
+ }
103
+
104
+ /**
105
+ * Whether this Host runs under the desktop shell.
106
+ *
107
+ * The shell spawns the Host as the Electron binary in Node mode, so both facts
108
+ * must hold: the binary is Electron, and Node mode is on. A CLI Host reads files
109
+ * from the same Home but has no shell to restart.
110
+ */
111
+ function detectDesktop() {
112
+ const electron = process.versions?.electron
113
+ if (typeof electron !== 'string' || electron.length === 0) {
114
+ return { desktop: false, reason: 'not running under the Electron shell (CLI or headless Host)' }
115
+ }
116
+ if (process.env.ELECTRON_RUN_AS_NODE !== '1') {
117
+ return { desktop: false, reason: 'Electron binary is not in Node mode, so this is not a Host process' }
118
+ }
119
+ if (!Number.isInteger(process.ppid) || process.ppid <= 0) {
120
+ return { desktop: false, reason: 'the Host has no parent process to restart' }
121
+ }
122
+ return { desktop: true, reason: null }
123
+ }
124
+
125
+ function sendJson(res, status, payload) {
126
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
127
+ res.end(JSON.stringify(payload))
128
+ }
129
+
130
+ /**
131
+ * Same-origin guard for the destructive endpoint: browsers always send Origin on
132
+ * a cross-origin POST, so a mismatch is a drive-by request. Non-browser clients
133
+ * that send no Origin are the user's own tooling.
134
+ */
135
+ function rejectCrossOrigin(req, res) {
136
+ const origin = req.headers?.origin
137
+ if (!origin) return false
138
+ const host = String(req.headers?.host ?? '')
139
+ let sameOrigin = false
140
+ try {
141
+ sameOrigin = host.length > 0 && new URL(origin).host === host.toLowerCase()
142
+ } catch {
143
+ sameOrigin = false
144
+ }
145
+ if (sameOrigin) return false
146
+ sendJson(res, 403, { error: 'cross-origin request refused' })
147
+ return true
148
+ }
149
+
150
+ function readBody(req, limit = 64 * 1024) {
151
+ return new Promise((resolve, reject) => {
152
+ const chunks = []
153
+ let size = 0
154
+ req.on('data', (chunk) => {
155
+ size += chunk.length
156
+ if (size > limit) {
157
+ reject(new Error('request body too large'))
158
+ req.destroy()
159
+ return
160
+ }
161
+ chunks.push(chunk)
162
+ })
163
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
164
+ req.on('error', reject)
165
+ })
166
+ }
167
+
168
+ async function readJson(file) {
169
+ try {
170
+ const text = await readFile(file, 'utf8')
171
+ // PowerShell 5.1 helpers may leave a BOM behind; JSON.parse would choke on it.
172
+ return JSON.parse(text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text)
173
+ } catch {
174
+ return null
175
+ }
176
+ }
177
+
178
+ async function writeJson(file, value) {
179
+ await writeFile(file, JSON.stringify(value, null, 2), 'utf8')
180
+ }
181
+
182
+ /**
183
+ * Quote one argument for a Windows command line.
184
+ *
185
+ * The only parser that sees this string is `CommandLineToArgvW` in the helper's
186
+ * process — no shell is involved, because WMI calls CreateProcess directly.
187
+ * Double quotes plus backslash-escaped embedded quotes is therefore the whole
188
+ * contract, and it is enough for the paths this plugin passes.
189
+ */
190
+ export function quoteArgument(value) {
191
+ return `"${String(value).replaceAll('"', '\\"')}"`
192
+ }
193
+
194
+ /**
195
+ * The helper invocation the WMI service replays verbatim.
196
+ *
197
+ * Pure on purpose: the quoting is the fragile part of this hand-off, and a
198
+ * wrong backslash here is invisible until the one restart that matters.
199
+ */
200
+ export function buildHelperCommandLine({
201
+ powershell,
202
+ helperPath,
203
+ shellPid,
204
+ hostPid,
205
+ shellPath,
206
+ stateDir,
207
+ delayMs,
208
+ hostGraceMs,
209
+ killTimeoutMs,
210
+ }) {
211
+ return [
212
+ quoteArgument(powershell),
213
+ '-NoProfile',
214
+ '-NonInteractive',
215
+ '-WindowStyle', 'Hidden',
216
+ '-ExecutionPolicy', 'Bypass',
217
+ '-File', quoteArgument(helperPath),
218
+ '-ShellPid', String(shellPid),
219
+ '-HostPid', String(hostPid),
220
+ '-ShellPath', quoteArgument(shellPath),
221
+ '-StateDir', quoteArgument(stateDir),
222
+ '-DelayMs', String(delayMs),
223
+ '-HostGraceMs', String(hostGraceMs),
224
+ '-KillTimeoutMs', String(killTimeoutMs),
225
+ ].join(' ')
226
+ }
227
+
228
+ /**
229
+ * The PowerShell program that asks WMI to start the helper.
230
+ *
231
+ * Both variable strings arrive base64-encoded so nothing in a path (quotes,
232
+ * spaces, non-ASCII characters) can break the script. The result is written to
233
+ * a JSON file rather than stdout: the launcher is spawned with ignored stdio so
234
+ * it can never hold the Host's pipes open, and a failed create must still be
235
+ * explainable after the fact.
236
+ *
237
+ * The `Win32_ProcessStartup` record is not decoration. A bare
238
+ * `Win32_Process.Create` calls CreateProcess with no startup information, so a
239
+ * console program — the helper is powershell.exe — is given a fresh, visible
240
+ * console window the moment it exists. Measured on this machine: a helper
241
+ * created that way kept a console window on screen for its whole run (a black
242
+ * box appears, the app closes a second later). `-WindowStyle Hidden` does not
243
+ * prevent it; that switch hides a window after the console has already been
244
+ * shown, which is why it was invisible in tests and obvious to the user. Passing
245
+ * SW_HIDE through the startup record is what creates the process hidden in the
246
+ * first place.
247
+ *
248
+ * The result file records whether that record could be built (`windowHidden`),
249
+ * so a machine where it fails leaves a trace instead of a mystery window.
250
+ */
251
+ export function buildWmiLauncherScript(commandLine, resultPath) {
252
+ const encode = (text) => Buffer.from(text, 'utf8').toString('base64')
253
+ return [
254
+ "$ErrorActionPreference = 'Stop'",
255
+ `$line = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${encode(commandLine)}'))`,
256
+ `$resultPath = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${encode(resultPath)}'))`,
257
+ '$startup = $null',
258
+ 'try { $startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 } } catch { $startup = $null }',
259
+ 'try {',
260
+ ' $arguments = @{ CommandLine = $line }',
261
+ ' if ($null -ne $startup) { $arguments.ProcessStartupInformation = $startup }',
262
+ ' $created = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $arguments',
263
+ ' $payload = [ordered]@{ ok = ($created.ReturnValue -eq 0); pid = [int]$created.ProcessId; returnValue = [int]$created.ReturnValue; windowHidden = ($null -ne $startup); error = $null; commandLine = $line; at = (Get-Date).ToString("o") }',
264
+ '} catch {',
265
+ ' $payload = [ordered]@{ ok = $false; pid = 0; returnValue = -1; windowHidden = ($null -ne $startup); error = $_.Exception.Message; commandLine = $line; at = (Get-Date).ToString("o") }',
266
+ '}',
267
+ '[System.IO.File]::WriteAllText($resultPath, ($payload | ConvertTo-Json -Compress), (New-Object System.Text.UTF8Encoding($false)))',
268
+ 'if ($payload.ok) { exit 0 } else { exit 1 }',
269
+ ].join('\n')
270
+ }
271
+
272
+ /**
273
+ * Count work that a restart would interrupt.
274
+ *
275
+ * Mirrors the desktop shell's own admission check: a generating or tool-running
276
+ * agent (including subagents and turns waiting for approval), queued inbox
277
+ * messages, and running or stopping background jobs.
278
+ *
279
+ * Detection is fail-open on purpose. This is a safety net, not a lock: if a
280
+ * service face is missing, refusing to restart would strand the user with a
281
+ * button that never works. The failure is reported so the UI can say the count
282
+ * is incomplete.
283
+ */
284
+ export function collectActiveWork(ctx) {
285
+ const counts = { agents: 0, jobs: 0, terminals: 0 }
286
+ let detectionAvailable = true
287
+
288
+ let agentList = []
289
+ try {
290
+ const agents = ctx.get('agents')
291
+ if (!agents || typeof agents.list !== 'function') throw new Error('agents service is unavailable')
292
+ const rows = agents.list()
293
+ agentList = Array.isArray(rows) ? rows : []
294
+ counts.agents = agentList.filter((agent) => agent && (
295
+ agent.status === 'running'
296
+ || (Array.isArray(agent.inbox?.nextTurn) && agent.inbox.nextTurn.length > 0)
297
+ || (Array.isArray(agent.inbox?.nextStep) && agent.inbox.nextStep.length > 0)
298
+ )).length
299
+ } catch {
300
+ detectionAvailable = false
301
+ }
302
+
303
+ try {
304
+ const jobs = ctx.get('jobs')
305
+ if (!jobs || typeof jobs.list !== 'function') throw new Error('jobs service is unavailable')
306
+ const seen = new Set()
307
+ for (const caller of [undefined, ...agentList]) {
308
+ const rows = jobs.list(caller)
309
+ for (const job of Array.isArray(rows) ? rows : []) {
310
+ const key = String(job?.id)
311
+ if (seen.has(key)) continue
312
+ seen.add(key)
313
+ if (job && (job.status === 'running' || job.status === 'stopping')) counts.jobs += 1
314
+ }
315
+ }
316
+ } catch {
317
+ detectionAvailable = false
318
+ }
319
+
320
+ try {
321
+ const seen = new Set()
322
+ const scan = (terminals, owner) => {
323
+ if (!terminals || typeof terminals.list !== 'function') return
324
+ for (const terminal of terminals.list(owner) ?? []) {
325
+ const key = String(terminal?.sessionId)
326
+ if (seen.has(key)) continue
327
+ seen.add(key)
328
+ if (terminal?.status?.kind === 'running') counts.terminals += 1
329
+ }
330
+ }
331
+ for (const agent of agentList) {
332
+ try {
333
+ scan(agent?.ctx?.get?.('terminals'), agent)
334
+ } catch {
335
+ detectionAvailable = false
336
+ }
337
+ }
338
+ scan(ctx.get('terminals'), undefined)
339
+ } catch {
340
+ detectionAvailable = false
341
+ }
342
+
343
+ return {
344
+ ...counts,
345
+ total: counts.agents + counts.jobs + counts.terminals,
346
+ detectionAvailable,
347
+ }
348
+ }
349
+
350
+ /** @param ctx - profile context carrying `webServer` */
351
+ export function apply(ctx, config) {
352
+ const options = readOptions(config)
353
+ const stateDir = resolveStateDir()
354
+ const helperPath = join(dirname(fileURLToPath(import.meta.url)), 'helper.ps1')
355
+ const pendingPath = join(stateDir, PENDING_FILE)
356
+ const statePath = join(stateDir, STATE_FILE)
357
+ const logPath = join(stateDir, LOG_FILE)
358
+ const launchPath = join(stateDir, LAUNCH_FILE)
359
+ const cancelPath = join(stateDir, CANCEL_FILE)
360
+
361
+ const desktop = detectDesktop()
362
+ // console, not ctx.logger: the logger face differs between Host versions, and a
363
+ // missing or differently-shaped service must never take this plugin's load down.
364
+ const log = (level, message) => console[level](`[dsh-restart] ${message}`)
365
+ if (!desktop.desktop) log('warn', `restart disabled: ${desktop.reason}`)
366
+ if (!existsSync(helperPath)) log('warn', `helper script missing: ${helperPath}`)
367
+
368
+ /** Set once a restart is in flight, so a second click cannot schedule another. */
369
+ let scheduled = null
370
+
371
+ async function readPending() {
372
+ const marker = await readJson(pendingPath)
373
+ if (!marker || typeof marker.sessionId !== 'string' || typeof marker.at !== 'number') return null
374
+ if (Date.now() - marker.at > options.pendingTtlMinutes * 60 * 1000) {
375
+ await rm(pendingPath, { force: true }).catch(() => {})
376
+ return null
377
+ }
378
+ return marker
379
+ }
380
+
381
+ async function status() {
382
+ const activeWork = collectActiveWork(ctx)
383
+ const pendingRestore = await readPending()
384
+ const lastRun = await readJson(statePath)
385
+ const lastLaunch = await readJson(launchPath)
386
+ const helperAvailable = existsSync(helperPath)
387
+ const canRestart = options.enabled && desktop.desktop && helperAvailable && scheduled === null
388
+ let reason = null
389
+ if (!options.enabled) reason = 'the plugin is disabled by configuration'
390
+ else if (!desktop.desktop) reason = desktop.reason
391
+ else if (!helperAvailable) reason = `helper script missing: ${helperPath}`
392
+ else if (scheduled !== null) reason = 'a restart is already scheduled'
393
+ return {
394
+ canRestart,
395
+ reason,
396
+ hostPid: process.pid,
397
+ shellPid: desktop.desktop ? process.ppid : null,
398
+ shellPath: desktop.desktop ? process.execPath : null,
399
+ stateDir,
400
+ logPath,
401
+ activeWork,
402
+ pendingRestore: pendingRestore === null ? null : { sessionId: pendingRestore.sessionId, at: pendingRestore.at },
403
+ lastRun: lastRun === null ? null : {
404
+ phase: lastRun.phase ?? null,
405
+ at: lastRun.at ?? null,
406
+ error: lastRun.error ?? null,
407
+ newShellPid: lastRun.newShellPid ?? 0,
408
+ hostPid: lastRun.hostPid ?? null,
409
+ shellPid: lastRun.shellPid ?? null,
410
+ },
411
+ lastLaunch: lastLaunch === null ? null : {
412
+ ok: lastLaunch.ok === true,
413
+ pid: lastLaunch.pid ?? null,
414
+ windowHidden: lastLaunch.windowHidden ?? null,
415
+ error: lastLaunch.error ?? null,
416
+ at: lastLaunch.at ?? null,
417
+ },
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Start the helper through the WMI service and wait for its first state write.
423
+ *
424
+ * The launcher itself is an ordinary child of this process, but it only asks
425
+ * the service to create the helper and then exits. The helper is a child of
426
+ * WmiPrvSE, on the other side of the process-tree boundary, which is the whole
427
+ * reason it can kill the shell and still be alive to start the replacement.
428
+ *
429
+ * Waiting matters: a helper that never starts would otherwise be discovered
430
+ * only after the application is already gone. The reply states whether the
431
+ * helper is actually running, not merely that a spawn call returned.
432
+ */
433
+ async function spawnHelper() {
434
+ await mkdir(stateDir, { recursive: true })
435
+ await rm(statePath, { force: true }).catch(() => {})
436
+ await rm(logPath, { force: true }).catch(() => {})
437
+ await rm(launchPath, { force: true }).catch(() => {})
438
+ await rm(cancelPath, { force: true }).catch(() => {})
439
+
440
+ const powershell = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
441
+ const commandLine = buildHelperCommandLine({
442
+ powershell,
443
+ helperPath,
444
+ shellPid: process.ppid,
445
+ hostPid: process.pid,
446
+ shellPath: process.execPath,
447
+ stateDir,
448
+ delayMs: options.delayMs,
449
+ hostGraceMs: options.hostGraceMs,
450
+ killTimeoutMs: options.killTimeoutMs,
451
+ })
452
+
453
+ let spawnError = null
454
+ let launcher = null
455
+ let launcherExited = null
456
+ try {
457
+ launcher = spawn(powershell, [
458
+ '-NoProfile',
459
+ '-NonInteractive',
460
+ '-ExecutionPolicy', 'Bypass',
461
+ // The script goes in as UTF-16LE base64, so no argument in it needs
462
+ // quoting and no path can be split by the command line.
463
+ '-EncodedCommand', Buffer.from(buildWmiLauncherScript(commandLine, launchPath), 'utf16le').toString('base64'),
464
+ ], {
465
+ // Ignored stdio keeps the Host's pipes out of the launcher's hands.
466
+ stdio: 'ignore',
467
+ windowsHide: true,
468
+ cwd: stateDir,
469
+ })
470
+ launcher.on('error', (error) => {
471
+ // Without a listener, a spawn failure would be an unhandled 'error'
472
+ // event and take the Host down.
473
+ spawnError = error
474
+ })
475
+ launcher.on('exit', (code) => {
476
+ launcherExited = code
477
+ })
478
+ launcher.unref()
479
+ } catch (error) {
480
+ spawnError = error
481
+ }
482
+
483
+ const deadline = Date.now() + HELPER_BOOT_TIMEOUT_MS
484
+ while (Date.now() < deadline) {
485
+ const state = await readJson(statePath)
486
+ if (state && typeof state.phase === 'string') {
487
+ const launch = await readJson(launchPath)
488
+ // A helper created without the hidden-window record puts a console box on
489
+ // screen. The restart still works, so this is a warning, not a refusal —
490
+ // but it must be visible in the log instead of only in the task bar.
491
+ if (launch !== null && launch.ok === true && launch.windowHidden === false) {
492
+ log('warn', 'the helper was created without a hidden window; a console window will show')
493
+ }
494
+ return { started: true, state, launch, spawnError: null }
495
+ }
496
+ if (spawnError !== null) break
497
+ // The launcher is short-lived: once it is gone and its result file says
498
+ // the service refused, waiting out the rest of the budget would only make
499
+ // a broken WMI call feel like a hang.
500
+ if (launcherExited !== null) {
501
+ const launch = await readJson(launchPath)
502
+ if (launch !== null && launch.ok !== true) {
503
+ return { started: false, state: null, launch, spawnError }
504
+ }
505
+ }
506
+ await new Promise((resolve) => setTimeout(resolve, 100))
507
+ }
508
+ const launch = await readJson(launchPath)
509
+ if (launcher !== null) {
510
+ // The launcher may still be waiting on the service; the helper cannot
511
+ // exist yet without its boot state, so stopping it here is safe.
512
+ try { launcher.kill('SIGKILL') } catch { /* already gone */ }
513
+ }
514
+ return { started: false, state: null, launch, spawnError }
515
+ }
516
+
517
+ /**
518
+ * Tell a helper that may still be sleeping not to kill anything.
519
+ *
520
+ * The helper is not this process's child, so it cannot be killed directly and
521
+ * does not need to be: it checks this marker after its delay, before the kill.
522
+ * Written only when the launch could not be confirmed, where the alternative
523
+ * is a delayed helper tearing the application down after the UI said it would
524
+ * not.
525
+ */
526
+ async function cancelHelper() {
527
+ try {
528
+ await writeFile(cancelPath, JSON.stringify({ at: Date.now() }), 'utf8')
529
+ } catch { /* the state directory may be gone; nothing left to cancel */ }
530
+ }
531
+
532
+ const routes = [
533
+ {
534
+ path: `${API_BASE}/status`,
535
+ handler: async (req, res) => {
536
+ if (req.method !== 'GET') {
537
+ sendJson(res, 405, { error: 'method not allowed' })
538
+ return
539
+ }
540
+ try {
541
+ sendJson(res, 200, await status())
542
+ } catch (error) {
543
+ sendJson(res, 500, { error: error?.message ?? String(error) })
544
+ }
545
+ },
546
+ },
547
+ {
548
+ path: `${API_BASE}/restart`,
549
+ handler: async (req, res) => {
550
+ if (req.method !== 'POST') {
551
+ sendJson(res, 405, { error: 'method not allowed' })
552
+ return
553
+ }
554
+ if (rejectCrossOrigin(req, res)) return
555
+ try {
556
+ const raw = await readBody(req)
557
+ const body = raw.length === 0 ? {} : (JSON.parse(raw) ?? {})
558
+ const sessionId = typeof body.sessionId === 'string' && body.sessionId.length > 0 ? body.sessionId : null
559
+
560
+ if (!options.enabled) {
561
+ sendJson(res, 503, { error: 'the plugin is disabled by configuration' })
562
+ return
563
+ }
564
+ if (!desktop.desktop) {
565
+ sendJson(res, 503, { error: desktop.reason })
566
+ return
567
+ }
568
+ if (!existsSync(helperPath)) {
569
+ sendJson(res, 503, { error: `helper script missing: ${helperPath}` })
570
+ return
571
+ }
572
+ if (scheduled !== null) {
573
+ sendJson(res, 409, { error: 'a restart is already scheduled' })
574
+ return
575
+ }
576
+
577
+ // The client confirms before it sends `force`. Without that flag an
578
+ // interrupted agent is a refusal, not a warning.
579
+ const activeWork = collectActiveWork(ctx)
580
+ if (activeWork.total > 0 && body.force !== true) {
581
+ sendJson(res, 409, {
582
+ error: `active work would be interrupted (agents ${activeWork.agents} / jobs ${activeWork.jobs} / terminals ${activeWork.terminals})`,
583
+ items: activeWork,
584
+ })
585
+ return
586
+ }
587
+
588
+ scheduled = { at: Date.now(), sessionId }
589
+ if (sessionId !== null) {
590
+ await mkdir(stateDir, { recursive: true })
591
+ await writeJson(pendingPath, { sessionId, at: Date.now() })
592
+ }
593
+
594
+ const { started, state, launch, spawnError } = await spawnHelper()
595
+ if (!started) {
596
+ scheduled = null
597
+ await cancelHelper()
598
+ await rm(pendingPath, { force: true }).catch(() => {})
599
+ const detail = launch?.error ?? spawnError?.message ?? 'no boot state from the helper'
600
+ sendJson(res, 500, {
601
+ error: `the restart helper did not start (${detail}); the application was left running`,
602
+ detail,
603
+ launch: launch ?? null,
604
+ stateDir,
605
+ logPath,
606
+ launchPath,
607
+ })
608
+ return
609
+ }
610
+
611
+ sendJson(res, 200, {
612
+ ok: true,
613
+ helperPid: state.helperPid ?? null,
614
+ launchedPid: launch?.pid ?? null,
615
+ shellPid: process.ppid,
616
+ hostPid: process.pid,
617
+ delayMs: options.delayMs,
618
+ sessionId,
619
+ })
620
+ } catch (error) {
621
+ scheduled = null
622
+ sendJson(res, 400, { error: error?.message ?? String(error) })
623
+ }
624
+ },
625
+ },
626
+ {
627
+ path: `${API_BASE}/pending`,
628
+ handler: async (req, res) => {
629
+ if (req.method !== 'GET') {
630
+ sendJson(res, 405, { error: 'method not allowed' })
631
+ return
632
+ }
633
+ const marker = await readPending()
634
+ sendJson(res, 200, marker === null ? { sessionId: null } : { sessionId: marker.sessionId, at: marker.at })
635
+ },
636
+ },
637
+ {
638
+ path: `${API_BASE}/pending/consume`,
639
+ handler: async (req, res) => {
640
+ if (req.method !== 'POST') {
641
+ sendJson(res, 405, { error: 'method not allowed' })
642
+ return
643
+ }
644
+ if (rejectCrossOrigin(req, res)) return
645
+ const marker = await readPending()
646
+ if (marker !== null) await rm(pendingPath, { force: true }).catch(() => {})
647
+ sendJson(res, 200, marker === null ? { sessionId: null } : { sessionId: marker.sessionId, at: marker.at })
648
+ },
649
+ },
650
+ ]
651
+
652
+ for (const route of routes) {
653
+ ctx.effect(
654
+ () => ctx.webServer.register({ kind: 'exact', path: route.path, handler: route.handler }),
655
+ `dsh-restart ${route.path}`,
656
+ )
657
+ }
658
+ }
659
+
660
+ export default { name, inject, apply }