dsh-restart-module 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +160 -2
- package/cordis.patch.yml +9 -0
- package/lib/index.js +337 -0
- package/package.json +20 -3
package/README.md
CHANGED
|
@@ -1,3 +1,161 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-restart — 会话内自重启 DSH
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
给 DSH 加一个模型可调用的工具 `dsh_restart`:任意会话(桌面 GUI / `dsh web` / QQ 机器人 / 其它终端)
|
|
4
|
+
在写完文件、改完插件或配置之后,可以自己排一次重启让改动生效,不再需要外部 `restart-dsh-*.bat`。
|
|
5
|
+
|
|
6
|
+
## 为什么不用 dshmarket 的一键重启
|
|
7
|
+
|
|
8
|
+
市场插件(`dshmarket` 1.66.21)自带 self-restart,但在本机桌面版上结构不成立:
|
|
9
|
+
|
|
10
|
+
1. `dshArgv()`(`dshmarket/src/dsh-cli.ts:468`)只在 `process.argv[1]` 匹配
|
|
11
|
+
`/[\\/](?:bin\.(?:js|ts)|dsh)$/` 时才重放原命令;桌面宿主的 argv[1] 是
|
|
12
|
+
`...\dsh-desktop-host\lib\index.js`,匹配不上 → 回退成裸 `dsh`,
|
|
13
|
+
重放出来的是 web CLI 而不是 `F:\dsh\DeepSeek Harness.exe`。
|
|
14
|
+
2. 它的 helper 由宿主 `spawn(detached)` 出来,仍在宿主的 Job 里;宿主 500 ms 后
|
|
15
|
+
`process.kill(process.pid,'SIGTERM')` 自杀,Job 关闭会把 helper 一起带走 —— 没有活口重拉。
|
|
16
|
+
|
|
17
|
+
所以本插件复用已经实测跑通的独立重启器 `F:\dsh\001X\restart-module`:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
dsh_restart(schedule)
|
|
21
|
+
└─ node trigger-restart.mjs --delay N
|
|
22
|
+
├─ 写 request.json
|
|
23
|
+
└─ WMI Win32_Process.Create('cmd.exe /c run-helper.cmd') ← 脱离 DSH 进程树与 Job
|
|
24
|
+
└─ node dsh-restart-helper.mjs
|
|
25
|
+
├─ 等到 scheduledAt
|
|
26
|
+
├─ taskkill /F /T /IM "DeepSeek Harness.exe"
|
|
27
|
+
├─ 等 8 个进程与 127.0.0.1:19387 双双消失
|
|
28
|
+
├─ spawn F:\dsh\DeepSeek Harness.exe(清理 ELECTRON_RUN_AS_NODE / DSH_* 环境)
|
|
29
|
+
└─ 轮询端口最多 120 s,失败重拉一次,写 logs\result.json
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
实测:杀进程到端口重新可访问 ≈ **2.1 s**,会话靠 `session/end-seed` 自动恢复,
|
|
33
|
+
只丢失当时正在执行的那一次工具调用。
|
|
34
|
+
|
|
35
|
+
## 工具
|
|
36
|
+
|
|
37
|
+
单个工具 `dsh_restart`,用 `action` 区分三种操作:
|
|
38
|
+
|
|
39
|
+
| action | 作用 | 常用参数 |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| `schedule`(默认) | 排程一次重启 | `delay_sec`(5–3600,默认 60)、`note`、`dry_run`、`force`(默认 false,见守卫节)、`wake`(默认 true,见唤醒节)、`wake_note` |
|
|
42
|
+
| `status` | 查上次结果 / 是否有待执行请求 / 最近一条插件日志 | — |
|
|
43
|
+
| `cancel` | 杀掉等待中的 helper 并删 `request.json`;即使只删 `request.json`,helper 也会在 0.5 s 内自查作废、绝不杀进程(实测 03:59:12Z pid=32868 killed,App 未重启) | — |
|
|
44
|
+
|
|
45
|
+
返回里带 `scheduledAt`(ISO UTC)、`helperPid`、`delaySec`;`dry_run: true` 只验证链路不杀进程。
|
|
46
|
+
|
|
47
|
+
典型用法(agent 写完文件后):
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{ "action": "schedule", "delay_sec": 30, "note": "装完插件让改动生效" }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 忙时禁重启守卫
|
|
54
|
+
|
|
55
|
+
「其他会话正在跑任务时不许重启」是默认行为,防止把别人的回合打断在半路。
|
|
56
|
+
|
|
57
|
+
- **排程阶段**:`trigger-restart.mjs` 先探一次——`GET /dsh-market/status` 的 `runningAgents`,加上各会话
|
|
58
|
+
transcript(`session.v4.jsonl.zstd`)mtime 是否在 3 秒内被写过;除发起会话外还有活人就直接拒绝
|
|
59
|
+
(退出码 7,工具返回 `reason: "others-busy"`),不写 `request.json`、不启动 helper。
|
|
60
|
+
- **到点阶段**:helper 在 kill 之前再等「连续 3 秒没有任何其他会话活动」,最长等 `quietWaitSec`
|
|
61
|
+
(默认 180 秒,排程时写进 `request.json`);等不到就中止——**不杀进程**,写
|
|
62
|
+
`{ "ok": false, "reason": "others-busy", "busy": [...] }` 到 `result.json`,退出码 3。
|
|
63
|
+
- **用户明确同意**(例如「现在就重启,别等了」)时传 `force: true`:两步都跳过,只在日志里记一行。
|
|
64
|
+
不带 `force` 的自动重启永远不会打断别的会话。
|
|
65
|
+
- **判据实现**:`F:\dsh\001X\restart-module\busy-check.mjs`(只读,不写落盘)。可单独跑:
|
|
66
|
+
`node F:\dsh\001X\restart-module\busy-check.mjs --self session-xxxx --max-wait 0`(忙时退出码 3)。
|
|
67
|
+
- **邮件告知/获准**那条通路暂未实现,等与 `dsh-crossterm` 互通插件配对后再接;`request.json` 里留了
|
|
68
|
+
`"notify": "pending-crossterm-integration"` 占位。
|
|
69
|
+
|
|
70
|
+
## 重启后自动唤醒(wake)
|
|
71
|
+
|
|
72
|
+
重启会打断当前回合;重启后桌面 App 会恢复上次会话,但不会自己继续——要等人输入。默认开启的 wake 补的就是这一步:
|
|
73
|
+
|
|
74
|
+
- helper 在 kill 之前把 `{ sessionId, text, createdAt }` 写进 `F:\dsh\001X\restart-module\logs\wake.json`。
|
|
75
|
+
- 新宿主起来、插件把工具挂到该会话时读这张卡(10 分钟内有效、`sessionId` 必须匹配),2.5 秒后调
|
|
76
|
+
`agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })` —— 这是一条普通用户消息,
|
|
77
|
+
会立刻唤醒会话继续干活;成功后 `plugin.log` 留一行 `wake: injected into <sessionId>`。
|
|
78
|
+
- 默认文案:「DSH 已于 <时间> 完成重启,请继续刚才的工作;若上一回合被中断,先核查中断点再续。」
|
|
79
|
+
可用 `wake_note` 追加说明;不想唤醒传 `wake: false`(helper 日志记 `wake: skipped`)。
|
|
80
|
+
- 卡片只有 10 分钟寿命:重启没发生(被 cancel、被守卫中止)时过期自动丢弃,不会误唤醒。
|
|
81
|
+
- 与「邮件告知/获准」是两件事:wake 是重启后的自唤醒,不依赖 `dsh-crossterm`。
|
|
82
|
+
- **实测(2026-10-18T03:46:40Z)**:`idle guard` 等满 3.019 s → 写卡 → kill 8 进程(0 s 消失)→ 新宿主 2.15 s 拉起 →
|
|
83
|
+
`03:46:46.483Z wake: injected into session-a3e9f80e-…`,即**杀进程到会话自动开工 ≈6.3 s**,`wake.json` 随后被消费删除。
|
|
84
|
+
|
|
85
|
+
## 时间口径
|
|
86
|
+
|
|
87
|
+
- 工具参数 `delay_sec` 是**相对秒数**,与服务器时区无关。
|
|
88
|
+
- 日志与返回值里的 `scheduledAt` / `startedAt` / `finishedAt` 是 **ISO 8601 UTC**(结尾 `Z`),
|
|
89
|
+
本地时间 = UTC + 8(`15:54:09Z` = `23:54:09`)。
|
|
90
|
+
- `plugin.log` / `helper-*.log` 用的是带 `Z` 的 UTC 时间戳。
|
|
91
|
+
|
|
92
|
+
## 安装 / 卸载
|
|
93
|
+
|
|
94
|
+
**npm 包名 `dsh-restart-module`**(0.2.0 起;`dsh-restart` 这个名字在 npm 上被别人占用了,
|
|
95
|
+
与本插件无关)。本机开发源仍用 `link:` 安装,其他机器用包名安装。
|
|
96
|
+
|
|
97
|
+
```bat
|
|
98
|
+
:: 其他机器(npm)
|
|
99
|
+
dsh plugin --profile desktop add dsh-restart-module
|
|
100
|
+
dsh plugin --profile desktop remove dsh-restart-module
|
|
101
|
+
|
|
102
|
+
:: 本机开发源(link:,改源码即生效)
|
|
103
|
+
dsh plugin --profile desktop add link:F:\dsh\001X\dsh-restart
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
手工改 profile(等价):在 `C:\Users\Administrator\.dsh\profiles\desktop\package.json` 的
|
|
107
|
+
`dependencies` 加 `"dsh-restart-module": "link:F:/dsh/001X/dsh-restart"`,并把 `dsh-restart-module`
|
|
108
|
+
加进 `dsh.profile.bundles`;再建 `node_modules\dsh-restart-module` → `F:\dsh\001X\dsh-restart` 的符号链接。
|
|
109
|
+
改动插件源码后需要重启 DSH 才生效(可以用它自己的 `dsh_restart` 来重启)。
|
|
110
|
+
|
|
111
|
+
## 配置
|
|
112
|
+
|
|
113
|
+
| 变量 | 默认 | 说明 |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| `DSH_RESTART_MODULE_DIR` | `F:\dsh\001X\restart-module` | 重启器目录,需包含 `trigger-restart.mjs` |
|
|
116
|
+
| `DSH_NODE_EXE` | 内置 runtime node | 调 trigger 用的 node,默认 `...\dsh-primary-runtime\dependencies\node\bin\node.exe` |
|
|
117
|
+
|
|
118
|
+
## 日志
|
|
119
|
+
|
|
120
|
+
| 文件 | 内容 |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| `restart-module\logs\plugin.log` | 插件侧:何时挂到哪个 agent、排程调用与错误 |
|
|
123
|
+
| `restart-module\logs\helper-<时间>.log` | helper 侧:kill / 等端口 / 拉起 / 重试全过程 |
|
|
124
|
+
| `restart-module\logs\helper-console.log` | `run-helper.cmd` 的 stdout/stderr |
|
|
125
|
+
| `restart-module\logs\result.json` | 最近一次结果 `{ok, dryRun, startedAt, finishedAt, port, procs, logFile}` |
|
|
126
|
+
| `restart-module\request.json` | 最近一次排程请求 `{delaySec, scheduledAt, note, ...}` |
|
|
127
|
+
|
|
128
|
+
## 挂载方式(踩过的坑)
|
|
129
|
+
|
|
130
|
+
- 用 **per-agent** 注册:`agent.ctx.effect(() => agent.ctx.tools.register(tool))`,不要全局注册。
|
|
131
|
+
- 不要只看 `ctx.agents.roots()`:被 resume 的会话可能既不在 `roots()` 里,也等不到
|
|
132
|
+
`agent/created`,所以每 5 s 用「`ctx.sessions.list()` × `ctx.agents.get(id)`」补挂一次。
|
|
133
|
+
- 验证工具是否真的进了模型,别用 `agent.ctx.tools.schemas(agent.ctx)`(它只回 global 层,
|
|
134
|
+
`term_*`、`qq_push` 都不在里面)。权威依据是会话日志里的 `request/header` 事件:
|
|
135
|
+
解帧后用 `F:\dsh\001X\restart-module\_tools-hist.cjs` 打印 tools 名单。
|
|
136
|
+
|
|
137
|
+
## 已知限制
|
|
138
|
+
|
|
139
|
+
- **唤醒注入必须带 `id` + `role`(0.1.1 起,重要)**:`agent.followup()` 的官方样例形状
|
|
140
|
+
`{ content, source }` 落盘后会缺 `id`,DSH 重放会话时判 `session event at seq N lacks an identified message`,
|
|
141
|
+
该会话直接打不开。现行写法:
|
|
142
|
+
`followup({ id: crypto.randomUUID(), role: 'user', content: [{type:'text',text}], source: {kind:'user'} })`。
|
|
143
|
+
0.1.0 曾在 `session-a3e9f80e` 上污染过 `seq 3508 / 3734`(由 crossterm 主维护会话修回),**别退回旧形状**。
|
|
144
|
+
0.1.1 已于 2026-10-18T04:20Z 由主维护会话用自身会话端到端验证:注入事件 `seq 3297` 带
|
|
145
|
+
`id=4bfbf697-…`、`role="user"`、`source={"kind":"user"}`,全机 `selfcheck.mjs` 30 会话 0 命中、exit 0。
|
|
146
|
+
- `taskkill /F /T /IM "DeepSeek Harness.exe"` 会杀掉该镜像的**所有**实例(含测试 profile)。
|
|
147
|
+
- 走 WMI 拉起,helper 可能延迟几秒到 ~20 s 才真正开始计时;`delay_sec` 是从 `createdAt` 起算,会兜底至少 3 s。
|
|
148
|
+
- 15 分钟内已有 helper 在跑时 `--delay` 会被拒绝(退出码 3),用 `status` 看 `helper.pid`。
|
|
149
|
+
- 非优雅退出:重启瞬间正在执行的那次工具调用会作废(回复本身若已落盘不受影响)。
|
|
150
|
+
- 没有实现 dshmarket 那种「重启失败时的 recovery 页」;失败时看 `logs\result.json` 和 `helper-*.log`,或跑
|
|
151
|
+
`F:\dsh\001X\restart-module\revert-plugin-and-relaunch.cmd`。
|
|
152
|
+
|
|
153
|
+
## 回滚
|
|
154
|
+
|
|
155
|
+
```bat
|
|
156
|
+
copy /y F:\dsh\001X\backups\profile-desktop\package.json.20261017-233708.bak C:\Users\Administrator\.dsh\profiles\desktop\package.json
|
|
157
|
+
rmdir C:\Users\Administrator\.dsh\profiles\desktop\node_modules\dsh-restart
|
|
158
|
+
taskkill /F /IM "DeepSeek Harness.exe" & timeout /t 4 >nul & start "" "F:\dsh\DeepSeek Harness.exe"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
或直接跑 `F:\dsh\001X\restart-module\revert-plugin-and-relaunch.cmd`。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# dsh-restart-module —— 会话内自重启(排程杀掉并重拉 DSH 桌面进程)
|
|
2
|
+
#
|
|
3
|
+
# npm 安装:dsh plugin --profile desktop add dsh-restart-module
|
|
4
|
+
# 本地源码装:dsh plugin --profile desktop add link:F:\dsh\001X\dsh-restart
|
|
5
|
+
# 卸载:dsh plugin --profile desktop remove dsh-restart-module
|
|
6
|
+
# 改动本插件源码后,需要重启 DSH 才生效(可用它自己的 dsh_restart 工具来重启)。
|
|
7
|
+
- insert:
|
|
8
|
+
- id: dsh-restart-module
|
|
9
|
+
name: dsh-restart-module
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-restart —— 在会话内把 DSH 自身重启一次(替代外部 restart-dsh-*.bat)。
|
|
3
|
+
*
|
|
4
|
+
* 重启链路(restart-module,已多次端到端实测):
|
|
5
|
+
* 写 request.json → WMI Win32_Process.Create 起 cmd.exe(脱离 DSH 进程树与 Job)
|
|
6
|
+
* → cmd 跑 run-helper.cmd → node dsh-restart-helper.mjs
|
|
7
|
+
* → 等到 scheduledAt → taskkill /F /T /IM "DeepSeek Harness.exe"
|
|
8
|
+
* → 等进程与 19387 端口双消失 → 重新拉起 F:\dsh\DeepSeek Harness.exe
|
|
9
|
+
* → 轮询端口最多 120s,失败重拉一次。
|
|
10
|
+
* 实测:杀进程到端口可访问 ≈ 2.1s,会话靠 end-seed 自动恢复。
|
|
11
|
+
*
|
|
12
|
+
* 工具挂载(照 dsh-crossterm 的实战写法):
|
|
13
|
+
* - 每个 agent 的 scope 里注册(agent.ctx.tools.register),不用全局注册;
|
|
14
|
+
* - 不能只认 ctx.agents.roots():已存在的/被 resume 的会话不一定在 roots() 里,
|
|
15
|
+
* 也等不到 agent/created,所以每 5 秒用「会话列表 × 活着的 agent」补挂一次。
|
|
16
|
+
*/
|
|
17
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
18
|
+
import { execFile } from 'node:child_process'
|
|
19
|
+
import crypto from 'node:crypto'
|
|
20
|
+
import fs from 'node:fs'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
|
|
23
|
+
const MODULE_DIR = process.env.DSH_RESTART_MODULE_DIR || 'F:\\dsh\\001X\\restart-module'
|
|
24
|
+
const TRIGGER = path.join(MODULE_DIR, 'trigger-restart.mjs')
|
|
25
|
+
const LOG_DIR = path.join(MODULE_DIR, 'logs')
|
|
26
|
+
const PID_FILE = path.join(LOG_DIR, 'helper.pid')
|
|
27
|
+
const RESULT_FILE = path.join(LOG_DIR, 'result.json')
|
|
28
|
+
const REQUEST_FILE = path.join(MODULE_DIR, 'request.json')
|
|
29
|
+
const MARK_FILE = path.join(LOG_DIR, 'plugin.log')
|
|
30
|
+
const DEFAULT_DELAY = 60
|
|
31
|
+
|
|
32
|
+
function mark(line) {
|
|
33
|
+
try {
|
|
34
|
+
fs.mkdirSync(LOG_DIR, { recursive: true })
|
|
35
|
+
fs.appendFileSync(MARK_FILE, `[${new Date().toISOString()}] ${line}\n`)
|
|
36
|
+
} catch { /* 日志失败不影响功能 */ }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function textOf(value) {
|
|
40
|
+
return [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function nodeExe() {
|
|
44
|
+
const explicit = process.env.DSH_NODE_EXE
|
|
45
|
+
if (explicit && fs.existsSync(explicit)) return explicit
|
|
46
|
+
const bundled = 'C:\\Users\\Administrator\\.dsh\\dsh-runtimes\\dsh-primary-runtime\\dependencies\\node\\bin\\node.exe'
|
|
47
|
+
if (fs.existsSync(bundled)) return bundled
|
|
48
|
+
return 'node'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readJson(file) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''))
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function tail(file, lines = 6) {
|
|
60
|
+
try {
|
|
61
|
+
return fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/).filter(Boolean).slice(-lines)
|
|
62
|
+
} catch {
|
|
63
|
+
return []
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function pidAlive(pid) {
|
|
68
|
+
if (!Number.isFinite(pid) || pid <= 0) return false
|
|
69
|
+
try {
|
|
70
|
+
process.kill(pid, 0)
|
|
71
|
+
return true
|
|
72
|
+
} catch {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function runTrigger(args) {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const env = { ...process.env }
|
|
80
|
+
delete env.ELECTRON_RUN_AS_NODE
|
|
81
|
+
delete env.ELECTRON_NO_ATTACH_CONSOLE
|
|
82
|
+
execFile(nodeExe(), [TRIGGER, ...args], {
|
|
83
|
+
cwd: MODULE_DIR,
|
|
84
|
+
env,
|
|
85
|
+
timeout: 120000,
|
|
86
|
+
windowsHide: true,
|
|
87
|
+
encoding: 'utf8',
|
|
88
|
+
}, (err, stdout, stderr) => {
|
|
89
|
+
resolve({ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, stdout: stdout || '', stderr: stderr || '' })
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function scheduleArgs(args, selfSessionId) {
|
|
95
|
+
const asked = Number(args?.delay_sec)
|
|
96
|
+
const delay = Number.isFinite(asked) ? Math.min(3600, Math.max(5, Math.round(asked))) : DEFAULT_DELAY
|
|
97
|
+
const cli = ['--delay', String(delay)]
|
|
98
|
+
const note = typeof args?.note === 'string' ? args.note.trim() : ''
|
|
99
|
+
if (note) cli.push('--note', note)
|
|
100
|
+
if (args?.dry_run === true) cli.push('--dry-run')
|
|
101
|
+
if (args?.force === true) cli.push('--force')
|
|
102
|
+
if (selfSessionId) cli.push('--self-session', String(selfSessionId))
|
|
103
|
+
return { cli, delay }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function execute(args, sessionId) {
|
|
107
|
+
const action = typeof args?.action === 'string' ? args.action : 'schedule'
|
|
108
|
+
|
|
109
|
+
if (action === 'status') {
|
|
110
|
+
const result = readJson(RESULT_FILE)
|
|
111
|
+
const request = readJson(REQUEST_FILE)
|
|
112
|
+
const pid = Number((readJson(PID_FILE) || {}).pid ?? NaN)
|
|
113
|
+
const alive = pidAlive(pid)
|
|
114
|
+
const schedAt = request && request.scheduledAt ? Date.parse(request.scheduledAt) : NaN
|
|
115
|
+
const finishedAt = result ? Date.parse(result.finishedAt || result.at || '') : NaN
|
|
116
|
+
const alreadyRan = Number.isFinite(schedAt) && Number.isFinite(finishedAt) && finishedAt >= schedAt
|
|
117
|
+
const pending = alive && !alreadyRan
|
|
118
|
+
const pendingText = pending
|
|
119
|
+
? `待执行:${request.scheduledAt}(delay ${request.delaySec}s,helper pid ${pid})`
|
|
120
|
+
: (alreadyRan ? `无待执行请求(${request.scheduledAt} 那次已执行完毕)` : '无待执行请求')
|
|
121
|
+
return {
|
|
122
|
+
ok: true,
|
|
123
|
+
action,
|
|
124
|
+
detail: [
|
|
125
|
+
alive ? `helper 在跑(pid ${pid})` : '当前没有活着的 helper',
|
|
126
|
+
pendingText,
|
|
127
|
+
result ? `上次结果:ok=${result.ok} ${result.startedAt || result.at || ''} → ${result.finishedAt || ''} port=${result.port || '?'}${result.reason ? ` reason=${result.reason}` : ''}` : '还没有重启记录',
|
|
128
|
+
].join(';'),
|
|
129
|
+
helper: { pid: pending ? pid : null, pendingRequest: pending ? request : null },
|
|
130
|
+
lastResult: result || null,
|
|
131
|
+
lastPluginLog: tail(MARK_FILE, 1)[0] || null,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (action === 'cancel') {
|
|
136
|
+
let pid = Number((readJson(PID_FILE) || {}).pid ?? NaN)
|
|
137
|
+
if (!Number.isFinite(pid)) {
|
|
138
|
+
// helper 写的是纯文本 pid(不是 JSON),这里得按文本兜底
|
|
139
|
+
try { pid = Number(String(fs.readFileSync(PID_FILE, 'utf8')).trim()) } catch { /* ignore */ }
|
|
140
|
+
}
|
|
141
|
+
const alive = pidAlive(pid)
|
|
142
|
+
let killed = false
|
|
143
|
+
if (alive) {
|
|
144
|
+
try {
|
|
145
|
+
execFile('taskkill', ['/F', '/PID', String(pid)], { windowsHide: true }, () => {})
|
|
146
|
+
killed = true
|
|
147
|
+
} catch { /* ignore */ }
|
|
148
|
+
}
|
|
149
|
+
try { fs.rmSync(REQUEST_FILE, { force: true }) } catch { /* ignore */ }
|
|
150
|
+
if (alive) { try { fs.rmSync(PID_FILE, { force: true }) } catch { /* ignore */ } }
|
|
151
|
+
mark(`cancel: pid=${pid} alive=${alive} killed=${killed}`)
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
action,
|
|
155
|
+
detail: killed ? `已取消:结束等待中的 helper(pid ${pid})并删除请求文件` : '没有等待中的重启,已清理请求文件',
|
|
156
|
+
helperPid: alive ? pid : null,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (action !== 'schedule') {
|
|
161
|
+
return { ok: false, action, detail: `未知 action "${action}",只支持 schedule / status / cancel` }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const { cli, delay } = scheduleArgs(args, sessionId)
|
|
165
|
+
if (args?.wake === false) cli.push('--no-wake')
|
|
166
|
+
if (typeof args?.wake_note === 'string' && args.wake_note.trim()) cli.push('--wake-text', args.wake_note.trim())
|
|
167
|
+
const r = await runTrigger(cli)
|
|
168
|
+
mark(`schedule: code=${r.code} args=${JSON.stringify(cli)} stdout=${r.stdout.trim().slice(0, 400)}`)
|
|
169
|
+
if (r.code === 7) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
action,
|
|
173
|
+
reason: 'others-busy',
|
|
174
|
+
detail: `${(r.stderr || r.stdout).trim().slice(0, 600)} 要么等它们结束(到点前会等 3 秒静默),要么由用户明确同意后加 force:true 重试。${sessionId ? '' : '(本会话 id 未知,保守拒绝)'}`,
|
|
175
|
+
stdout: r.stdout.trim(),
|
|
176
|
+
stderr: r.stderr.trim(),
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (r.code !== 0) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
action,
|
|
183
|
+
detail: `排程失败(exit ${r.code}):${(r.stderr || r.stdout).trim().slice(0, 600)}`,
|
|
184
|
+
stdout: r.stdout.trim(),
|
|
185
|
+
stderr: r.stderr.trim(),
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const pidMatch = /spawned cmd pid=(\d+)/i.exec(r.stdout)
|
|
189
|
+
const atMatch = /scheduled at ([^\r\n]+)/i.exec(r.stdout)
|
|
190
|
+
return {
|
|
191
|
+
ok: true,
|
|
192
|
+
action,
|
|
193
|
+
delaySec: delay,
|
|
194
|
+
scheduledAt: atMatch ? atMatch[1].trim() : new Date(Date.now() + delay * 1000).toISOString(),
|
|
195
|
+
helperPid: pidMatch ? Number(pidMatch[1]) : null,
|
|
196
|
+
dryRun: args?.dry_run === true,
|
|
197
|
+
detail: args?.dry_run === true
|
|
198
|
+
? `dry-run 已排程:${delay}s 后只做检查、不杀进程(详情见 ${RESULT_FILE})`
|
|
199
|
+
: `已排程 ${delay}s 后重启 DSH。本会话届时被中断,重启后会自动恢复;本次回复会在此之前落盘。`,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function buildTool(agentId) {
|
|
204
|
+
return defineTool({
|
|
205
|
+
name: 'dsh_restart',
|
|
206
|
+
description:
|
|
207
|
+
'在会话内把 DSH 自身重启一次(替代外部 restart-dsh-*.bat)。适合改完插件/配置、写完文件后让改动生效:'
|
|
208
|
+
+ 'schedule(默认)排程一次重启——先落盘当前回复,再由脱离进程树的独立 helper 杀掉宿主并重新拉起桌面 App,'
|
|
209
|
+
+ '会话重启后自动恢复;status 查上次重启结果与是否有待执行请求;cancel 撤销还没触发的重启。'
|
|
210
|
+
+ '注意:排程后本会话会被中断,delay_sec 至少 5 秒(默认 60),要给回复留出发送时间。'
|
|
211
|
+
+ '守卫:默认禁止在其他会话正在跑任务时重启——排程会被拒(exit 7),helper 到点前也会等「连续 3 秒无其他会话活动」,超时则中止不杀进程。'
|
|
212
|
+
+ '只有用户明确同意(例如「现在就重启」)时才传 force=true 跳过守卫。'
|
|
213
|
+
+ '默认重启成功后会自动唤醒本会话(helper 先留 wake.json,重启后插件注入一条“继续工作”的用户消息),需要静默重启时传 wake=false。',
|
|
214
|
+
parameters: {
|
|
215
|
+
action: { type: 'string', enum: ['schedule', 'status', 'cancel'], description: 'schedule 排程重启(默认);status 查状态;cancel 取消未触发的重启' },
|
|
216
|
+
delay_sec: { type: 'number', description: '排程模式下多少秒后重启,5–3600,默认 60。要给当前回复留出发送时间,别小于 15' },
|
|
217
|
+
note: { type: 'string', description: '写进 request.json 与日志的备注(例如“装完 dsh-restart 插件”)' },
|
|
218
|
+
dry_run: { type: 'boolean', description: '只验证链路,不杀进程(默认 false)' },
|
|
219
|
+
force: { type: 'boolean', description: '用户明确同意时置 true:即使其他会话在跑任务也立刻重启(默认 false)' },
|
|
220
|
+
wake: { type: 'boolean', description: '重启后是否自动唤醒本会话(默认 true):helper 杀进程前留 wake.json,重启后插件注入一条“继续工作”的用户消息;置 false 关闭' },
|
|
221
|
+
wake_note: { type: 'string', description: '唤醒消息里附加的说明(可选,例如“继续写剩下的两节”)' },
|
|
222
|
+
},
|
|
223
|
+
output: {
|
|
224
|
+
schema: { type: 'json' },
|
|
225
|
+
render: (_args, value) => textOf(value && value.detail ? value.detail : value),
|
|
226
|
+
},
|
|
227
|
+
async execute(args) {
|
|
228
|
+
try {
|
|
229
|
+
return await execute(args || {}, agentId)
|
|
230
|
+
} catch (e) {
|
|
231
|
+
mark('execute error: ' + ((e && e.stack) || String(e)))
|
|
232
|
+
return { ok: false, detail: 'dsh_restart 执行异常:' + ((e && e.message) || String(e)) }
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 重启后自动唤醒:helper 在杀进程前写 logs\wake.json,这里在 agent 挂载后把它注入成一条用户消息。
|
|
239
|
+
const WAKE_FILE = path.join(LOG_DIR, 'wake.json')
|
|
240
|
+
|
|
241
|
+
function takeWake(agentId) {
|
|
242
|
+
let raw = ''
|
|
243
|
+
try { raw = fs.readFileSync(WAKE_FILE, 'utf8').replace(/^\uFEFF/, '') } catch { return null }
|
|
244
|
+
let wake = null
|
|
245
|
+
try { wake = JSON.parse(raw) } catch { wake = null }
|
|
246
|
+
if (!wake) { try { fs.rmSync(WAKE_FILE, { force: true }) } catch { /* ignore */ } return null }
|
|
247
|
+
if (wake.sessionId && agentId && wake.sessionId !== agentId) return null
|
|
248
|
+
const age = Date.now() - Date.parse(wake.createdAt || '')
|
|
249
|
+
try { fs.rmSync(WAKE_FILE, { force: true }) } catch { /* ignore */ }
|
|
250
|
+
if (!Number.isFinite(age) || age > 10 * 60 * 1000) {
|
|
251
|
+
mark(`wake: stale (age=${Math.round(age / 1000)}s), dropped`)
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
return wake
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function wakeAgent(agent, wake) {
|
|
258
|
+
const target = typeof agent?.followup === 'function' ? agent : agent?.agent
|
|
259
|
+
if (!target || typeof target.followup !== 'function') {
|
|
260
|
+
mark(`wake: agent ${agent?.id} has no followup(); skipped`)
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
const timer = setTimeout(() => {
|
|
264
|
+
try {
|
|
265
|
+
// 必须带 id / role:DSH 重放会话时会校验「user/message 的 data 本身就是消息」,
|
|
266
|
+
// 缺 id 直接判 corrupt(Error: session event at seq N lacks an identified message)。
|
|
267
|
+
// 官方样例 followup({content, source}) 就是残缺形状,踩过一次(2026-10-18,a3e9f80e 会话被污染)。
|
|
268
|
+
target.followup({
|
|
269
|
+
id: crypto.randomUUID(),
|
|
270
|
+
role: 'user',
|
|
271
|
+
content: [{ type: 'text', text: String(wake.text || '') }],
|
|
272
|
+
source: { kind: 'user' },
|
|
273
|
+
})
|
|
274
|
+
mark(`wake: injected into ${agent.id}`)
|
|
275
|
+
} catch (e) {
|
|
276
|
+
mark(`wake: inject failed on ${agent.id}: ${(e && e.message) || String(e)}`)
|
|
277
|
+
}
|
|
278
|
+
}, 2500)
|
|
279
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export default {
|
|
283
|
+
name: 'dsh-restart',
|
|
284
|
+
apply(root) {
|
|
285
|
+
root.inject(['agents'], (ctx) => {
|
|
286
|
+
const mounted = new Map()
|
|
287
|
+
|
|
288
|
+
const mount = (agent) => {
|
|
289
|
+
if (!agent || mounted.has(agent)) return
|
|
290
|
+
try {
|
|
291
|
+
const dispose = agent.ctx.effect(() => {
|
|
292
|
+
const disposers = []
|
|
293
|
+
try {
|
|
294
|
+
const maybe = agent.ctx.tools.register(buildTool(agent.id))
|
|
295
|
+
if (typeof maybe === 'function') disposers.push(maybe)
|
|
296
|
+
} catch (e) {
|
|
297
|
+
mark(`register throw on agent ${agent.id}: ${(e && e.message) || String(e)}`)
|
|
298
|
+
}
|
|
299
|
+
return () => {
|
|
300
|
+
for (const fn of disposers) {
|
|
301
|
+
try { fn() } catch { /* ignore */ }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}, 'dsh-restart: dsh_restart tool')
|
|
305
|
+
mounted.set(agent, dispose)
|
|
306
|
+
mark(`mounted on agent ${agent.id}`)
|
|
307
|
+
const wake = takeWake(agent.id)
|
|
308
|
+
if (wake) wakeAgent(agent, wake)
|
|
309
|
+
} catch (e) {
|
|
310
|
+
mark(`mount failed on agent ${agent && agent.id}: ${(e && e.message) || String(e)}`)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const reconcile = () => {
|
|
315
|
+
try {
|
|
316
|
+
let list = []
|
|
317
|
+
try { list = ctx.sessions?.list?.() || [] } catch { list = [] }
|
|
318
|
+
for (const s of list) {
|
|
319
|
+
try { const agent = ctx.agents?.get?.(s.id); if (agent) mount(agent) } catch { /* ignore */ }
|
|
320
|
+
}
|
|
321
|
+
} catch { /* ignore */ }
|
|
322
|
+
try { for (const agent of ctx.agents.roots()) mount(agent) } catch { /* ignore */ }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
reconcile()
|
|
326
|
+
ctx.effect(() => {
|
|
327
|
+
const timer = setInterval(reconcile, 5000)
|
|
328
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
329
|
+
return () => clearInterval(timer)
|
|
330
|
+
}, 'dsh-restart: 会话工具补挂')
|
|
331
|
+
|
|
332
|
+
ctx.on('agent/created', ({ agent }) => mount(agent))
|
|
333
|
+
ctx.on('agent/disposed', ({ agent }) => { mounted.delete(agent) })
|
|
334
|
+
mark('ready')
|
|
335
|
+
})
|
|
336
|
+
},
|
|
337
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-restart-module",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "会话内自重启:任何会话都能排程「杀掉本机 DSH 桌面进程并自动拉回」,替代 restart-dsh-*.bat",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib",
|
|
9
|
+
"cordis.patch.yml",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
14
|
+
},
|
|
15
|
+
"dsh": {
|
|
16
|
+
"bundle": {
|
|
17
|
+
"patch": "./cordis.patch.yml"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@deepseek-ai/dsh-tools": "*"
|
|
22
|
+
}
|
|
6
23
|
}
|