dsh-restart-continue 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) 2026 LISHUANGXI1412
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,90 @@
1
- # Temporary Holding Version
1
+ # dsh-restart-continue
2
+
3
+ 让 DSH 里的 Agent 自己重启 DSH Desktop,并在新进程启动后**自动回到同一个会话继续对话**。
4
+
5
+ > npm 包名:`dsh-restart-continue`(`dsh-restart` 已被他人占用,源码目录仍为 `E:\dsh的工作区\dsh-restart`)。
6
+
7
+ ## 解决的问题
8
+
9
+ 装插件、改配置、换原生依赖之后,DSH 经常需要重启才能生效。原来的流程是:
10
+
11
+ 1. Agent 说“需要重启”;
12
+ 2. 用户手动重启;
13
+ 3. 用户回到会话,再补一句“继续”。
14
+
15
+ 这个插件把 1→3 串起来:Agent 调一次工具,重启完成后插件自己往会话里投递一条续聊指令,Agent 接着干。**全程没有确认弹窗、不需要点任何东西。**
16
+
17
+ ## 工作方式
18
+
19
+ ```
20
+ 工具调用 dsh_restart
21
+ │ ① 拒绝:其他对话有 turn 正在运行(重启会打断它们)
22
+ │ ② 写 hand-off 文件 ~/.dsh/restart/continuation.json
23
+ │ ③ 挂一个一次性 agent/status 监听
24
+ ▼
25
+ 本轮结束(Agent 进入 idle)
26
+ │ ④ 再次检查:这期间有没有别的对话开始跑
27
+ │ ⑤ flush 当前会话,保证最终消息落盘
28
+ │ ⑥ 写 launch.vbs(UTF-16LE + BOM)并启动 wscript.exe
29
+ ▼
30
+ wscript(GUI 子系统,无控制台)
31
+ └→ WScript.Shell.Run(..., 0, False) 隐藏启动 PowerShell worker
32
+ ├─ 清掉 ELECTRON_RUN_AS_NODE(否则新进程变成 Node 模式秒退)
33
+ ├─ 优雅 taskkill 主进程(WM_CLOSE)
34
+ ├─ closeToTray 只隐藏窗口 → forcing:先 kill Host,再 kill 主进程
35
+ └─ 用原始命令行重新启动 DSH NEXT.exe
36
+ ▼
37
+ 新进程启动,插件读到 hand-off 文件
38
+ │ ⑦ 校验:记录早于本次启动 + 10 分钟内 + 尝试次数 < 3 + 同 Profile
39
+ │ ⑧ 用 sessionController.prompt(..., mode: 'queue') 投递续聊消息
40
+ ▼
41
+ 原会话恢复上下文,开一轮新 turn 继续工作
42
+ ```
43
+
44
+ 几个刻意的设计:
45
+
46
+ - **不用桌面壳的重启确认框**:`desktopActions.requestRestart()` 会弹 Electron 原生对话框,而且默认按钮是「取消」。静默重启走“优雅关闭 + 重新拉起”,窗口会关掉再打开,约 3~8 秒。
47
+ - **为什么是 wscript 而不是直接 spawn PowerShell**:Node 用 `detached: true` 启动 PowerShell 会在执行脚本前静默退出;普通子进程又会跟着父控制台一起被销毁。`wscript` 是无控制台的 GUI 宿主,配合 `WScript.Shell.Run(cmd, 0, False)` 能拉起一个独立、隐藏、继承正确用户环境的 worker。
48
+ - **VBS 必须 UTF-16LE + BOM**:否则 `WScript` 按 ANSI 读,`C:\Users\木\...` 这类中文路径会被读坏。
49
+ - **必须清 `ELECTRON_RUN_AS_NODE`**:桌面壳用 Node 模式启动 Host,会把 `ELECTRON_RUN_AS_NODE=1` 注入 Host 环境;worker 继承后若原样传给 App,Electron 会以纯 Node 运行、没有脚本参数就立即退出(表现为“拉起后秒退、什么都没发生”)。
50
+ - **等本轮结束再重启**:tool result 和最终回复先落盘,重启不会吞掉它们。
51
+ - **其他对话在跑就拒绝**:调度时检查一次,真正触发前再检查一次;有别的 Agent 正在跑 turn 就放弃重启(只记日志,不强制)。
52
+ - **只认“比本次启动更早”的记录**:10 分钟过期、最多跨启动重试 3 次,避免复活旧会话或陷入重启循环。
53
+ - **续聊走公开审计路径**:消息以普通 user message 出现在对话里,头部标注 `[DSH 自动重启 · 已完成]`。
54
+ - **插件 dispose 不删 hand-off 文件**:Host 关闭时插件会被卸载,只有 `cancel` 或成功续聊后才删。
55
+
56
+ ## 安装
57
+
58
+ ```powershell
59
+ plugin_manager install_bundle E:\dsh的工作区\dsh-restart
60
+ ```
61
+
62
+ 插件改代码后:在 Settings → Plugins 里把它关掉再打开即可热重载(不必重启 Host)。
63
+
64
+ ## 用法
65
+
66
+ ```json
67
+ { "action": "restart", "reason": "plugin 已升级", "prompt": "重启后继续验收 Settings 面板" }
68
+ ```
69
+
70
+ - `action: restart`(默认):预约重启。
71
+ - `action: status`:只读,报告重启能力(`silent-app-relaunch` / `desktop-confirm` / `unsupported`)、正在跑的对话、挂起请求、hand-off 路径。
72
+ - `action: cancel`:撤销挂起的请求(同时删掉 hand-off 文件)。
73
+
74
+ Agent 侧约定:调用完工具后**正常结束本轮**,不要在本轮里等重启。
75
+
76
+ ## 限制
77
+
78
+ - **静默重启仅 Windows**:靠 `taskkill`(WM_CLOSE)+ 重新拉起实现。macOS/Linux 会退回桌面壳的 `requestRestart()`,那一步需要用户确认。
79
+ - **重启 = 整个 App 退出再启动**:窗口会关掉;正在跑的**其他**对话会被拒绝保护,但已经打开的其他会话页面会一起重载。
80
+ - **10 分钟有效窗口**:超时后不会续聊(会话本身不受影响)。
81
+ - **续聊是 user message**:会以用户消息出现在对话里。
82
+ - **`closeToTray` 开启时**:优雅关闭会变成隐藏窗口,助手等 12 秒后强制结束进程再拉起。
83
+
84
+ ## 验证状态
85
+
86
+ - 单元测试:`node --test`,18 项全过(状态过期、启动先后、重试与放弃、续聊文本、运行中对话守卫)。
87
+ - `dsh_restart action=status` 在 DSH Desktop 返回 `silentRelaunch: true`、`mode: "silent-app-relaunch"`。
88
+ - **端到端实测通过**(2026-11-15 18:28):Agent 调用工具 → 本轮结束后窗口自动关闭 → worker 重新拉起 App(`relaunch.log`:`clearing ELECTRON_RUN_AS_NODE=1` → `graceful quit timed out; forcing` → `relaunch attempt 1 pid=24700` → `done started=True`)→ 新 Host 启动 → 同一会话自动注入续聊消息并继续。
89
+ - **守卫实测**:另起一个子会话跑长任务时,`status.runningAgents` 列出它,`action=restart` 返回 `ok:false` 并给出 `running` 列表,不重启。
2
90
 
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.
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-restart-continue
3
+ name: dsh-restart-continue
package/icon.svg ADDED
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Restart and continue">
2
+ <defs>
3
+ <linearGradient id="g" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
4
+ <stop offset="0" stop-color="#3b82f6"/>
5
+ <stop offset="1" stop-color="#6366f1"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="2" y="2" width="60" height="60" rx="16" fill="url(#g)"/>
9
+ <path d="M22 22h14a10 10 0 0 1 10 10v2a10 10 0 0 1-10 10H26" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
10
+ <path d="M27 16l-7 6 7 6" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
11
+ <path d="M40 30h8M44 26v8" stroke="#bfdbfe" stroke-width="3" stroke-linecap="round"/>
12
+ </svg>
package/index.js ADDED
@@ -0,0 +1,447 @@
1
+ /**
2
+ * dsh-restart — let an agent restart DSH Desktop and continue the same
3
+ * conversation after the new process boots.
4
+ *
5
+ * Restarting is silent on Windows: a detached helper closes the Electron main
6
+ * process gracefully (the same path as the window close button) and starts the
7
+ * same executable again. The continuation is a durable hand-off file consumed by
8
+ * the next Host boot, so it survives the process replacement.
9
+ */
10
+ import { randomUUID } from 'node:crypto'
11
+ import { basename } from 'node:path'
12
+ import {
13
+ STATE_VERSION,
14
+ appendLog,
15
+ bootTime,
16
+ clearState,
17
+ isResumable,
18
+ readState,
19
+ statePath,
20
+ writeState,
21
+ } from './lib/state.js'
22
+ import { deliverContinuation } from './lib/continuation.js'
23
+ import { otherRunningAgents } from './lib/guard.js'
24
+ import { canRelaunchSilently, requestAppRelaunch } from './lib/relaunch.js'
25
+
26
+ export const name = 'dsh-restart-continue'
27
+ export const inject = ['tools']
28
+
29
+ /** How long a scheduled request may wait for the Agent to go idle before it expires. */
30
+ const SCHEDULE_MAX_WAIT_MS = 30 * 60 * 1000
31
+
32
+ /** Delay after boot before the continuation prompt is admitted. */
33
+ const CONTINUATION_DELAY_MS = 1500
34
+
35
+ function json(value) {
36
+ return JSON.stringify(value, null, 2)
37
+ }
38
+
39
+ /**
40
+ * Ask the session store to persist everything appended so far. The restart that
41
+ * follows may terminate the process without the shell's graceful shutdown, so
42
+ * the final assistant message must already be durable.
43
+ * @param {object} ctx - plugin context.
44
+ * @param {string} sessionId - session to flush.
45
+ * @returns {Promise<void>} resolves after the flush settles or times out.
46
+ */
47
+ async function flushSession(ctx, sessionId) {
48
+ const sessions = ctx.get('sessions')
49
+ if (typeof sessions?.flush !== 'function') return
50
+ let session
51
+ try {
52
+ session = sessions.get?.(sessionId)
53
+ } catch {}
54
+ if (session === undefined) return
55
+ let timer
56
+ try {
57
+ await Promise.race([
58
+ sessions.flush(session),
59
+ new Promise((resolve) => {
60
+ timer = setTimeout(resolve, 3000)
61
+ timer.unref?.()
62
+ }),
63
+ ])
64
+ } finally {
65
+ if (timer !== undefined) clearTimeout(timer)
66
+ }
67
+ }
68
+
69
+ function textOutput() {
70
+ return {
71
+ schema: { type: 'string' },
72
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Best-effort name of the Profile this Host booted, used only to avoid replaying
78
+ * a hand-off into a different Profile. The Desktop Host runs with the Profile
79
+ * directory as its cwd and does not export DSH_PROFILE.
80
+ * @returns {string | undefined} profile name when it can be derived.
81
+ */
82
+ function currentProfile() {
83
+ const configured = process.env.DSH_PROFILE?.trim()
84
+ if (configured) return configured
85
+ try {
86
+ return basename(process.cwd())
87
+ } catch {
88
+ return undefined
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Owns the single pending restart request for this Host process.
94
+ */
95
+ class RestartScheduler {
96
+ /**
97
+ * @param {object} ctx - plugin context.
98
+ * @param {{ later: Function, log?: Function }} deps - timer helper and logger.
99
+ */
100
+ constructor(ctx, deps) {
101
+ this.ctx = ctx
102
+ this.later = deps.later
103
+ this.log = deps.log ?? appendLog
104
+ this.pending = undefined
105
+ this.disposeStatusListener = undefined
106
+ this.expiryTimer = undefined
107
+ }
108
+
109
+ /** @returns {{ silentRelaunch: boolean, desktopShell: boolean, mode: string }} restart capabilities. */
110
+ capabilities() {
111
+ const silentRelaunch = canRelaunchSilently()
112
+ const desktopShell = typeof this.ctx.get('desktopActions')?.requestRestart === 'function'
113
+ return {
114
+ silentRelaunch,
115
+ desktopShell,
116
+ mode: silentRelaunch ? 'silent-app-relaunch' : desktopShell ? 'desktop-confirm' : 'unsupported',
117
+ }
118
+ }
119
+
120
+ /** @returns {boolean} whether this Host can restart itself at all. */
121
+ canRestart() {
122
+ return this.capabilities().mode !== 'unsupported'
123
+ }
124
+
125
+ /**
126
+ * @param {unknown} callerId - Agent that asked for the restart.
127
+ * @returns {Array<{ id: string, status: string }>} other Agents that are mid-turn.
128
+ */
129
+ runningOthers(callerId) {
130
+ const agents = this.ctx.get('agents')
131
+ let list = []
132
+ try {
133
+ list = agents?.list?.() ?? []
134
+ } catch {}
135
+ return otherRunningAgents(list, callerId)
136
+ }
137
+
138
+ /**
139
+ * Persist a request and arm the one-shot idle listener.
140
+ * @param {{ sessionId: string, cwd?: string, prompt?: string, reason?: string }} request - caller intent.
141
+ * @returns {object} the stored record.
142
+ */
143
+ schedule(request) {
144
+ this.cancel()
145
+ const state = {
146
+ version: STATE_VERSION,
147
+ token: randomUUID(),
148
+ sessionId: String(request.sessionId),
149
+ cwd: typeof request.cwd === 'string' ? request.cwd : undefined,
150
+ profile: currentProfile(),
151
+ prompt: typeof request.prompt === 'string' ? request.prompt : undefined,
152
+ reason: typeof request.reason === 'string' ? request.reason : undefined,
153
+ createdAt: Date.now(),
154
+ attempts: 0,
155
+ }
156
+ writeState(state)
157
+ this.pending = state
158
+ this.log({ event: 'restart.scheduled', sessionId: state.sessionId })
159
+
160
+ this.disposeStatusListener = this.ctx.on('agent/status', (payload) => {
161
+ if (payload?.status !== 'idle') return
162
+ if (String(payload.agent?.id) !== state.sessionId) return
163
+ void this.fire()
164
+ })
165
+
166
+ // Never keep a request alive forever when the Agent never reaches idle.
167
+ this.expiryTimer = this.later(() => {
168
+ if (this.pending?.token !== state.token) return
169
+ this.log({ event: 'restart.expired', sessionId: state.sessionId })
170
+ this.cancel()
171
+ }, SCHEDULE_MAX_WAIT_MS)
172
+
173
+ return state
174
+ }
175
+
176
+ /** Drop the pending request and its durable record (user cancellation). */
177
+ cancel() {
178
+ this.disposeInMemory()
179
+ if (readState() !== undefined) clearState()
180
+ }
181
+
182
+ /** Release listeners and timers without touching the hand-off file (plugin disposal). */
183
+ dispose() {
184
+ this.disposeInMemory()
185
+ }
186
+
187
+ disposeInMemory() {
188
+ this.pending = undefined
189
+ if (this.disposeStatusListener !== undefined) {
190
+ this.disposeStatusListener()
191
+ this.disposeStatusListener = undefined
192
+ }
193
+ if (this.expiryTimer !== undefined) {
194
+ clearTimeout(this.expiryTimer)
195
+ this.expiryTimer = undefined
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Perform the restart once the Agent is idle.
201
+ * @returns {Promise<void>} resolves after the request is handed off (or rejected).
202
+ */
203
+ async fire() {
204
+ const pending = this.pending
205
+ if (pending === undefined) return
206
+ this.disposeInMemory()
207
+
208
+ const stored = readState()
209
+ if (stored === undefined || stored.token !== pending.token) return
210
+
211
+ // Re-check at the last moment: another conversation may have started a turn
212
+ // between the tool call and this idle edge.
213
+ const running = this.runningOthers(pending.sessionId)
214
+ if (running.length > 0) {
215
+ this.log({ event: 'restart.blocked', sessionId: pending.sessionId, running: running.map((agent) => agent.id) })
216
+ clearState()
217
+ return
218
+ }
219
+
220
+ writeState({ ...stored, turnEndedAt: Date.now() })
221
+ await flushSession(this.ctx, pending.sessionId)
222
+
223
+ if (canRelaunchSilently()) {
224
+ try {
225
+ const result = await requestAppRelaunch()
226
+ if (result.ok) {
227
+ this.log({ event: 'relaunch.spawned', sessionId: pending.sessionId, launcher: result.launcher })
228
+ } else {
229
+ this.log({
230
+ event: 'relaunch.failed',
231
+ sessionId: pending.sessionId,
232
+ code: result.code,
233
+ error: result.error ?? 'launcher did not start',
234
+ })
235
+ clearState()
236
+ }
237
+ } catch (error) {
238
+ this.log({
239
+ event: 'relaunch.failed',
240
+ error: error instanceof Error ? error.message : String(error),
241
+ })
242
+ clearState()
243
+ }
244
+ return
245
+ }
246
+
247
+ // Non-Windows fallback: the Desktop's own restart request, which is confirmed
248
+ // through the shell's native dialog.
249
+ const actions = this.ctx.get('desktopActions')
250
+ if (typeof actions?.requestRestart !== 'function') {
251
+ this.log({ event: 'restart.unsupported' })
252
+ clearState()
253
+ return
254
+ }
255
+ try {
256
+ await actions.requestRestart()
257
+ this.log({ event: 'restart.requested', sessionId: pending.sessionId })
258
+ } catch (error) {
259
+ this.log({
260
+ event: 'restart.request-failed',
261
+ error: error instanceof Error ? error.message : String(error),
262
+ })
263
+ clearState()
264
+ }
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Compose the Host plugin.
270
+ * @param {object} ctx - plugin context.
271
+ */
272
+ export function apply(ctx) {
273
+ const lifetime = new AbortController()
274
+ const timers = new Set()
275
+
276
+ const later = (fn, ms) => {
277
+ const timer = setTimeout(() => {
278
+ timers.delete(timer)
279
+ fn()
280
+ }, ms)
281
+ timer.unref?.()
282
+ timers.add(timer)
283
+ return timer
284
+ }
285
+
286
+ const scheduler = new RestartScheduler(ctx, { later })
287
+
288
+ ctx.tools.register({
289
+ name: 'dsh_restart',
290
+ description:
291
+ 'Restart DSH Desktop and resume this same conversation automatically after the new process boots. ' +
292
+ 'On Windows this is silent: a detached helper closes the app gracefully and starts it again, so no dialog ' +
293
+ 'and no click are involved. The restart is deferred until this turn ends, so the current tool result and ' +
294
+ 'final message are never lost; after the new app boots, a continuation prompt is injected into this exact ' +
295
+ 'session. It refuses while another conversation is mid-turn, because a restart would interrupt it. ' +
296
+ 'action="status" reports capabilities, running conversations, and any pending request; action="cancel" drops ' +
297
+ 'a pending request.',
298
+ parameters: {
299
+ type: 'object',
300
+ properties: {
301
+ action: {
302
+ type: 'string',
303
+ enum: ['restart', 'status', 'cancel'],
304
+ description: 'restart (default) schedules the restart; status is read-only; cancel drops the pending request.',
305
+ },
306
+ prompt: {
307
+ type: 'string',
308
+ description:
309
+ 'What the resumed turn should continue doing. Omit to continue the unfinished work of this conversation.',
310
+ },
311
+ reason: {
312
+ type: 'string',
313
+ description: 'Short human-readable reason recorded in the continuation message and the plugin log.',
314
+ },
315
+ },
316
+ },
317
+ output: textOutput(),
318
+ timeoutMs: 15000,
319
+ isConcurrencySafe: () => false,
320
+ async execute(args, exec) {
321
+ const action = typeof args?.action === 'string' ? args.action : 'restart'
322
+
323
+ if (action === 'status') {
324
+ const pending = readState()
325
+ return json({
326
+ ok: true,
327
+ action,
328
+ host: {
329
+ pid: process.pid,
330
+ parentPid: process.ppid,
331
+ startedAt: new Date(bootTime()).toISOString(),
332
+ profile: currentProfile() ?? null,
333
+ cwd: process.cwd(),
334
+ dshHome: process.env.DSH_HOME ?? null,
335
+ },
336
+ capabilities: {
337
+ ...scheduler.capabilities(),
338
+ sessionPrompt: typeof ctx.get('sessionController')?.prompt === 'function',
339
+ },
340
+ runningAgents: scheduler.runningOthers(exec?.agent?.id),
341
+ stateFile: statePath(),
342
+ pending: pending ?? null,
343
+ })
344
+ }
345
+
346
+ if (action === 'cancel') {
347
+ const pending = readState()
348
+ scheduler.cancel()
349
+ return json({
350
+ ok: true,
351
+ action,
352
+ cancelled: pending !== null && pending !== undefined,
353
+ pending: null,
354
+ })
355
+ }
356
+
357
+ const agent = exec?.agent
358
+ if (agent === undefined || agent === null) {
359
+ return json({
360
+ ok: false,
361
+ error: 'dsh_restart must run inside an Agent turn; no calling Agent is available.',
362
+ })
363
+ }
364
+ const capabilities = scheduler.capabilities()
365
+ if (capabilities.mode === 'unsupported') {
366
+ return json({
367
+ ok: false,
368
+ error:
369
+ 'This Host cannot restart itself: no silent relaunch is available on this platform and the Desktop shell ' +
370
+ 'exposes no restart capability. dsh_restart needs DSH Desktop on Windows.',
371
+ })
372
+ }
373
+
374
+ const running = scheduler.runningOthers(agent.id)
375
+ if (running.length > 0) {
376
+ return json({
377
+ ok: false,
378
+ error:
379
+ `Refusing to restart: ${running.length} other conversation(s) are running a turn. ` +
380
+ 'A restart replaces the whole app process and would interrupt them.',
381
+ running,
382
+ suggestion:
383
+ 'Wait for those turns to finish (or cancel them), then call dsh_restart again. action="status" shows them.',
384
+ })
385
+ }
386
+
387
+ const state = scheduler.schedule({
388
+ sessionId: agent.id,
389
+ cwd: agent.session?.header?.cwd,
390
+ prompt: typeof args?.prompt === 'string' ? args.prompt : undefined,
391
+ reason: typeof args?.reason === 'string' ? args.reason : undefined,
392
+ })
393
+
394
+ return json({
395
+ ok: true,
396
+ action,
397
+ scheduled: true,
398
+ mode: capabilities.mode,
399
+ sessionId: state.sessionId,
400
+ stateFile: statePath(),
401
+ restartAt: 'when this turn ends (the Agent reaches idle)',
402
+ continuation:
403
+ 'After the new process boots, this plugin reopens the session and injects a continuation prompt. ' +
404
+ 'Finish the current turn normally instead of waiting for the restart inside the turn.',
405
+ guard:
406
+ 'The restart is re-checked at fire time; if another conversation starts a turn meanwhile it is skipped, not forced.',
407
+ note:
408
+ 'The request expires in 10 minutes and never replays an older conversation.',
409
+ })
410
+ },
411
+ })
412
+
413
+ // A previous Host may have handed off a continuation to this boot.
414
+ const pending = readState()
415
+ if (pending !== undefined) {
416
+ const profile = currentProfile()
417
+ const foreignProfile =
418
+ typeof pending.profile === 'string' && profile !== undefined && pending.profile !== profile
419
+ if (foreignProfile || !isResumable(pending)) {
420
+ clearState()
421
+ appendLog({
422
+ event: 'state.discarded',
423
+ sessionId: pending?.sessionId,
424
+ expired: !foreignProfile,
425
+ foreignProfile,
426
+ expectedProfile: pending?.profile,
427
+ profile,
428
+ })
429
+ } else {
430
+ const accepted = { ...pending, attempts: (pending.attempts ?? 0) + 1, resumedAt: Date.now() }
431
+ try {
432
+ writeState(accepted)
433
+ } catch {}
434
+ appendLog({ event: 'state.accepted', sessionId: accepted.sessionId, attempts: accepted.attempts })
435
+ later(() => {
436
+ void deliverContinuation(ctx, accepted, { signal: lifetime.signal })
437
+ }, CONTINUATION_DELAY_MS)
438
+ }
439
+ }
440
+
441
+ ctx.effect(() => () => {
442
+ lifetime.abort()
443
+ for (const timer of timers) clearTimeout(timer)
444
+ timers.clear()
445
+ scheduler.dispose()
446
+ })
447
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Turn a pending restart record into one continuation prompt.
3
+ *
4
+ * Runs right after a Host boot. The session is resumed through
5
+ * `ctx.sessionController.prompt`, the same admission path a Web client uses,
6
+ * so the model sees a normal user turn with the full durable history.
7
+ */
8
+ import { randomUUID } from 'node:crypto'
9
+ import {
10
+ STATE_MAX_ATTEMPTS,
11
+ appendLog,
12
+ clearState,
13
+ continuationText,
14
+ readState,
15
+ writeState,
16
+ } from './state.js'
17
+
18
+ const DEFAULT_RETRY_MS = [0, 3000, 8000, 15000]
19
+
20
+ /**
21
+ * Sleep for a fixed delay while remaining cancellable.
22
+ * @param {number} ms - delay.
23
+ * @param {AbortSignal} [signal] - plugin lifetime.
24
+ * @returns {Promise<boolean>} false when the lifetime ended first.
25
+ */
26
+ function sleep(ms, signal) {
27
+ if (ms <= 0) return Promise.resolve(!signal?.aborted)
28
+ return new Promise((resolve) => {
29
+ if (signal?.aborted) return resolve(false)
30
+ const timer = setTimeout(() => {
31
+ signal?.removeEventListener('abort', onAbort)
32
+ resolve(!signal?.aborted)
33
+ }, ms)
34
+ timer.unref?.()
35
+ function onAbort() {
36
+ clearTimeout(timer)
37
+ resolve(false)
38
+ }
39
+ signal?.addEventListener('abort', onAbort, { once: true })
40
+ })
41
+ }
42
+
43
+ /**
44
+ * Resolve one Host service, waiting for late composition.
45
+ * @param {import('@deepseek-ai/cordis').Context} ctx - plugin context.
46
+ * @param {string} name - service key.
47
+ * @param {number} timeoutMs - upper bound.
48
+ * @param {AbortSignal} [signal] - plugin lifetime.
49
+ * @returns {Promise<any | undefined>} the service, or undefined on timeout.
50
+ */
51
+ async function waitForService(ctx, name, timeoutMs, signal) {
52
+ const deadline = Date.now() + timeoutMs
53
+ for (;;) {
54
+ if (signal?.aborted) return undefined
55
+ if (ctx.get(name) !== undefined) return ctx.get(name)
56
+ if (Date.now() >= deadline) return undefined
57
+ if (!(await sleep(250, signal))) return undefined
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Deliver the continuation prompt, retrying transient failures in this boot.
63
+ * @param {import('@deepseek-ai/cordis').Context} ctx - plugin context.
64
+ * @param {object} state - the pending record read at boot.
65
+ * @param {object} [options] - test seams.
66
+ * @param {AbortSignal} [options.signal] - plugin lifetime.
67
+ * @param {number[]} [options.retryMs] - delay before each attempt.
68
+ * @param {boolean} [options.waitForService] - whether to await `sessionController` first.
69
+ * @param {Function} [options.log] - diagnostic sink.
70
+ * @returns {Promise<{ ok: boolean, reason?: string, error?: string }>} outcome.
71
+ */
72
+ export async function deliverContinuation(ctx, state, options = {}) {
73
+ const {
74
+ signal,
75
+ retryMs = DEFAULT_RETRY_MS,
76
+ waitForService: awaitService = true,
77
+ log = appendLog,
78
+ } = options
79
+
80
+ if (awaitService) await waitForService(ctx, 'sessionController', 20000, signal)
81
+
82
+ let lastError = ''
83
+ for (let attempt = 0; attempt < retryMs.length; attempt += 1) {
84
+ if (attempt > 0 && !(await sleep(retryMs[attempt], signal))) {
85
+ return { ok: false, reason: 'disposed' }
86
+ }
87
+ if (signal?.aborted) return { ok: false, reason: 'disposed' }
88
+
89
+ // Another code path may have consumed or replaced the request meanwhile.
90
+ const latest = readState()
91
+ if (!latest || latest.token !== state.token) {
92
+ return { ok: false, reason: 'state-changed' }
93
+ }
94
+
95
+ const controller = ctx.get('sessionController')
96
+ if (controller?.prompt === undefined) {
97
+ lastError = 'sessionController.prompt is unavailable'
98
+ log({ event: 'continuation.service-missing' })
99
+ continue
100
+ }
101
+
102
+ try {
103
+ const text = continuationText(latest)
104
+ await controller.prompt(
105
+ {
106
+ requestId: `restart-${randomUUID()}`,
107
+ sessionId: latest.sessionId,
108
+ mode: 'queue',
109
+ content: [{ type: 'text', text }],
110
+ },
111
+ AbortSignal.timeout(120000),
112
+ )
113
+ clearState()
114
+ log({ event: 'continuation.sent', sessionId: latest.sessionId, attempt })
115
+ return { ok: true }
116
+ } catch (error) {
117
+ lastError = error instanceof Error ? error.message : String(error)
118
+ log({
119
+ event: 'continuation.failed',
120
+ sessionId: latest.sessionId,
121
+ attempt,
122
+ error: lastError,
123
+ })
124
+ }
125
+ }
126
+
127
+ // Leave a record of the failure for the next boot or for the user to inspect,
128
+ // but stop retrying once this request has exhausted its budget.
129
+ const attempts = (state.attempts ?? 0) + 1
130
+ if (attempts >= STATE_MAX_ATTEMPTS) {
131
+ clearState()
132
+ log({ event: 'continuation.abandoned', sessionId: state.sessionId, attempts, error: lastError })
133
+ return { ok: false, reason: 'abandoned', error: lastError }
134
+ }
135
+ try {
136
+ writeState({ ...state, attempts })
137
+ } catch {}
138
+ log({ event: 'continuation.retry-later', sessionId: state.sessionId, attempts, error: lastError })
139
+ return { ok: false, reason: 'retry-later', error: lastError }
140
+ }
package/lib/guard.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Refuse to restart while another conversation is mid-turn.
3
+ *
4
+ * A restart replaces the whole app process, so any other Agent with an active
5
+ * driver would lose its turn. Idle Agents are fine: nothing is executing.
6
+ */
7
+
8
+ /**
9
+ * @param {Array<{ id: unknown, status?: string }> | undefined} agents - live agents.
10
+ * @param {unknown} callerId - the Agent that asked for the restart.
11
+ * @returns {Array<{ id: string, status: string }>} running agents other than the caller.
12
+ */
13
+ export function otherRunningAgents(agents, callerId) {
14
+ const caller = String(callerId ?? '')
15
+ return (agents ?? [])
16
+ .filter((agent) => agent !== undefined && agent !== null)
17
+ .filter((agent) => String(agent.id) !== caller)
18
+ .filter((agent) => agent.status === 'running')
19
+ .map((agent) => ({ id: String(agent.id), status: 'running' }))
20
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Silent Desktop relaunch.
3
+ *
4
+ * The Host cannot ask the Electron shell to restart without the shell's own
5
+ * native confirmation dialog, and that dialog defaults to "Cancel". Instead a
6
+ * helper closes the Electron main process gracefully (WM_CLOSE, the same path as
7
+ * the window close button) and starts the same executable again.
8
+ *
9
+ * Launch chain, and why it is shaped this way:
10
+ * Host -> wscript.exe launch.vbs (GUI subsystem: no console) -> powershell worker
11
+ *
12
+ * A `detached` PowerShell spawned straight from Node exits before running the
13
+ * script, and a plain child dies with its parent's console. `WScript.Shell.Run`
14
+ * with a hidden window starts the worker with its own console and the Host's
15
+ * environment, and it survives the wscript launcher exiting immediately.
16
+ */
17
+ import { spawn } from 'node:child_process'
18
+ import fs from 'node:fs'
19
+ import path from 'node:path'
20
+ import { stateDir } from './state.js'
21
+
22
+ /** Absolute path to Windows PowerShell, independent of PATH. */
23
+ export function powershellPath() {
24
+ const root = process.env.SystemRoot ?? 'C:\\Windows'
25
+ return path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
26
+ }
27
+
28
+ /** Absolute path to the Windows Script Host (GUI subsystem, no console). */
29
+ export function wscriptPath() {
30
+ const root = process.env.SystemRoot ?? 'C:\\Windows'
31
+ return path.join(root, 'System32', 'wscript.exe')
32
+ }
33
+
34
+ export function relaunchScriptPath() {
35
+ return path.join(stateDir(), 'relaunch.ps1')
36
+ }
37
+
38
+ export function relaunchLauncherPath() {
39
+ return path.join(stateDir(), 'launch.vbs')
40
+ }
41
+
42
+ export function relaunchLogPath() {
43
+ return path.join(stateDir(), 'relaunch.log')
44
+ }
45
+
46
+ /** The helper script; kept as one literal so a packaged bundle needs no extra files. */
47
+ function helperSource() {
48
+ return `param(
49
+ [int]$ParentPid,
50
+ [int]$HostPid,
51
+ [string]$ExePath,
52
+ [string]$LogPath
53
+ )
54
+ function Write-Log([string]$Message) {
55
+ try { Add-Content -LiteralPath $LogPath -Value ((Get-Date).ToString('o') + ' ' + $Message) } catch {}
56
+ }
57
+ function Get-OriginalArguments([int]$TargetId, [string]$Executable) {
58
+ try {
59
+ $info = Get-CimInstance Win32_Process -Filter "ProcessId=$TargetId"
60
+ $line = $info.CommandLine
61
+ if ([string]::IsNullOrWhiteSpace($line)) { return '' }
62
+ if ($line.StartsWith('"')) {
63
+ $end = $line.IndexOf('"', 1)
64
+ if ($end -gt 0) { return $line.Substring($end + 1).Trim() }
65
+ return ''
66
+ }
67
+ if ($line.StartsWith($Executable, [System.StringComparison]::OrdinalIgnoreCase)) {
68
+ return $line.Substring($Executable.Length).Trim()
69
+ }
70
+ $space = $line.IndexOf(' ')
71
+ if ($space -gt 0) { return $line.Substring($space + 1).Trim() }
72
+ return ''
73
+ } catch {
74
+ Write-Log ("command-line read failed: " + $_.Exception.Message)
75
+ return ''
76
+ }
77
+ }
78
+ Write-Log ("helper start parent=" + $ParentPid + " host=" + $HostPid + " exe=" + $ExePath)
79
+ # The Desktop starts its Host in Electron's Node mode, so this worker inherited
80
+ # ELECTRON_RUN_AS_NODE=1. If the relaunched app inherits it too, Electron runs as
81
+ # plain Node with no script, reads EOF on stdin, and exits before any window.
82
+ if (Test-Path Env:ELECTRON_RUN_AS_NODE) {
83
+ Write-Log ("clearing ELECTRON_RUN_AS_NODE=" + $env:ELECTRON_RUN_AS_NODE)
84
+ Remove-Item Env:ELECTRON_RUN_AS_NODE -ErrorAction SilentlyContinue
85
+ }
86
+ $appArguments = Get-OriginalArguments -TargetId $ParentPid -Executable $ExePath
87
+ Write-Log ("original arguments: [" + $appArguments + "]")
88
+ Start-Sleep -Milliseconds 900
89
+ $graceful = $false
90
+ try { $null = & taskkill.exe /PID $ParentPid 2>&1 } catch { Write-Log ("taskkill error: " + $_.Exception.Message) }
91
+ $deadline = (Get-Date).AddSeconds(4)
92
+ while ((Get-Date) -lt $deadline) {
93
+ if (-not (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue)) { $graceful = $true; break }
94
+ Start-Sleep -Milliseconds 250
95
+ }
96
+ if (-not $graceful) {
97
+ # close-to-tray keeps the app alive on WM_CLOSE; stop the Host first, then the
98
+ # shell. Never use /T here: this worker may be a descendant of the Host.
99
+ Write-Log 'graceful quit timed out; forcing'
100
+ if ($HostPid -gt 0 -and $HostPid -ne $PID) {
101
+ try { $null = & taskkill.exe /PID $HostPid /F 2>&1 } catch { Write-Log ("host kill error: " + $_.Exception.Message) }
102
+ Start-Sleep -Milliseconds 400
103
+ }
104
+ try { $null = & taskkill.exe /PID $ParentPid /F 2>&1 } catch { Write-Log ("force kill error: " + $_.Exception.Message) }
105
+ Start-Sleep -Seconds 2
106
+ }
107
+ Start-Sleep -Milliseconds 1200
108
+ function Find-AppProcess([string]$Path) {
109
+ try {
110
+ return @(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -eq $Path })
111
+ } catch {
112
+ return @()
113
+ }
114
+ }
115
+ $started = $false
116
+ for ($attempt = 1; $attempt -le 2 -and -not $started; $attempt++) {
117
+ if ((Find-AppProcess $ExePath).Count -gt 0) { $started = $true; break }
118
+ try {
119
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
120
+ $psi.FileName = $ExePath
121
+ $psi.Arguments = $appArguments
122
+ $psi.WorkingDirectory = (Split-Path -Parent $ExePath)
123
+ $psi.UseShellExecute = $true
124
+ $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Normal
125
+ $process = [System.Diagnostics.Process]::Start($psi)
126
+ Write-Log ("relaunch attempt " + $attempt + " pid=" + $process.Id)
127
+ Start-Sleep -Seconds 4
128
+ if (-not $process.HasExited) { $started = $true }
129
+ else {
130
+ try { Write-Log ("attempt " + $attempt + " exited code=" + $process.ExitCode) } catch {}
131
+ $started = (Find-AppProcess $ExePath).Count -gt 0
132
+ }
133
+ } catch {
134
+ Write-Log ("relaunch attempt " + $attempt + " failed: " + $_.Exception.Message)
135
+ }
136
+ if (-not $started) { Start-Sleep -Seconds 2 }
137
+ }
138
+ if (-not $started) {
139
+ # Last resort: the shell association launch, which does not inherit this
140
+ # process's console or window state.
141
+ Write-Log 'fallback: explorer launch'
142
+ try { $null = & explorer.exe $ExePath } catch { Write-Log ("explorer launch failed: " + $_.Exception.Message) }
143
+ Start-Sleep -Seconds 5
144
+ $started = (Find-AppProcess $ExePath).Count -gt 0
145
+ Write-Log ("fallback started=" + $started)
146
+ }
147
+ Write-Log ("done started=" + $started)
148
+ `
149
+ }
150
+
151
+ /**
152
+ * Write the worker script to disk and return its path.
153
+ * @returns {string} absolute script path.
154
+ */
155
+ export function writeRelaunchScript() {
156
+ const script = relaunchScriptPath()
157
+ fs.mkdirSync(path.dirname(script), { recursive: true })
158
+ fs.writeFileSync(script, helperSource(), 'utf8')
159
+ return script
160
+ }
161
+
162
+ /**
163
+ * Quote one command-line token so Windows/PowerShell parses it as one argument.
164
+ * @param {string} value - raw token.
165
+ * @returns {string} quoted token.
166
+ */
167
+ function quote(value) {
168
+ return `"${String(value).replaceAll('"', '\\"')}"`
169
+ }
170
+
171
+ /**
172
+ * Write the VBS launcher that starts the worker hidden and detached.
173
+ * @param {string} script - worker script path.
174
+ * @param {string} log - worker log path.
175
+ * @param {{ parentPid?: number, hostPid?: number, exePath?: string }} [overrides] - test seams.
176
+ * @returns {string} launcher path.
177
+ */
178
+ export function writeRelaunchLauncher(script, log, overrides = {}) {
179
+ const parentPid = overrides.parentPid ?? process.ppid
180
+ const hostPid = overrides.hostPid ?? process.pid
181
+ const exePath = overrides.exePath ?? process.execPath
182
+ const worker = [
183
+ quote(powershellPath()),
184
+ '-NoProfile',
185
+ '-ExecutionPolicy',
186
+ 'Bypass',
187
+ '-WindowStyle',
188
+ 'Hidden',
189
+ '-File',
190
+ quote(script),
191
+ '-ParentPid',
192
+ String(parentPid),
193
+ '-HostPid',
194
+ String(hostPid),
195
+ '-ExePath',
196
+ quote(exePath),
197
+ '-LogPath',
198
+ quote(log),
199
+ ].join(' ')
200
+ const vbs = [
201
+ 'Set shell = CreateObject("WScript.Shell")',
202
+ `shell.Run "${worker.replaceAll('"', '""')}", 0, False`,
203
+ '',
204
+ ].join('\r\n')
205
+ const launcher = relaunchLauncherPath()
206
+ fs.mkdirSync(path.dirname(launcher), { recursive: true })
207
+ // Windows Script Host reads .vbs as ANSI unless a BOM marks UTF-16; the home
208
+ // path may contain non-ASCII characters, so always write UTF-16LE + BOM.
209
+ const body = Buffer.from(vbs, 'utf16le')
210
+ fs.writeFileSync(launcher, Buffer.concat([Buffer.from([0xff, 0xfe]), body]))
211
+ return launcher
212
+ }
213
+
214
+ /**
215
+ * Start the launcher and wait for it to hand off (wscript exits immediately).
216
+ * @param {number} timeoutMs - upper bound for the launcher itself.
217
+ * @returns {Promise<{ ok: boolean, code: number | null, launcher: string }>} launch result.
218
+ */
219
+ export function requestAppRelaunch(timeoutMs = 8000) {
220
+ const script = writeRelaunchScript()
221
+ const log = relaunchLogPath()
222
+ const launcher = writeRelaunchLauncher(script, log)
223
+ const child = spawn(wscriptPath(), [launcher], { stdio: 'ignore', windowsHide: true })
224
+ return new Promise((resolve) => {
225
+ let settled = false
226
+ const finish = (result) => {
227
+ if (settled) return
228
+ settled = true
229
+ clearTimeout(timer)
230
+ resolve(result)
231
+ }
232
+ const timer = setTimeout(() => finish({ ok: true, code: null, launcher }), timeoutMs)
233
+ timer.unref?.()
234
+ child.on('error', (error) => finish({ ok: false, code: null, launcher, error: error.message }))
235
+ child.on('exit', (code) => finish({ ok: code === 0, code, launcher }))
236
+ })
237
+ }
238
+
239
+ /** @returns {boolean} whether this Host can relaunch the Desktop silently. */
240
+ export function canRelaunchSilently() {
241
+ if (process.platform !== 'win32') return false
242
+ if (!Number.isInteger(process.ppid) || process.ppid <= 0) return false
243
+ try {
244
+ return fs.existsSync(process.execPath) && fs.existsSync(wscriptPath())
245
+ } catch {
246
+ return false
247
+ }
248
+ }
package/lib/state.js ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Durable restart hand-off state.
3
+ *
4
+ * The Host process that schedules a restart writes one JSON record before it
5
+ * exits. The next Host boot reads it and turns it into a continuation prompt.
6
+ * Everything here is synchronous small-file IO: it runs at most once per boot.
7
+ */
8
+ import fs from 'node:fs'
9
+ import os from 'node:os'
10
+ import path from 'node:path'
11
+
12
+ /** Bump when the on-disk shape changes incompatibly. */
13
+ export const STATE_VERSION = 1
14
+
15
+ /** A request older than this is stale and must not revive an old conversation. */
16
+ export const STATE_MAX_AGE_MS = 10 * 60 * 1000
17
+
18
+ /** Boot attempts allowed inside one request before we stop trying. */
19
+ export const STATE_MAX_ATTEMPTS = 3
20
+
21
+ /** Resolve the DSH data directory; the desktop Host sets DSH_HOME explicitly. */
22
+ export function dshHome() {
23
+ const configured = process.env.DSH_HOME?.trim()
24
+ return configured && configured.length > 0 ? configured : path.join(os.homedir(), '.dsh')
25
+ }
26
+
27
+ export function stateDir() {
28
+ return path.join(dshHome(), 'restart')
29
+ }
30
+
31
+ export function statePath() {
32
+ return path.join(stateDir(), 'continuation.json')
33
+ }
34
+
35
+ export function logPath() {
36
+ return path.join(stateDir(), 'log.jsonl')
37
+ }
38
+
39
+ /**
40
+ * Read the pending record.
41
+ * @param {string} [file] - override for tests.
42
+ * @returns {object | undefined} parsed record, or undefined when absent/invalid.
43
+ */
44
+ export function readState(file = statePath()) {
45
+ try {
46
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
47
+ return parsed && typeof parsed === 'object' ? parsed : undefined
48
+ } catch {
49
+ return undefined
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Replace the pending record atomically.
55
+ * @param {object} state - complete record.
56
+ * @param {string} [file] - override for tests.
57
+ */
58
+ export function writeState(state, file = statePath()) {
59
+ fs.mkdirSync(path.dirname(file), { recursive: true })
60
+ const temp = `${file}.${process.pid}.tmp`
61
+ fs.writeFileSync(temp, JSON.stringify(state, null, 2))
62
+ fs.renameSync(temp, file)
63
+ }
64
+
65
+ /**
66
+ * Remove the pending record; absence is not an error.
67
+ * @param {string} [file] - override for tests.
68
+ */
69
+ export function clearState(file = statePath()) {
70
+ try {
71
+ fs.unlinkSync(file)
72
+ } catch {}
73
+ }
74
+
75
+ /**
76
+ * Append one diagnostic line. Logging must never break the restart path.
77
+ * @param {object} entry - fields merged into the line.
78
+ * @param {string} [file] - override for tests.
79
+ */
80
+ export function appendLog(entry, file = logPath()) {
81
+ try {
82
+ fs.mkdirSync(path.dirname(file), { recursive: true })
83
+ fs.appendFileSync(file, `${JSON.stringify({ time: new Date().toISOString(), ...entry })}\n`)
84
+ } catch {}
85
+ }
86
+
87
+ /** Wall-clock time at which this process started. */
88
+ export function bootTime() {
89
+ return Date.now() - Math.round(process.uptime() * 1000)
90
+ }
91
+
92
+ /**
93
+ * Whether a record belongs to a restart this boot can serve.
94
+ * @param {object | undefined} state - candidate record.
95
+ * @param {{ now?: number, boot?: number }} [clock] - injectable clock for tests.
96
+ * @returns {boolean} true when the record is fresh and predates this boot.
97
+ */
98
+ export function isResumable(state, clock = {}) {
99
+ const now = clock.now ?? Date.now()
100
+ const boot = clock.boot ?? bootTime()
101
+ if (!state || typeof state !== 'object') return false
102
+ if (state.version !== STATE_VERSION) return false
103
+ if (typeof state.sessionId !== 'string' || state.sessionId.length === 0) return false
104
+ if (typeof state.createdAt !== 'number' || !Number.isFinite(state.createdAt)) return false
105
+ // The request must have been written by an earlier process: a record that
106
+ // appears after this boot cannot describe a restart this boot is finishing.
107
+ if (state.createdAt > boot) return false
108
+ if (now - state.createdAt > STATE_MAX_AGE_MS) return false
109
+ if ((state.attempts ?? 0) >= STATE_MAX_ATTEMPTS) return false
110
+ return true
111
+ }
112
+
113
+ /**
114
+ * Build the user-visible continuation text injected into the resumed session.
115
+ * @param {object} state - pending record.
116
+ * @returns {string} prompt text.
117
+ */
118
+ export function continuationText(state) {
119
+ const when = new Date(state.turnEndedAt ?? state.createdAt)
120
+ const stamp = Number.isNaN(when.getTime()) ? 'unknown time' : when.toLocaleString('zh-CN')
121
+ const reason = typeof state.reason === 'string' && state.reason.trim().length > 0 ? `重启原因:${state.reason.trim()}\n` : ''
122
+ const instruction =
123
+ typeof state.prompt === 'string' && state.prompt.trim().length > 0
124
+ ? state.prompt.trim()
125
+ : '继续上一次会话中未完成的工作。'
126
+ return [
127
+ '[DSH 自动重启 · 已完成]',
128
+ `宿主进程已于 ${stamp} 重启,当前会话(${state.sessionId})已恢复,之前的对话上下文仍然可用。`,
129
+ reason + `继续指令:${instruction}`,
130
+ '请直接从上次停下的位置继续,不要重复已完成的步骤或重新开始任务;先用一句话说明接下来要做什么,然后继续执行。',
131
+ ].join('\n')
132
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Restart and continue",
4
+ "description": "Restart DSH Desktop from an agent tool and resume the same conversation automatically."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "重启并续聊",
4
+ "description": "让 Agent 主动重启 DSH Desktop,并在新进程启动后自动恢复同一会话继续工作。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "dsh-restart-continue",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Restart DSH Desktop from an agent tool and resume the same conversation automatically.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "license": "MIT",
8
+ "author": "LISHUANGXI1412",
9
+ "homepage": "https://github.com/LISHUANGXI1412/dsh-restart-continue",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/LISHUANGXI1412/dsh-restart-continue"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/LISHUANGXI1412/dsh-restart-continue/issues"
16
+ },
17
+ "keywords": [
18
+ "dsh",
19
+ "dsh-plugin",
20
+ "restart",
21
+ "resume",
22
+ "session"
23
+ ],
24
+ "engines": {
25
+ "node": ">=22"
26
+ },
27
+ "exports": {
28
+ ".": "./index.js",
29
+ "./locale/*.json": "./locale/*.json",
30
+ "./icon": "./icon.svg"
31
+ },
32
+ "files": [
33
+ "index.js",
34
+ "lib/",
35
+ "cordis.patch.yml",
36
+ "locale/*.json",
37
+ "icon.svg",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "scripts": {
42
+ "test": "node --test"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "dsh": {
48
+ "bundle": {
49
+ "patch": "./cordis.patch.yml"
50
+ }
51
+ }
52
+ }