dsh-crossterm 0.9.3 → 0.9.4
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/CHANGELOG.md +12 -0
- package/README.md +9 -11
- package/components/restart/core.js +49 -0
- package/components/restart/manifest.json +1 -1
- package/components/restart/scripts/busy-check.mjs +39 -1
- package/components/restart/scripts/countdown.html +24 -13
- package/components/restart/scripts/dsh-restart-helper.mjs +29 -2
- package/lib/bus.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 0.9.4 — 2026-10-20
|
|
2
|
+
|
|
3
|
+
自动重启组件 **0.3.28 → 0.3.29**(只动 `components/restart/**`;由该组件负责人 `a3e9f80e` 提交、主维护会话发版)。插件内其余五个组件不变。
|
|
4
|
+
|
|
5
|
+
- **忙时判定补第三路信号**:`components/restart/core.js` 每 2 秒把所有 agent 的 `{sessionId,inTurn}` 写 `logs/busy-agents.json`;`scripts/busy-check.mjs` 新增 `heartbeatBusy()`(文件 8 秒内有效,`inTurn` 且不是自己即算忙),`othersBusy()` 合并「市场运行列表 + transcript 写入时间 + 回合心跳」三路,`sources` 记 `heartbeat:busy-agents` / `heartbeat:unavailable`。
|
|
6
|
+
- 背景:长工具调用期间 transcript 不写盘,市场 `runningAgents` 也会掉出列表 —— 旧的两路判据会把「真在跑」判成空闲,于是在别人干活时重启。
|
|
7
|
+
- **守卫终检**:helper 的等待下限 3000 → **250 ms**;守卫通过后 400 ms 再探一次,有人开工就带剩余预算重新等(最多 3 次,日志 `idle guard recheck#N`)。
|
|
8
|
+
- **倒计时与正计时对齐**:`scripts/countdown.html` 归零后先停在 `00:00` + 「倒计时结束,正在准备重启…」,只有 helper 的 ui-state 进 `killing`/`starting` 才切 `+mm:ss`。
|
|
9
|
+
- 背景:helper 起得晚时窗口已归零而 helper 还在等,被读成「没读完就变正计时」,实际 kill 还在 `scheduledAt` 之后 1~5 秒。
|
|
10
|
+
|
|
11
|
+
> 本机是 `link:` 源码版:重启后 `ensureRestartRuntime()` 因版本变化(`.version` 0.9.3 → 0.9.4)把 8 个脚本整组重拷到 `<总线目录>\restart-module`。
|
|
12
|
+
|
|
1
13
|
## 0.9.3 — 2026-10-20
|
|
2
14
|
|
|
3
15
|
修 0.9.2 的两处显示缺陷(代码逻辑没变,只动 `GET /crossterm/api/about` 与 ui 组件清单):
|
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
|
|
|
23
23
|
| ③ | 跨终端 | v0.9.2 | 同一台机器上的多个 DSH 终端/进程之间互发邮件与文件 | `term_whoami` `term_list` `term_send` `term_send_files` |
|
|
24
24
|
| ④ | 跨设备 | v0.9.2 | 经云端中转站,在多台机器的 DSH 之间互发邮件与文件 | `term_cloud` |
|
|
25
25
|
| ⑤ | 跨账号 | v0.9.2 | 预留位:后续用于跨账号互通,当前不挂工具 | — |
|
|
26
|
-
| ⑥ | 自动重启 | v0.3.
|
|
26
|
+
| ⑥ | 自动重启 | v0.3.29 | 改完插件/配置后让 DSH 自己重启一次,并自动拉起离线会话(原 `dsh-restart` 插件,0.9.0 起并入) | `dsh_restart` |
|
|
27
27
|
|
|
28
28
|
> 入口行为什么必须用裸包名:浏览器 client 半端只对裸包名行生效(`dsh-client-modules` 的
|
|
29
29
|
> `locatePkgJson()` 对子路径行返回 undefined),而插件页顶部标题与这一行共用同一份 `locale/zh.json` 的 meta,
|
|
@@ -31,20 +31,18 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
|
|
|
31
31
|
>
|
|
32
32
|
> 组件版本号怎么升:**只在该组件自己的文件改动时升它**,跟着插件一起发版(例:只改自动重启 → `components/restart` 的 `0.3.28` → `0.3.29`,其余五个不动)。插件总版本仍按发版技能的三分位规则。
|
|
33
33
|
|
|
34
|
-
**本版更新(0.9.
|
|
34
|
+
**本版更新(0.9.4)**
|
|
35
35
|
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
36
|
+
- **只管本次变动**:插件总版本 0.9.3 → 0.9.4,组件里只有**自动重启 0.3.28 → 0.3.29**,其余五个(入口 0.9.3 / 跨会话 0.9.2 / 跨终端 0.9.2 / 跨设备 0.9.2 / 跨账号 0.9.2)不动。
|
|
37
|
+
- **重启前先确认没人干活(第三路信号)**:插件每 2 秒把「哪些会话正在回合中」写进 `<总线目录>\restart-module\logs\busy-agents.json`,重启守卫读它 —— 长工具调用期间 transcript 不写盘、市场运行列表也会掉,过去靠这两路会误判空闲。
|
|
38
|
+
- **守卫终检 + 倒计时对齐**:守卫放行后 400 ms 再确认一次;倒计时归零先停 `00:00`(「正在准备重启…」),等 helper 真开始杀进程才转正计时。
|
|
39
39
|
|
|
40
|
-
**上一版(0.9.
|
|
40
|
+
**上一版(0.9.3)**
|
|
41
41
|
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
- **组件维护分出去**:`MAINTAINERS.md` 的 `modules` 段记录每个组件的负责人;自动重启 = `a3e9f80e`,其余由主维护会话 `b6aa099b` 负责。
|
|
45
|
-
- 兼容:`cordis.patch.yml` 的 `name` 改为 `dsh-crossterm/components/<part>`,`exports` 同步;升级后重启 DSH 生效。
|
|
42
|
+
- 组件表补全:`/crossterm/api/about` 的 `components` 现在六个组件都在(之前漏了自动重启),入口名从 `ui` 改成「跨端互通(入口)」。
|
|
43
|
+
- 组件版本号各升各的第一次落地:那次只改 ui,所以只有它 0.9.2 → 0.9.3。
|
|
46
44
|
|
|
47
|
-
**更早(0.9.0 / 0.8.14 / 0.8.13 / 0.8.12 / 0.8.11 / 0.8.10 / 0.8.9 / 0.8.8 / 0.8.7 / 0.8.6 / 0.8.5 / 0.8.0)**
|
|
45
|
+
**更早(0.9.2 / 0.9.0 / 0.8.14 / 0.8.13 / 0.8.12 / 0.8.11 / 0.8.10 / 0.8.9 / 0.8.8 / 0.8.7 / 0.8.6 / 0.8.5 / 0.8.0)**
|
|
48
46
|
|
|
49
47
|
- 0.9.0:自动重启并进来当第 6 个组件(`dsh_restart` + 8 个 helper 脚本),由原 `dsh-restart` 会话继续维护、不再单独发版。
|
|
50
48
|
- 0.8.14:装完未重启时卡片显示「待重启」、更新按钮变灰「已装待重启」,`/check` 回 `needRestart=true`。
|
|
@@ -465,6 +465,48 @@ function scheduleRevive(root) {
|
|
|
465
465
|
mark(`revive: scheduled in ${REVIVE_DELAY_MS}ms`)
|
|
466
466
|
}
|
|
467
467
|
|
|
468
|
+
// ── 忙时心跳 ────────────────────────────────────────────────────────────────
|
|
469
|
+
// 判定「谁还在回合里」最可靠的信号在宿主进程内:agent.session.events 的最后一条是
|
|
470
|
+
// turn/start 还是 turn/end。市场 /dsh-market/status 的 runningAgents 在工具调用期间
|
|
471
|
+
// 会掉出去(实测),transcript mtime 在长工具调用期间也不写盘 —— 两个都会误判空闲。
|
|
472
|
+
// 所以插件每 2 秒把「所有 agent 的 id + 是否在回合中」写成 logs\busy-agents.json,
|
|
473
|
+
// helper 侧的 busy-check.mjs 拿它当第三路信号。
|
|
474
|
+
const BUSY_HEARTBEAT = path.join(LOG_DIR, 'busy-agents.json')
|
|
475
|
+
|
|
476
|
+
function inTurn(agent) {
|
|
477
|
+
try {
|
|
478
|
+
const events = agent && agent.session && agent.session.events
|
|
479
|
+
if (!events || !events.length) return false
|
|
480
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
481
|
+
const type = String((events[i] && events[i].type) || '')
|
|
482
|
+
if (type === 'turn/end') return false
|
|
483
|
+
if (type === 'turn/start') return true
|
|
484
|
+
}
|
|
485
|
+
return false
|
|
486
|
+
} catch { return false }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function writeBusyHeartbeat(ctx) {
|
|
490
|
+
try {
|
|
491
|
+
const seen = new Map()
|
|
492
|
+
const add = (agent) => {
|
|
493
|
+
const id = String((agent && agent.id) || '')
|
|
494
|
+
if (!id || seen.has(id)) return
|
|
495
|
+
seen.set(id, { sessionId: id, inTurn: inTurn(agent) })
|
|
496
|
+
}
|
|
497
|
+
try { for (const agent of ctx.agents.roots()) add(agent) } catch { /* ignore */ }
|
|
498
|
+
try {
|
|
499
|
+
for (const s of (ctx.sessions?.list?.() || [])) {
|
|
500
|
+
try { add(ctx.agents?.get?.(s.id)) } catch { /* ignore */ }
|
|
501
|
+
}
|
|
502
|
+
} catch { /* ignore */ }
|
|
503
|
+
fs.mkdirSync(LOG_DIR, { recursive: true })
|
|
504
|
+
fs.writeFileSync(BUSY_HEARTBEAT, JSON.stringify({ at: new Date().toISOString(), agents: [...seen.values()] }), 'utf8')
|
|
505
|
+
} catch (e) {
|
|
506
|
+
mark(`busy heartbeat failed: ${(e && e.message) || String(e)}`)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
468
510
|
export default {
|
|
469
511
|
name: 'crossterm-restart',
|
|
470
512
|
apply(root) {
|
|
@@ -515,6 +557,13 @@ export default {
|
|
|
515
557
|
return () => clearInterval(timer)
|
|
516
558
|
}, 'dsh-restart: 会话工具补挂')
|
|
517
559
|
|
|
560
|
+
writeBusyHeartbeat(ctx)
|
|
561
|
+
ctx.effect(() => {
|
|
562
|
+
const timer = setInterval(() => writeBusyHeartbeat(ctx), 2000)
|
|
563
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
564
|
+
return () => clearInterval(timer)
|
|
565
|
+
}, 'dsh-restart: 忙时心跳')
|
|
566
|
+
|
|
518
567
|
ctx.on('agent/created', ({ agent }) => mount(agent))
|
|
519
568
|
ctx.on('agent/disposed', ({ agent }) => { mounted.delete(agent) })
|
|
520
569
|
mark('ready')
|
|
@@ -9,10 +9,17 @@
|
|
|
9
9
|
import http from 'node:http'
|
|
10
10
|
import fs from 'node:fs'
|
|
11
11
|
import path from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
12
13
|
|
|
13
14
|
export const QUIET_MS = 3000
|
|
14
15
|
export const DEFAULT_PORT = Number(process.env.DSH_RESTART_PORT || 19387)
|
|
15
16
|
|
|
17
|
+
// 心跳文件:插件(宿主进程内)每 2 秒写一次「谁还在回合中」,比市场 runningAgents
|
|
18
|
+
// (工具调用期间会掉出列表)和 transcript mtime(长工具调用期间不写盘)都准。
|
|
19
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
20
|
+
const HEARTBEAT_FILE = process.env.DSH_BUSY_HEARTBEAT || path.join(HERE, 'logs', 'busy-agents.json')
|
|
21
|
+
const HEARTBEAT_FRESH_MS = Number(process.env.DSH_BUSY_HEARTBEAT_MS || 8000)
|
|
22
|
+
|
|
16
23
|
const norm = (id) => String(id ?? '').replace(/^session-/, '')
|
|
17
24
|
|
|
18
25
|
function httpGetJson(port, pathName, timeoutMs) {
|
|
@@ -110,16 +117,47 @@ export async function othersBusy({ selfSessionId, port = DEFAULT_PORT, recentMs
|
|
|
110
117
|
const recent = recentSessionWriter({ selfSessionId: self, recentMs, dshHome })
|
|
111
118
|
if (recent) {
|
|
112
119
|
sources.push(`transcript-mtime(${recentMs}ms)`)
|
|
113
|
-
if (!busy.some((b) => b.sessionId === recent.sessionId)) {
|
|
120
|
+
if (!busy.some((b) => norm(b.sessionId) === norm(recent.sessionId))) {
|
|
114
121
|
busy.push({ sessionId: recent.sessionId, why: 'transcript-write' })
|
|
115
122
|
}
|
|
116
123
|
} else {
|
|
117
124
|
sources.push('transcript-mtime:quiet')
|
|
118
125
|
}
|
|
119
126
|
|
|
127
|
+
const hb = heartbeatBusy({ selfSessionId: self })
|
|
128
|
+
if (hb.stale) {
|
|
129
|
+
sources.push('heartbeat:unavailable')
|
|
130
|
+
} else {
|
|
131
|
+
sources.push('heartbeat:busy-agents')
|
|
132
|
+
for (const item of hb.busy) {
|
|
133
|
+
if (!busy.some((b) => norm(b.sessionId) === norm(item.sessionId))) busy.push(item)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
120
137
|
return { busy, sources, checkedAt: new Date().toISOString() }
|
|
121
138
|
}
|
|
122
139
|
|
|
140
|
+
/** 读插件写的 busy-agents.json:文件够新才采信,其中 inTurn 的会话都算忙。 */
|
|
141
|
+
export function heartbeatBusy({ selfSessionId, heartbeatFile = HEARTBEAT_FILE, freshMs = HEARTBEAT_FRESH_MS } = {}) {
|
|
142
|
+
try {
|
|
143
|
+
const stat = fs.statSync(heartbeatFile)
|
|
144
|
+
if (Date.now() - stat.mtimeMs > freshMs) return { stale: true, busy: [] }
|
|
145
|
+
const raw = fs.readFileSync(heartbeatFile, 'utf8').replace(/^\uFEFF/, '')
|
|
146
|
+
const data = JSON.parse(raw)
|
|
147
|
+
const agents = Array.isArray(data && data.agents) ? data.agents : []
|
|
148
|
+
const self = norm(selfSessionId)
|
|
149
|
+
const busy = []
|
|
150
|
+
for (const agent of agents) {
|
|
151
|
+
const id = String((agent && agent.sessionId) || '')
|
|
152
|
+
if (!id || norm(id) === self) continue
|
|
153
|
+
if (agent && agent.inTurn === true) busy.push({ sessionId: id, why: 'agent-in-turn' })
|
|
154
|
+
}
|
|
155
|
+
return { stale: false, busy }
|
|
156
|
+
} catch {
|
|
157
|
+
return { stale: true, busy: [] }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
123
161
|
/**
|
|
124
162
|
* 等到「连续 quietMs 内没有其他会话活动」。
|
|
125
163
|
* maxWaitMs=0 表示只探一次(用于排程时的快速拒绝)。
|
|
@@ -381,23 +381,34 @@ function fitWindow() {
|
|
|
381
381
|
}
|
|
382
382
|
if (!spawned) { el("clock").textContent = "00:00"; el("fill").style.width = "0%"; zeroAt = Date.now(); spawnHelper(); }
|
|
383
383
|
|
|
384
|
-
//
|
|
384
|
+
// 归零后先停在 00:00 等 helper 真动手(守卫可能还在等空闲),
|
|
385
|
+
// 只有 helper 进入 killing/starting 才切正计时 + 转圈,避免“倒计时没读完就开始 +”。
|
|
386
|
+
var u = readJson(UI);
|
|
387
|
+
var phase = u && u.phase ? String(u.phase) : "";
|
|
388
|
+
var killing = phase === "killing" || phase === "starting";
|
|
385
389
|
if (zeroAt && !done) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
el("fill").style.display
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
390
|
+
if (killing) {
|
|
391
|
+
el("clock").textContent = "+" + mmss((Date.now() - zeroAt) / 1000);
|
|
392
|
+
if (el("fill").style.display !== "none") {
|
|
393
|
+
el("fill").style.display = "none";
|
|
394
|
+
var bar = el("bar");
|
|
395
|
+
if (bar) bar.className = "bar off";
|
|
396
|
+
var sp = el("spin");
|
|
397
|
+
if (sp) sp.className = "spin on";
|
|
398
|
+
dbg("spinner on");
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
el("clock").textContent = "00:00";
|
|
402
|
+
if (el("fill").style.display !== "none") {
|
|
403
|
+
el("fill").style.width = "0%";
|
|
404
|
+
}
|
|
394
405
|
}
|
|
395
406
|
}
|
|
396
407
|
|
|
397
|
-
|
|
398
|
-
if (
|
|
399
|
-
else if (
|
|
400
|
-
else if (
|
|
408
|
+
if (phase === "killing") el("hint").textContent = "正在结束 DSH 进程…";
|
|
409
|
+
else if (phase === "starting") el("hint").textContent = "正在启动 DSH…";
|
|
410
|
+
else if (phase === "waiting") el("hint").textContent = "等待其他会话空闲后重启…";
|
|
411
|
+
else if (zeroAt && !killing) el("hint").textContent = "倒计时结束,正在准备重启…";
|
|
401
412
|
|
|
402
413
|
var r = readJson(RES);
|
|
403
414
|
var when = r ? Date.parse(r.finishedAt || r.at || "") : NaN;
|
|
@@ -157,7 +157,9 @@ async function main() {
|
|
|
157
157
|
log(`pre-kill port ${PORT}: ${(await portOpen()) ? 'open' : 'closed'}`);
|
|
158
158
|
|
|
159
159
|
let waitMs = targetMs - Date.now();
|
|
160
|
-
|
|
160
|
+
// 不再强加 3 秒下限:以前 helper 起得比窗口晚时会被推到 scheduledAt 之后,
|
|
161
|
+
// 窗口那边倒计时已经归零、这边还没动手,于是“没读完就变正计时”。现在只留 250ms 防呆。
|
|
162
|
+
if (waitMs < 250) waitMs = 250;
|
|
161
163
|
log(`waiting ${waitMs}ms before kill`);
|
|
162
164
|
ui('waiting', `还剩 ${Math.round(waitMs / 1000)} 秒`);
|
|
163
165
|
// 等待期间每 500ms 复查请求是否被 cancel / 被新排程替换:撤销了就立刻作废,绝不杀进程。
|
|
@@ -199,13 +201,38 @@ async function main() {
|
|
|
199
201
|
const quietWaitSec = Math.max(0, Number(req.quietWaitSec ?? 180));
|
|
200
202
|
const selfSessionId = req.selfSessionId || req.sessionId || '';
|
|
201
203
|
log(`idle guard: self=${selfSessionId || '(unknown)'} quietWait=${quietWaitSec}s`);
|
|
202
|
-
const
|
|
204
|
+
const guardStart = Date.now();
|
|
205
|
+
const guardDeadline = guardStart + quietWaitSec * 1000;
|
|
206
|
+
let quiet = await waitQuiet({
|
|
203
207
|
selfSessionId,
|
|
204
208
|
maxWaitMs: quietWaitSec * 1000,
|
|
205
209
|
onPoll: ({ elapsedMs, busy }) => {
|
|
206
210
|
if (busy.length > 0) log(`idle guard waiting ${Math.round(elapsedMs / 1000)}s: busy=${JSON.stringify(busy)}`);
|
|
207
211
|
},
|
|
208
212
|
});
|
|
213
|
+
// 终检:静默窗刚满足时,可能有会话正好在这几秒里开了新回合。
|
|
214
|
+
// 连续两次「立刻探测」都空闲才真放行;中间有人开工就重新进等待(共用同一预算)。
|
|
215
|
+
if (quiet.ok) {
|
|
216
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
217
|
+
await sleep(400);
|
|
218
|
+
const probe = await waitQuiet({ selfSessionId, maxWaitMs: 0 });
|
|
219
|
+
if (!probe.ok) {
|
|
220
|
+
log(`idle guard recheck#${attempt}: 有人在这几秒里开工 -> 重新等待:${JSON.stringify(probe.busy)}`);
|
|
221
|
+
const remaining = Math.max(0, guardDeadline - Date.now());
|
|
222
|
+
quiet = await waitQuiet({
|
|
223
|
+
selfSessionId,
|
|
224
|
+
maxWaitMs: remaining,
|
|
225
|
+
onPoll: ({ elapsedMs, busy }) => {
|
|
226
|
+
if (busy.length > 0) log(`idle guard re-wait ${Math.round(elapsedMs / 1000)}s: busy=${JSON.stringify(busy)}`);
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
if (!quiet.ok) break;
|
|
230
|
+
} else {
|
|
231
|
+
log(`idle guard recheck#${attempt}: still quiet`);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
209
236
|
log(`idle guard result: ${JSON.stringify(quiet)}`);
|
|
210
237
|
if (!quiet.ok) {
|
|
211
238
|
const result = { ok: false, dryRun: false, reason: 'others-busy', busy: quiet.busy, waitedMs: quiet.waitedMs, at: new Date().toISOString(), logFile };
|
package/lib/bus.js
CHANGED
|
@@ -23,7 +23,7 @@ import path from 'node:path'
|
|
|
23
23
|
import crypto from 'node:crypto'
|
|
24
24
|
import { rotateIfNeeded } from './logs.js'
|
|
25
25
|
|
|
26
|
-
export const VERSION = '0.9.
|
|
26
|
+
export const VERSION = '0.9.4'
|
|
27
27
|
|
|
28
28
|
/** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
|
|
29
29
|
export function dshHome() {
|