dsh-retrace 0.4.28 → 0.4.29
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 +14 -8
- package/README.zh.md +10 -5
- package/lib/boot-pin.js +94 -0
- package/lib/client.bundle.js +8 -4
- package/lib/client.js +30 -16
- package/lib/close-guard-client.js +39 -12
- package/lib/close-guard.js +130 -0
- package/lib/dynamic-client.js +8 -4
- package/lib/http.js +14 -3
- package/lib/index.js +26 -29
- package/lib/watchdog.js +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -360,7 +360,7 @@ Read it before filing an issue.
|
|
|
360
360
|
### Upgrading
|
|
361
361
|
|
|
362
362
|
```bash
|
|
363
|
-
dsh plugin --profile desktop add dsh-retrace@0.4.
|
|
363
|
+
dsh plugin --profile desktop add dsh-retrace@0.4.29
|
|
364
364
|
# then restart DSH — plugins are not hot-reloaded
|
|
365
365
|
```
|
|
366
366
|
|
|
@@ -439,13 +439,19 @@ the **host surface**, not by semver alone.
|
|
|
439
439
|
| 🛡️ | **Running-work detection** | every session is scanned for live work: agent running, queued inbox items, background jobs, unclosed turns |
|
|
440
440
|
| 📋 | **Running banner** | sessions with live work show a persistent in-page banner (short session code + reasons), so you can see it before quitting |
|
|
441
441
|
| ⚠️ | **Exit prompt** | on plugin dispose (app exit / reload) a Chinese notice lists each running session and why it is considered busy — it only warns, it never cancels your running agent |
|
|
442
|
-
| 🔒 | **Page-close interception (
|
|
443
|
-
| 🔎 | **Query surface** | `retrace.runningState` (host RPC) + `GET|POST /api/plugins/retrace/runningState` (HTTP) — same shape on both transports |
|
|
444
|
-
|
|
445
|
-
> Desktop note:
|
|
446
|
-
>
|
|
447
|
-
>
|
|
448
|
-
>
|
|
442
|
+
| 🔒 | **Page-close interception (web browsers)** | `beforeunload` interception, armed only where the host reports a native confirm dialog: a strong confirm when work is running (details modal, `[仍关闭]` = confirm-and-go), a light confirm otherwise |
|
|
443
|
+
| 🔎 | **Query surface** | `retrace.runningState` (host RPC) + `GET|POST /api/plugins/retrace/runningState` (HTTP) — same shape on both transports; the all-sessions shape also carries the host-reported page surface (`surface` / `quitVeto`) |
|
|
444
|
+
|
|
445
|
+
> Desktop note: quit entry points differ by version/platform, and the DSH Desktop Electron
|
|
446
|
+
> shell we inspected has no `will-prevent-unload` handler (0 hits across the packaged 2.0.9
|
|
447
|
+
> `app.asar`). Where the entry does reach the page — the external report's DSH Desktop 0.9.0 /
|
|
448
|
+
> Windows — the page `beforeunload` veto is **swallowed silently**: no dialog, no feedback, and
|
|
449
|
+
> the exit looks stuck (only a force-quit works). Where it does not — the 2.0.9 shell we
|
|
450
|
+
> inspected routes the tray item through `requestQuit(0) → window.destroy() → app.exit(0)` — the
|
|
451
|
+
> quit is unaffected either way. The page cannot tell which case it is in, so desktop **never**
|
|
452
|
+
> arms the native gate; it relies on the running banner plus the dispose notice. The gate is
|
|
453
|
+
> armed only where the host reports that the page really surfaces a native dialog
|
|
454
|
+
> (`quitVeto: true`, i.e. browser pages).
|
|
449
455
|
|
|
450
456
|
> Command surface: `retrace.runningState` (host RPC) + `GET|POST /api/plugins/retrace/runningState` (HTTP).
|
|
451
457
|
|
package/README.zh.md
CHANGED
|
@@ -326,11 +326,16 @@ dsh plugin --profile desktop add dsh-retrace@0.4.26
|
|
|
326
326
|
| 🛡️ | **运行中检测** | 逐会话扫描运行中工作:agent 正在跑 / inbox 排队 / 后台 jobs / 未闭合轮 |
|
|
327
327
|
| 📋 | **运行中横幅** | 有运行中工作的会话显示页面常驻横幅(会话短码 + 原因),退出前可见 |
|
|
328
328
|
| ⚠️ | **退出提示** | 插件 dispose(应用退出/重载)时中文提示列出每个运行中会话与原因——只提示,绝不代你取消 agent |
|
|
329
|
-
| 🔒 |
|
|
330
|
-
| 🔎 | **查询面** | `retrace.runningState`(host RPC)+ `GET|POST /api/plugins/retrace/runningState`(HTTP
|
|
331
|
-
|
|
332
|
-
>
|
|
333
|
-
>
|
|
329
|
+
| 🔒 | **页面关闭拦截(普通浏览器)** | `beforeunload` 拦截,只在宿主回报会弹原生确认框的页面上武装:有运行中任务强确认(明细模态,`[仍关闭]` 即确认离开),无任务轻确认 |
|
|
330
|
+
| 🔎 | **查询面** | `retrace.runningState`(host RPC)+ `GET|POST /api/plugins/retrace/runningState`(HTTP),两入口同形状;全会话形状另带宿主判定的承载面(`surface` / `quitVeto`) |
|
|
331
|
+
|
|
332
|
+
> 桌面说明:**退出入口随版本/平台而变**,而我们检查的 Electron 壳没有处理 `will-prevent-unload`
|
|
333
|
+
> (装好的 2.0.9 `app.asar` 全文检索 0 命中)。**会走到该入口的那类版本**(外部报告所在的
|
|
334
|
+
> DSH Desktop 0.9.0 / Windows)上,页面 `beforeunload` 否决被**静默吞掉**:不弹界面、不给
|
|
335
|
+
> 反馈,表现为退出卡住(只能强退);**不走该入口的版本**(我们检查的 2.0.9:托盘项走
|
|
336
|
+
> `requestQuit(0) → window.destroy() → app.exit(0)`)上,退出本来就不受影响。页面里分不出
|
|
337
|
+
> 自己属于哪一类,因此**桌面端一律不武装**原生门,保护由运行中横幅 + dispose 提示承担;
|
|
338
|
+
> 只有宿主回报"这个页面会弹原生确认框"(`quitVeto: true`,即普通浏览器页)时才武装。
|
|
334
339
|
|
|
335
340
|
**未来计划**——agent 业务层规划(运行时守护、中断治理、生态开放接口)**尚未发布**,此节是**计划**而非已上线能力。本 README 描述的是**开发线(main)**,可能领先于 npm 上最新发布版。
|
|
336
341
|
|
package/lib/boot-pin.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace — lib/boot-pin.js
|
|
3
|
+
*
|
|
4
|
+
* 启动批量 pin 的**重试调度**(唯一职责:什么时候再跑一次)。
|
|
5
|
+
*
|
|
6
|
+
* 从 lib/index.js 迁出的理由(2026-09-18 外部 issue #1:桌面端托盘退出死锁):
|
|
7
|
+
* 原实现是内联的递归 `setTimeout`,句柄被丢弃、既不 `unref` 也不在 dispose 里清。
|
|
8
|
+
* 宿主是 Node 侧 —— 一条 30s 递归(最多 20 次 ≈ 启动后 10 分钟)会把宿主事件循环
|
|
9
|
+
* 钉住,退出/关机流程得等它跑完(桌面端退不掉的两个拖累之一,另一个见
|
|
10
|
+
* lib/watchdog.js)。
|
|
11
|
+
*
|
|
12
|
+
* 与 lib/watchdog.js 同款:定时器创建/销毁可注入(`schedule` / `unschedule`),
|
|
13
|
+
* 于是"句柄有没有 unref""dispose 有没有撤销"能被用例直接捕获,不靠 review。
|
|
14
|
+
* 职责边界:本模块只决定"何时重试";"重试做什么"由 `run()` 注入(装配点传
|
|
15
|
+
* pinAllResident),"现在有没有可处理的会话"由 `hasResidentSessions()` 注入。
|
|
16
|
+
* 本模块零 import、零宿主服务引用(可单测,也进得了动态件 realm)。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 默认重试上限(对齐 0.4.19 的启动窗口:20 × 30s ≈ 启动后 10 分钟)。 */
|
|
20
|
+
export const BOOT_PIN_MAX_ATTEMPTS = 20
|
|
21
|
+
/** 默认重试间隔。 */
|
|
22
|
+
export const BOOT_PIN_DELAY_MS = 30_000
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 建启动重试链。
|
|
26
|
+
* @param {object} options
|
|
27
|
+
* @param {() => Promise<any>} options.run 实际执行体(返回值里的 `value.total` 用于日志)。
|
|
28
|
+
* @param {() => boolean} options.hasResidentSessions 现在有没有可处理的会话。
|
|
29
|
+
* @param {(line: string) => void} [options.log] 诊断日志。
|
|
30
|
+
* @param {number} [options.maxAttempts] 重试上限(含首跑)。
|
|
31
|
+
* @param {number} [options.delayMs] 重试间隔。
|
|
32
|
+
* @param {(cb: () => void, ms: number) => unknown} [options.schedule] 定时器创建
|
|
33
|
+
* (默认全局 setTimeout;返回的句柄上若有 `unref` 会被调用)。
|
|
34
|
+
* @param {(handle: unknown) => void} [options.unschedule] 定时器销毁(默认全局 clearTimeout)。
|
|
35
|
+
* @returns {{ dispose(): void }} dispose = 停止重试链 + 撤掉挂着的定时器(幂等)。
|
|
36
|
+
*/
|
|
37
|
+
export function createBootPinRetry({
|
|
38
|
+
run,
|
|
39
|
+
hasResidentSessions,
|
|
40
|
+
log = () => {},
|
|
41
|
+
maxAttempts = BOOT_PIN_MAX_ATTEMPTS,
|
|
42
|
+
delayMs = BOOT_PIN_DELAY_MS,
|
|
43
|
+
schedule = (cb, ms) => setTimeout(cb, ms),
|
|
44
|
+
unschedule = (handle) => clearTimeout(handle),
|
|
45
|
+
} = {}) {
|
|
46
|
+
if (typeof run !== 'function') throw new TypeError('createBootPinRetry: run must be a function')
|
|
47
|
+
if (typeof hasResidentSessions !== 'function') throw new TypeError('createBootPinRetry: hasResidentSessions must be a function')
|
|
48
|
+
|
|
49
|
+
let timer = null
|
|
50
|
+
let disposed = false
|
|
51
|
+
|
|
52
|
+
/** 挂下一次(序号 = next)。序号到顶就不挂,链自然收尾。 */
|
|
53
|
+
function arm(next) {
|
|
54
|
+
if (disposed || next >= maxAttempts) return
|
|
55
|
+
timer = schedule(() => {
|
|
56
|
+
timer = null
|
|
57
|
+
step(next + 1)
|
|
58
|
+
}, delayMs)
|
|
59
|
+
// 宿主是 Node 侧:不 unref ⇒ 这条 30s 递归把事件循环钉到启动后 10 分钟,
|
|
60
|
+
// 退出/关机被它拖住。unref 后宿主该退就退(会话仍在时事件循环自有别的句柄撑着)。
|
|
61
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function step(current) {
|
|
65
|
+
if (disposed) return
|
|
66
|
+
const finish = () => arm(current)
|
|
67
|
+
if (!hasResidentSessions()) {
|
|
68
|
+
finish()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
Promise.resolve()
|
|
72
|
+
.then(() => run())
|
|
73
|
+
.then((result) => {
|
|
74
|
+
log(`retrace: bootPin(${current}) → ${result?.value?.total ?? 0} 个驻留会话标题已处理`)
|
|
75
|
+
})
|
|
76
|
+
.catch((error) => {
|
|
77
|
+
log(`retrace: bootPin error: ${String(error).slice(0, 160)}`)
|
|
78
|
+
})
|
|
79
|
+
.then(finish)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
step(0)
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
/** 停止重试链并撤掉挂着的定时器(重复调用安全)。 */
|
|
86
|
+
dispose() {
|
|
87
|
+
disposed = true
|
|
88
|
+
if (timer !== null) {
|
|
89
|
+
unschedule(timer)
|
|
90
|
+
timer = null
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
}
|
package/lib/client.bundle.js
CHANGED
|
@@ -258,6 +258,9 @@ function buildRunningCopy(snapshot, { locale = "zh", labelOf, now = Date.now(),
|
|
|
258
258
|
const hint = stale ? lang === "zh" ? "(\u72B6\u6001\u540C\u6B65\u4E8E\u8F83\u65E9\u65F6\u523B,\u53EF\u80FD\u5DF2\u53D8\u5316)" : "(state may be stale)" : void 0;
|
|
259
259
|
return { head, lines, hint, stale };
|
|
260
260
|
}
|
|
261
|
+
function quitVetoOf(snapshot) {
|
|
262
|
+
return snapshot?.quitVeto === true;
|
|
263
|
+
}
|
|
261
264
|
function createGuardStore({ now = Date.now, armTtlMs = GUARD_ARM_TTL_MS } = {}) {
|
|
262
265
|
let snapshot = null;
|
|
263
266
|
let armedAt = 0;
|
|
@@ -469,7 +472,7 @@ var zh = {
|
|
|
469
472
|
"path.round": "\u7B2C {n} \u8F6E",
|
|
470
473
|
"path.empty": "\uFF08\u5F53\u524D\u6CA1\u6709\u5BF9\u8BDD\u8F6E\u6B21\uFF09",
|
|
471
474
|
"options.closeGuard": "\u9000\u51FA\u786E\u8BA4\uFF08\u5173\u95ED\u5B88\u536B\uFF09",
|
|
472
|
-
"options.closeGuardDesc": "\u5F00\uFF08\u9ED8\u8BA4\uFF09\uFF1A\u9875\u9762
|
|
475
|
+
"options.closeGuardDesc": "\u5F00\uFF08\u9ED8\u8BA4\uFF09\uFF1A\u9875\u9762\u5173\u95ED\u524D\u786E\u8BA4\u9000\u51FA\u2014\u2014\u6709\u8FD0\u884C\u4E2D\u4EFB\u52A1\u6216\u672A\u5B8C\u6210\u5BF9\u8BDD\u65F6\u5F3A\u62E6\u5E76\u5217\u51FA\u660E\u7EC6\uFF08\u9632\u8BEF\u5173\u4E22\u8FDB\u5EA6\uFF09\uFF0C\u65E0\u4EFB\u52A1\u65F6\u8F7B\u786E\u8BA4\u4E00\u6B21\u3002\u53EA\u5728\u5BBF\u4E3B\u786E\u8BA4\u4F1A\u663E\u793A\u539F\u751F\u786E\u8BA4\u6846\u7684\u9875\u9762\u4E0A\u6B66\u88C5\uFF08\u666E\u901A\u6D4F\u89C8\u5668\u6807\u7B7E\u9875\uFF1B\u5173\u6389\u672C\u9879\u540C\u65F6\u505C\u7528\u8FD0\u884C\u4E2D\u6A2A\u5E45\uFF09\u3002\u684C\u9762\u7AEF\uFF08DSH Desktop\uFF09\u4E00\u5F8B\u4E0D\u6B66\u88C5\uFF1A\u9000\u51FA\u5165\u53E3\u968F\u7248\u672C/\u5E73\u53F0\u800C\u53D8\u2014\u2014\u5916\u90E8\u62A5\u544A\u7684 0.9.0 \u4E00\u7C7B\u5165\u53E3\u4F1A\u89E6\u53D1\u9875\u9762 beforeunload\uFF0C\u800C\u684C\u9762\u58F3\u672A\u5904\u7406 will-prevent-unload \u21D2 \u5426\u51B3\u88AB\u9759\u9ED8\u541E\u6389\u3001\u9000\u51FA\u5361\u4F4F\uFF1B2.0.9 \u7684\u6258\u76D8\u9879\u8D70 requestQuit\u2192window.destroy\u2192app.exit\uFF0C\u6839\u672C\u4E0D\u7ECF\u8FC7\u5B83\u3002\u9875\u9762\u91CC\u5206\u4E0D\u51FA\u662F\u54EA\u4E00\u7C7B\uFF0C\u6545\u684C\u9762\u4FDD\u62A4\u7531\u8FD0\u884C\u4E2D\u6A2A\u5E45\u627F\u62C5\u3002"
|
|
473
476
|
};
|
|
474
477
|
var en = {
|
|
475
478
|
"action.edit": "Edit",
|
|
@@ -616,7 +619,7 @@ var en = {
|
|
|
616
619
|
"path.round": "Round {n}",
|
|
617
620
|
"path.empty": "(no conversation rounds yet)",
|
|
618
621
|
"options.closeGuard": "Exit confirmation (close guard)",
|
|
619
|
-
"options.closeGuardDesc": "On (default): confirm before the page
|
|
622
|
+
"options.closeGuardDesc": "On (default): confirm before the page closes \u2014 when sessions have running or unfinished work a strong guard lists them (prevents accidental progress loss); otherwise a light one-time confirm. Armed only where the host reports a native confirm dialog (ordinary browser tabs; turning this off also disables the running banner). Desktop (DSH Desktop) never arms it: quit entries differ by version/platform \u2014 the 0.9.0-class entry in the external report does reach page beforeunload, and the shell has no will-prevent-unload handler, so the veto is swallowed silently and the exit hangs; the 2.0.9 tray item goes requestQuit\u2192window.destroy\u2192app.exit and never reaches it. The page cannot tell which one it is, so the running banner carries that protection instead."
|
|
620
623
|
};
|
|
621
624
|
function opFailureText(code, message, t) {
|
|
622
625
|
if (code === "agent-busy") return t("error.busy");
|
|
@@ -2726,7 +2729,7 @@ function installCloseGuard(t) {
|
|
|
2726
2729
|
const value = payload && payload.ok ? payload.value : null;
|
|
2727
2730
|
if (!value || !Array.isArray(value.running)) return;
|
|
2728
2731
|
const prev = store.get();
|
|
2729
|
-
store.set({ running: value.running });
|
|
2732
|
+
store.set({ running: value.running, quitVeto: value.quitVeto, surface: value.surface });
|
|
2730
2733
|
if (runningSig(store.get()) !== runningSig(prev)) ui.dismissed = null;
|
|
2731
2734
|
renderBanner();
|
|
2732
2735
|
}).catch(() => {
|
|
@@ -2745,7 +2748,7 @@ function installCloseGuard(t) {
|
|
|
2745
2748
|
}
|
|
2746
2749
|
}
|
|
2747
2750
|
function bannerNote(lang) {
|
|
2748
|
-
return lang === "zh" ? "\u5173\u95ED\u9875\u9762/\u9000\u51FA\u524D\u8BF7\u5148\u786E\u8BA4\u2014\u2014\u4EFB\u52A1\u4E2D\u65AD\u53EF\u80FD\u4E22\u5931\u8FDB\u5EA6(\u684C\u9762\u7AEF\
|
|
2751
|
+
return lang === "zh" ? "\u5173\u95ED\u9875\u9762/\u9000\u51FA\u524D\u8BF7\u5148\u786E\u8BA4\u2014\u2014\u4EFB\u52A1\u4E2D\u65AD\u53EF\u80FD\u4E22\u5931\u8FDB\u5EA6(\u684C\u9762\u7AEF\u4E0D\u6B66\u88C5\u539F\u751F\u9000\u51FA\u786E\u8BA4,\u8BF7\u4EE5\u6B64\u6A2A\u5E45\u4E3A\u51C6)" : "Confirm before closing/exiting \u2014 interrupting work may lose progress (no native exit prompt on Desktop; rely on this banner)";
|
|
2749
2752
|
}
|
|
2750
2753
|
function renderBanner() {
|
|
2751
2754
|
const cfg = getConfig();
|
|
@@ -2887,6 +2890,7 @@ function installCloseGuard(t) {
|
|
|
2887
2890
|
if (store.isArmed()) return;
|
|
2888
2891
|
const kind = classifySnapshot(store.get());
|
|
2889
2892
|
if (kind === "unknown") return;
|
|
2893
|
+
if (!quitVetoOf(store.get())) return;
|
|
2890
2894
|
try {
|
|
2891
2895
|
event.preventDefault?.();
|
|
2892
2896
|
} catch {
|
package/lib/client.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
buildRunningCopy,
|
|
25
25
|
runningLines,
|
|
26
26
|
createGuardStore,
|
|
27
|
+
quitVetoOf,
|
|
27
28
|
} from './close-guard-client.js'
|
|
28
29
|
|
|
29
30
|
export const name = 'dsh-retrace'
|
|
@@ -238,7 +239,7 @@ export const zh = {
|
|
|
238
239
|
'path.round': '第 {n} 轮',
|
|
239
240
|
'path.empty': '(当前没有对话轮次)',
|
|
240
241
|
'options.closeGuard': '退出确认(关闭守卫)',
|
|
241
|
-
'options.closeGuardDesc': '
|
|
242
|
+
'options.closeGuardDesc': '开(默认):页面关闭前确认退出——有运行中任务或未完成对话时强拦并列出明细(防误关丢进度),无任务时轻确认一次。只在宿主确认会显示原生确认框的页面上武装(普通浏览器标签页;关掉本项同时停用运行中横幅)。桌面端(DSH Desktop)一律不武装:退出入口随版本/平台而变——外部报告的 0.9.0 一类入口会触发页面 beforeunload,而桌面壳未处理 will-prevent-unload ⇒ 否决被静默吞掉、退出卡住;2.0.9 的托盘项走 requestQuit→window.destroy→app.exit,根本不经过它。页面里分不出是哪一类,故桌面保护由运行中横幅承担。',
|
|
242
243
|
}
|
|
243
244
|
/** English dictionary, checked complete against the zh key set. */
|
|
244
245
|
export const en = {
|
|
@@ -386,7 +387,7 @@ export const en = {
|
|
|
386
387
|
'path.round': 'Round {n}',
|
|
387
388
|
'path.empty': '(no conversation rounds yet)',
|
|
388
389
|
'options.closeGuard': 'Exit confirmation (close guard)',
|
|
389
|
-
'options.closeGuardDesc': 'On (default): confirm before the page
|
|
390
|
+
'options.closeGuardDesc': 'On (default): confirm before the page closes — when sessions have running or unfinished work a strong guard lists them (prevents accidental progress loss); otherwise a light one-time confirm. Armed only where the host reports a native confirm dialog (ordinary browser tabs; turning this off also disables the running banner). Desktop (DSH Desktop) never arms it: quit entries differ by version/platform — the 0.9.0-class entry in the external report does reach page beforeunload, and the shell has no will-prevent-unload handler, so the veto is swallowed silently and the exit hangs; the 2.0.9 tray item goes requestQuit→window.destroy→app.exit and never reaches it. The page cannot tell which one it is, so the running banner carries that protection instead.',
|
|
390
391
|
}
|
|
391
392
|
|
|
392
393
|
// ---------------------------------------------------------------------------
|
|
@@ -3412,15 +3413,20 @@ function whatBody(what, summaryText, t, { suppressContinue = false, maxLines = n
|
|
|
3412
3413
|
// 关闭守卫 V2 — 装配层(纯逻辑/文案在
|
|
3413
3414
|
// lib/close-guard-client.js,可单测;这里只做 DOM 与事件接线)
|
|
3414
3415
|
//
|
|
3415
|
-
//
|
|
3416
|
-
//
|
|
3417
|
-
//
|
|
3418
|
-
//
|
|
3416
|
+
// 事实与选型(2026-09-18 外部 issue #1 复核后重写;详见 lib/close-guard-client.js
|
|
3417
|
+
// 头注释与 lib/close-guard.js 头注释):
|
|
3418
|
+
// - 退出入口随 Desktop 版本/平台而变:外部报告的 0.9.0 / Windows 那一类入口会触发页面
|
|
3419
|
+
// beforeunload,而 DSH Desktop 的 Electron 壳没有处理 will-prevent-unload
|
|
3420
|
+
// (装好的 2.0.9 app.asar 全文 0 命中)⇒ 否决被静默吞掉,那里表现为"退出卡住、
|
|
3421
|
+
// 只能强退";2.0.9 的托盘项走 requestQuit(0) → window.destroy() → app.exit(code),
|
|
3422
|
+
// 不经过 beforeunload,那类版本上退出本来就正常;
|
|
3423
|
+
// - ⇒ 原生门的武装条件 = **宿主明确回报本页承载 quit-veto**(普通浏览器页面)。
|
|
3424
|
+
// 页面里分不出宿主属于哪一类,而第一类上否决是静默的(会卡住退出),所以桌面端
|
|
3425
|
+
// 一律不武装。承载面由宿主随 runningState 载荷下发(quitVeto === true 才武装;
|
|
3426
|
+
// false/未知都不武装,未知取安全侧)。客户端不再自己猜宿主;
|
|
3427
|
+
// - 桌面端的保护 = 运行中横幅(有任务即常驻)+ dispose 提示,两者都与退出路径无关;
|
|
3419
3428
|
// - 浏览器 beforeunload 确认框文案/按钮不可自定义(Chromium 统一原生框),
|
|
3420
|
-
// 故 A 强拦 = 原生门 + 「取消后」的中文明细模态;B 轻确认 =
|
|
3421
|
-
// - Desktop 退出时用户能看到的 client 侧提示 = 运行中横幅(有任务即常驻,
|
|
3422
|
-
// 不依赖退出路径)——桌面替代 seam(宿主原生 quit-veto)需官方 shell 支持,
|
|
3423
|
-
// 已在报告中向宿主提议(main.js requestQuit / electron-runtime release)。
|
|
3429
|
+
// 故 A 强拦 = 原生门 + 「取消后」的中文明细模态;B 轻确认 = 原生一次确认。
|
|
3424
3430
|
// ---------------------------------------------------------------------------
|
|
3425
3431
|
const GUARD_MODAL_ID = 'dsh-rt-guard-modal'
|
|
3426
3432
|
const GUARD_BANNER_ID = 'dsh-rt-guard-banner'
|
|
@@ -3468,7 +3474,9 @@ function installCloseGuard(t) {
|
|
|
3468
3474
|
const value = payload && payload.ok ? payload.value : null
|
|
3469
3475
|
if (!value || !Array.isArray(value.running)) return
|
|
3470
3476
|
const prev = store.get()
|
|
3471
|
-
|
|
3477
|
+
// quitVeto = 宿主回报的"本页是否承载 quit-veto"(见 close-guard-client.js
|
|
3478
|
+
// quitVetoOf)。**原样缓存宿主的值**:缺字段 = 旧宿主 = 未知 ⇒ 不武装。
|
|
3479
|
+
store.set({ running: value.running, quitVeto: value.quitVeto, surface: value.surface })
|
|
3472
3480
|
if (runningSig(store.get()) !== runningSig(prev)) ui.dismissed = null
|
|
3473
3481
|
renderBanner()
|
|
3474
3482
|
}).catch(() => {
|
|
@@ -3488,8 +3496,8 @@ function installCloseGuard(t) {
|
|
|
3488
3496
|
}
|
|
3489
3497
|
function bannerNote(lang) {
|
|
3490
3498
|
return lang === 'zh'
|
|
3491
|
-
? '关闭页面/退出前请先确认——任务中断可能丢失进度(
|
|
3492
|
-
: 'Confirm before closing/exiting — interrupting work may lose progress (
|
|
3499
|
+
? '关闭页面/退出前请先确认——任务中断可能丢失进度(桌面端不武装原生退出确认,请以此横幅为准)'
|
|
3500
|
+
: 'Confirm before closing/exiting — interrupting work may lose progress (no native exit prompt on Desktop; rely on this banner)'
|
|
3493
3501
|
}
|
|
3494
3502
|
function renderBanner() {
|
|
3495
3503
|
const cfg = getConfig()
|
|
@@ -3618,12 +3626,17 @@ function installCloseGuard(t) {
|
|
|
3618
3626
|
ui.toastTimer = setTimeout(() => { if (ui.toast) { ui.toast.remove(); ui.toast = null } }, 8000)
|
|
3619
3627
|
}
|
|
3620
3628
|
|
|
3621
|
-
// -- 页面关闭拦截(beforeunload
|
|
3629
|
+
// -- 页面关闭拦截(beforeunload;只在宿主确认承载 quit-veto 的页面上武装) ----
|
|
3622
3630
|
function onBeforeUnload(event) {
|
|
3623
3631
|
if (!getConfig().closeGuard || !supported) return
|
|
3624
3632
|
if (store.isArmed()) return // 已放行(用户已选 [仍关闭])→ 二次触发直接过
|
|
3625
3633
|
const kind = classifySnapshot(store.get())
|
|
3626
3634
|
if (kind === 'unknown') return // 状态未同步(host 不可达/刚启动)→ 不打扰
|
|
3635
|
+
// 宿主承载面闸门(2026-09-18 外部 issue #1):桌面 Electron 页面上原生
|
|
3636
|
+
// beforeunload 门没有意义(2.0.9 那类入口不经过它;0.9.0 那类入口经过时否决被
|
|
3637
|
+
// 壳静默吞掉、退出卡住)。仅当宿主明确回报 quitVeto === true(普通浏览器页面)
|
|
3638
|
+
// 才武装;false 与未知一律不武装(未知取安全侧)。
|
|
3639
|
+
if (!quitVetoOf(store.get())) return
|
|
3627
3640
|
// A/B 共用浏览器原生门:returnValue 触发原生确认(Chromium 文案不可自定义,
|
|
3628
3641
|
// 见 close-guard-client.js 头注释;用户控制可在设置关闭)。
|
|
3629
3642
|
try { event.preventDefault?.() } catch { /* ignore */ }
|
|
@@ -3701,8 +3714,9 @@ export function apply(ctx) {
|
|
|
3701
3714
|
const t = ctx.locale.bind(NS)
|
|
3702
3715
|
|
|
3703
3716
|
// 关闭守卫 V2:页面关闭 beforeunload 拦截(A 强拦/B 轻
|
|
3704
|
-
// 确认,运行中状态经 /runningState 轮询缓存)+
|
|
3705
|
-
//
|
|
3717
|
+
// 确认,运行中状态经 /runningState 轮询缓存)+ 运行中横幅。原生门只在宿主
|
|
3718
|
+
// 回报本页承载 quit-veto 时武装(见 installCloseGuard 段头注释);桌面端的
|
|
3719
|
+
// 可见提示 = 运行中横幅 + dispose 提示(与退出路径无关)。
|
|
3706
3720
|
// The close guard is DOM-only (no React), so the panel error boundary does not
|
|
3707
3721
|
// apply to it: fail CLOSED instead — the guard degrades to "not installed"
|
|
3708
3722
|
// rather than breaking the plugin's activation (and with it the client half).
|
|
@@ -4,18 +4,33 @@
|
|
|
4
4
|
* 关闭守卫 V2 · 客户端纯逻辑层——被 lib/client.js 装配,
|
|
5
5
|
* 零 DOM/零 react 依赖(可单测;client.bundle.js/dynamic-client.js 由构建内联)。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
*
|
|
7
|
+
* ── 桌面端为什么不武装原生门(2026-09-18 外部 issue #1 复核后重写)────────────
|
|
8
|
+
* 本文件此前把守卫的前提写成了"桌面端退出不经过页面关闭流程",并据此把守卫当纯
|
|
9
|
+
* web 能力 —— **那是把没覆盖到的路径当成了结论**(本 issue 的两条根因之一;该前提
|
|
10
|
+
* 只对**重启**路径 `app.relaunch(); app.exit(0)` 成立)。复核到的事实:
|
|
11
|
+
* - **退出入口随版本/平台而变**:外部报告的 DSH Desktop 0.9.0/Windows 上,托盘「退出」
|
|
12
|
+
* 走的是**会**触发页面 `beforeunload` 的入口;而我们检查的 2.0.9 壳上,托盘项走
|
|
13
|
+
* `requestQuit(0)` → `generation.release()` → `window.destroy()` → `app.exit(code)`
|
|
14
|
+
* (**不**经过 `beforeunload`)。⇒ 我们**无法保证覆盖所有入口**,
|
|
15
|
+
* 这正是"桌面端一律不武装"的理由(而不是"桌面端都不会触发");
|
|
16
|
+
* - DSH Desktop 的 Electron 壳没有处理 `will-prevent-unload`(装好的 2.0.9
|
|
17
|
+
* `app.asar` 全文检索 0 命中;`beforeunload` 三处命中全在第三方库)⇒ Electron
|
|
18
|
+
* 的默认语义 = 尊重页面否决,且不弹任何界面。于是**在会走到 beforeunload 的那类
|
|
19
|
+
* 版本上**页面里的 `preventDefault()` 是静默的:否决被吞掉、界面什么都没有,用户
|
|
20
|
+
* 看到的就是"退出卡住、只能强退"(现场:关掉本插件的「退出确认」即恢复正常);
|
|
21
|
+
* 在 2.0.9 那类不经过它的版本上,退出本来就正常 —— 两种情况都指向同一个结论。
|
|
22
|
+
* 结论(与事实一致):桌面端**不武装**原生 `beforeunload` 门;桌面端的保护由
|
|
23
|
+
* **运行中横幅** + dispose 提示承担(两者都与退出路径无关)。网页端照常武装:
|
|
24
|
+
* Chromium 会弹原生确认框,页面否决在那里对用户是可见的。
|
|
25
|
+
*
|
|
26
|
+
* 承载面由**宿主**判定、随既有 runningState 载荷下发(见 lib/close-guard.js 头注释
|
|
27
|
+
* 与 `pageSurfaceOf` / `quitVetoFor`),客户端只在 `quitVeto === true` 时武装。
|
|
28
|
+
* `false`(桌面 Electron 页面)与 `null`/`undefined`(中性态:wire 通道拿不到请求
|
|
29
|
+
* 证据 / 旧版桌面壳)一律不武装 —— 未知取安全侧,避免再次死锁。
|
|
30
|
+
*
|
|
31
|
+
* 浏览器 beforeunload 确认框文案/按钮不可自定义(Chromium 一律通用原生框)
|
|
32
|
+
* ⇒ 本模块只产出语义/文案数据,选型由 client.js 装配层决定
|
|
33
|
+
* (A 强拦 = 原生门 + 取消后中文明细模态;B 轻确认 = 原生一次确认)。
|
|
19
34
|
*
|
|
20
35
|
* 检测数据源 = host 侧 runningSessions(ctx)(lib/close-guard.js 复用):
|
|
21
36
|
* 会话有 agent status=running / inbox 排队 / jobs 后台任务 / 未闭合轮 →
|
|
@@ -131,6 +146,18 @@ export function buildRunningCopy(snapshot, { locale = 'zh', labelOf, now = Date.
|
|
|
131
146
|
return { head, lines, hint, stale }
|
|
132
147
|
}
|
|
133
148
|
|
|
149
|
+
/**
|
|
150
|
+
* 本页宿主是否承载 quit-veto(页面否决对用户可见):
|
|
151
|
+
* 宿主在 runningState 载荷里明确回 `quitVeto === true` 才算。
|
|
152
|
+
* `false`(桌面 Electron 页面:壳不处理 will-prevent-unload,否决静默)与
|
|
153
|
+
* `null`/`undefined`(中性态)都返回 false —— 未知一律取**不武装**的安全侧。
|
|
154
|
+
* @param {{quitVeto?:boolean|null}} [snapshot] 宿主快照。
|
|
155
|
+
* @returns {boolean} 是否可以在本页武装原生 `beforeunload` 门。
|
|
156
|
+
*/
|
|
157
|
+
export function quitVetoOf(snapshot) {
|
|
158
|
+
return snapshot?.quitVeto === true
|
|
159
|
+
}
|
|
160
|
+
|
|
134
161
|
/**
|
|
135
162
|
* 关闭守卫客户端状态(纯,可单测):快照缓存 + 已放行标记。
|
|
136
163
|
* 「放行」= 用户在一轮关闭手势中已选 [仍关闭];二次触发 beforeunload 时放行,
|
package/lib/close-guard.js
CHANGED
|
@@ -18,6 +18,46 @@
|
|
|
18
18
|
*
|
|
19
19
|
* 铁律:只检测+提示,绝不中断/取消 agent、绝不写事件(中断属用户显式动作,
|
|
20
20
|
* 守卫只在用户"关闭"动作时确认——本插件无该 seam 时只提示,不代执行)。
|
|
21
|
+
*
|
|
22
|
+
* ── 宿主承载面判定(2026-09-18,外部 issue #1:桌面端托盘退出死锁)─────────────
|
|
23
|
+
*
|
|
24
|
+
* 现场:DSH Desktop 上托盘「退出」点了没反应,App 退不掉(只能强退);关掉本插件
|
|
25
|
+
* 的「退出确认」立刻正常;网页端一直正常。逐条核实到的事实:
|
|
26
|
+
* ① 客户端 `onBeforeUnload` 对 `kind !== 'unknown'` 一律 `preventDefault()`
|
|
27
|
+
* ——空会话也拦;
|
|
28
|
+
* ② DSH Desktop 的 Electron 壳**没有**处理 `will-prevent-unload`
|
|
29
|
+
* (装好的 2.0.9 `app.asar` 全文检索 0 命中)。Electron 的默认语义 = 尊重页面
|
|
30
|
+
* 否决,且不弹任何界面 ⇒ **一旦页面否决被抬起来**,它就是静默的:不弹框、
|
|
31
|
+
* 不给用户任何可见反馈;
|
|
32
|
+
* ③ 退出入口**随 Desktop 版本/平台而变**(这是本 issue 的病因:别把某一条入口
|
|
33
|
+
* 当成全部事实):
|
|
34
|
+
* · 外部报告(DSH Desktop 0.9.0 / Windows)那一类入口**会**触发页面
|
|
35
|
+
* `beforeunload` ⇒ ② 生效:否决被静默吞掉,用户看到"点了退出没反应、
|
|
36
|
+
* 只能强退";
|
|
37
|
+
* · 本机核对的 2.0.9 壳上,托盘项走 `requestQuit(0)` → `createDesktopShutdown`
|
|
38
|
+
* → `generation.release()` → `window.destroy()` → `app.exit(code)`
|
|
39
|
+
* (且 `before-quit` 也被 `preventDefault() + requestQuit(0)` 收进同一路径)
|
|
40
|
+
* ⇒ `destroy()` / `app.exit()` 都**不**经过页面 `beforeunload`,那类版本上
|
|
41
|
+
* 退出本来就正常(与本插件无关);
|
|
42
|
+
* ④ 反之,普通浏览器(网页版)会为 `beforeunload` 弹原生确认框,页面否决对用户
|
|
43
|
+
* 是**可见**的 ⇒ 只有这条路径上"武装原生门"才是有效动作。
|
|
44
|
+
*
|
|
45
|
+
* ⇒ 页面里分不出宿主属于哪一类,而③第一类上否决是静默的(会卡住退出且毫无反馈)
|
|
46
|
+
* —— 所以**桌面端一律不武装**原生门(与版本无关):2.0.9 那类版本上它本来是空转,
|
|
47
|
+
* 0.9.0 那类版本上它能卡死退出。「本页宿主是否承载 quit-veto(否决对用户可见)」
|
|
48
|
+
* 是**宿主侧**才知道的属性,由宿主判定、随既有的 runningState 载荷下发(复用既有
|
|
49
|
+
* 通道,不新造)。客户端只在宿主明确回 true 时武装原生门(见
|
|
50
|
+
* lib/close-guard-client.js quitVetoOf)。
|
|
51
|
+
*
|
|
52
|
+
* 判据取**请求级证据**:DSH Desktop 主进程给 renderer 的**每一个** HTTP/WS 请求
|
|
53
|
+
* 挂一个代际能力头(`electron-runtime` 的 installRendererAccessHeader;
|
|
54
|
+
* `webRequest.onBeforeSendHeaders({urls:['<all_urls>']})`,且挂之前先剥掉请求里
|
|
55
|
+
* 同名头 ⇒ 页面 JS 伪造不了)。头在 ⇒ 本请求来自 Desktop 的 Electron 页面;
|
|
56
|
+
* 头不在且宿主看不出 Electron 痕迹 ⇒ 普通浏览器页面。
|
|
57
|
+
*
|
|
58
|
+
* 已知中性态(默认取**不武装**这一侧):动态桥 wire 通道拿不到请求对象;宿主能看出
|
|
59
|
+
* 自己是 Electron 但请求没带该头(旧版桌面壳/桌面版兼容模式的普通浏览器页)。
|
|
60
|
+
* 两种都判 unknown ⇒ 客户端不武装原生门,退化为"仅运行中横幅"。
|
|
21
61
|
*/
|
|
22
62
|
// unclosedTurns 直接复用 interrupt-guard(该文件零 import,无环),
|
|
23
63
|
// 不再内联复制一份——后续 interrupt-guard 演进(如 reason 感知)自动同步。
|
|
@@ -126,6 +166,96 @@ export function sessionRunningState(ctx, sessionId) {
|
|
|
126
166
|
return runningState(String(sessionId), { agent, session, jobs })
|
|
127
167
|
}
|
|
128
168
|
|
|
169
|
+
// ── 宿主承载面:判定"本页宿主是否承载 quit-veto" ─────────────────────────────
|
|
170
|
+
/** DSH Desktop 主进程给 renderer 每个请求挂的代际能力头(页面 JS 伪造不了)。 */
|
|
171
|
+
export const DESKTOP_RENDERER_HEADER = 'x-dsh-desktop-renderer'
|
|
172
|
+
|
|
173
|
+
/** 承载面取值。语义见文件头注释。 */
|
|
174
|
+
export const PAGE_SURFACE = Object.freeze({
|
|
175
|
+
/** DSH Desktop 的 Electron 页面:原生 `beforeunload` 门在此静默。 */
|
|
176
|
+
DESKTOP_RENDERER: 'desktop-renderer',
|
|
177
|
+
/** 普通浏览器页面:原生 `beforeunload` 确认框在此出现。 */
|
|
178
|
+
BROWSER: 'browser',
|
|
179
|
+
/** 中性态:拿不到请求证据 / 能看出 Electron 但请求没带能力头。 */
|
|
180
|
+
UNKNOWN: 'unknown',
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* DSH Desktop 宿主侧注入的服务名(桌面专属)。任一在场 ⇒ 本宿主是桌面壳。
|
|
185
|
+
* 只用于"要不要退回中性态"的判断,不参与"是不是 Electron 页面"的判断。
|
|
186
|
+
*/
|
|
187
|
+
const DESKTOP_HOST_SERVICES = ['desktopBrowserAccess', 'desktopRuntime', 'desktopPnpmBootstrap']
|
|
188
|
+
|
|
189
|
+
/** 安全取一个可选服务(未注册/取值抛错一律当"没有")。 */
|
|
190
|
+
function serviceOf(ctx, name) {
|
|
191
|
+
try {
|
|
192
|
+
return typeof ctx?.get === 'function' ? ctx.get(name) : undefined
|
|
193
|
+
} catch { return undefined }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 本宿主是否带 Electron/桌面壳痕迹(多项独立判据,任一命中即为真)。 */
|
|
197
|
+
export function desktopHostEvidence(ctx) {
|
|
198
|
+
try {
|
|
199
|
+
if (typeof process !== 'undefined' && process?.versions?.electron) return true
|
|
200
|
+
} catch { /* 浏览器侧无 process */ }
|
|
201
|
+
for (const name of DESKTOP_HOST_SERVICES) {
|
|
202
|
+
if (serviceOf(ctx, name)) return true
|
|
203
|
+
}
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 请求头里是否带 Desktop renderer 能力头(值不看内容:非空即证据)。 */
|
|
208
|
+
function hasDesktopRendererHeader(headers) {
|
|
209
|
+
if (!headers || typeof headers !== 'object') return false
|
|
210
|
+
const present = (value) => {
|
|
211
|
+
if (typeof value === 'string') return value.length > 0
|
|
212
|
+
if (Array.isArray(value)) return value.length > 0
|
|
213
|
+
return Boolean(value)
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
// Node 的 req.headers 名字已小写;非 Node 形状(经序列化/代理)按大小写不敏感兜底。
|
|
217
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
218
|
+
if (name.toLowerCase() === DESKTOP_RENDERER_HEADER && present(value)) return true
|
|
219
|
+
}
|
|
220
|
+
} catch { return false }
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 从**请求头 + 宿主痕迹**判定承载面(纯函数,可单测)。
|
|
226
|
+
* @param {object} [headers] Node `req.headers` 形状;拿不到请求对象时为 undefined。
|
|
227
|
+
* @param {object} [opts] `{ desktopHost }` = desktopHostEvidence(ctx) 的结果。
|
|
228
|
+
* @returns {'desktop-renderer'|'browser'|'unknown'}
|
|
229
|
+
*/
|
|
230
|
+
export function pageSurfaceOf(headers, { desktopHost = false } = {}) {
|
|
231
|
+
if (hasDesktopRendererHeader(headers)) return PAGE_SURFACE.DESKTOP_RENDERER
|
|
232
|
+
// 无能力头。能看出宿主是 Electron ⇒ 中性态(可能是旧版桌面壳没挂头,也可能是
|
|
233
|
+
// 桌面版兼容模式下的普通浏览器页;两者分不开)⇒ 交给调用方按安全侧处理。
|
|
234
|
+
if (desktopHost) return PAGE_SURFACE.UNKNOWN
|
|
235
|
+
return PAGE_SURFACE.BROWSER
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 该承载面能否承载 quit-veto(页面否决对用户可见)。
|
|
240
|
+
* browser → true;desktop-renderer → false;unknown → null(未知 ⇒ 客户端不武装)。
|
|
241
|
+
*/
|
|
242
|
+
export function quitVetoFor(surface) {
|
|
243
|
+
if (surface === PAGE_SURFACE.BROWSER) return true
|
|
244
|
+
if (surface === PAGE_SURFACE.DESKTOP_RENDERER) return false
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 给 runningState 载荷用的承载面描述。
|
|
250
|
+
* @param {object} ctx - cordis ctx(用于看宿主痕迹)。
|
|
251
|
+
* @param {object} [headers] - 发起该请求的 `req.headers`;wire/harness 通道传 undefined。
|
|
252
|
+
* @returns {{ surface:string, quitVeto:boolean|null }}
|
|
253
|
+
*/
|
|
254
|
+
export function guardSurfaceOf(ctx, headers) {
|
|
255
|
+
const surface = pageSurfaceOf(headers, { desktopHost: desktopHostEvidence(ctx) })
|
|
256
|
+
return { surface, quitVeto: quitVetoFor(surface) }
|
|
257
|
+
}
|
|
258
|
+
|
|
129
259
|
/**
|
|
130
260
|
* 挂载"关闭前强提示":插件 dispose(应用退出/重载)时,若有运行中会话 → 中文提示
|
|
131
261
|
* 列出每个会话与原因(agent-running/queued/jobs/unclosed-turn)。只提示不中断。
|
package/lib/dynamic-client.js
CHANGED
|
@@ -264,6 +264,9 @@ return {
|
|
|
264
264
|
const hint = stale ? lang === "zh" ? "(\u72B6\u6001\u540C\u6B65\u4E8E\u8F83\u65E9\u65F6\u523B,\u53EF\u80FD\u5DF2\u53D8\u5316)" : "(state may be stale)" : void 0;
|
|
265
265
|
return { head, lines, hint, stale };
|
|
266
266
|
}
|
|
267
|
+
function quitVetoOf(snapshot) {
|
|
268
|
+
return snapshot?.quitVeto === true;
|
|
269
|
+
}
|
|
267
270
|
function createGuardStore({ now = Date.now, armTtlMs = GUARD_ARM_TTL_MS } = {}) {
|
|
268
271
|
let snapshot = null;
|
|
269
272
|
let armedAt = 0;
|
|
@@ -475,7 +478,7 @@ return {
|
|
|
475
478
|
"path.round": "\u7B2C {n} \u8F6E",
|
|
476
479
|
"path.empty": "\uFF08\u5F53\u524D\u6CA1\u6709\u5BF9\u8BDD\u8F6E\u6B21\uFF09",
|
|
477
480
|
"options.closeGuard": "\u9000\u51FA\u786E\u8BA4\uFF08\u5173\u95ED\u5B88\u536B\uFF09",
|
|
478
|
-
"options.closeGuardDesc": "\u5F00\uFF08\u9ED8\u8BA4\uFF09\uFF1A\u9875\u9762
|
|
481
|
+
"options.closeGuardDesc": "\u5F00\uFF08\u9ED8\u8BA4\uFF09\uFF1A\u9875\u9762\u5173\u95ED\u524D\u786E\u8BA4\u9000\u51FA\u2014\u2014\u6709\u8FD0\u884C\u4E2D\u4EFB\u52A1\u6216\u672A\u5B8C\u6210\u5BF9\u8BDD\u65F6\u5F3A\u62E6\u5E76\u5217\u51FA\u660E\u7EC6\uFF08\u9632\u8BEF\u5173\u4E22\u8FDB\u5EA6\uFF09\uFF0C\u65E0\u4EFB\u52A1\u65F6\u8F7B\u786E\u8BA4\u4E00\u6B21\u3002\u53EA\u5728\u5BBF\u4E3B\u786E\u8BA4\u4F1A\u663E\u793A\u539F\u751F\u786E\u8BA4\u6846\u7684\u9875\u9762\u4E0A\u6B66\u88C5\uFF08\u666E\u901A\u6D4F\u89C8\u5668\u6807\u7B7E\u9875\uFF1B\u5173\u6389\u672C\u9879\u540C\u65F6\u505C\u7528\u8FD0\u884C\u4E2D\u6A2A\u5E45\uFF09\u3002\u684C\u9762\u7AEF\uFF08DSH Desktop\uFF09\u4E00\u5F8B\u4E0D\u6B66\u88C5\uFF1A\u9000\u51FA\u5165\u53E3\u968F\u7248\u672C/\u5E73\u53F0\u800C\u53D8\u2014\u2014\u5916\u90E8\u62A5\u544A\u7684 0.9.0 \u4E00\u7C7B\u5165\u53E3\u4F1A\u89E6\u53D1\u9875\u9762 beforeunload\uFF0C\u800C\u684C\u9762\u58F3\u672A\u5904\u7406 will-prevent-unload \u21D2 \u5426\u51B3\u88AB\u9759\u9ED8\u541E\u6389\u3001\u9000\u51FA\u5361\u4F4F\uFF1B2.0.9 \u7684\u6258\u76D8\u9879\u8D70 requestQuit\u2192window.destroy\u2192app.exit\uFF0C\u6839\u672C\u4E0D\u7ECF\u8FC7\u5B83\u3002\u9875\u9762\u91CC\u5206\u4E0D\u51FA\u662F\u54EA\u4E00\u7C7B\uFF0C\u6545\u684C\u9762\u4FDD\u62A4\u7531\u8FD0\u884C\u4E2D\u6A2A\u5E45\u627F\u62C5\u3002"
|
|
479
482
|
};
|
|
480
483
|
var en = {
|
|
481
484
|
"action.edit": "Edit",
|
|
@@ -622,7 +625,7 @@ return {
|
|
|
622
625
|
"path.round": "Round {n}",
|
|
623
626
|
"path.empty": "(no conversation rounds yet)",
|
|
624
627
|
"options.closeGuard": "Exit confirmation (close guard)",
|
|
625
|
-
"options.closeGuardDesc": "On (default): confirm before the page
|
|
628
|
+
"options.closeGuardDesc": "On (default): confirm before the page closes \u2014 when sessions have running or unfinished work a strong guard lists them (prevents accidental progress loss); otherwise a light one-time confirm. Armed only where the host reports a native confirm dialog (ordinary browser tabs; turning this off also disables the running banner). Desktop (DSH Desktop) never arms it: quit entries differ by version/platform \u2014 the 0.9.0-class entry in the external report does reach page beforeunload, and the shell has no will-prevent-unload handler, so the veto is swallowed silently and the exit hangs; the 2.0.9 tray item goes requestQuit\u2192window.destroy\u2192app.exit and never reaches it. The page cannot tell which one it is, so the running banner carries that protection instead."
|
|
626
629
|
};
|
|
627
630
|
function opFailureText(code, message, t) {
|
|
628
631
|
if (code === "agent-busy") return t("error.busy");
|
|
@@ -2732,7 +2735,7 @@ return {
|
|
|
2732
2735
|
const value = payload && payload.ok ? payload.value : null;
|
|
2733
2736
|
if (!value || !Array.isArray(value.running)) return;
|
|
2734
2737
|
const prev = store.get();
|
|
2735
|
-
store.set({ running: value.running });
|
|
2738
|
+
store.set({ running: value.running, quitVeto: value.quitVeto, surface: value.surface });
|
|
2736
2739
|
if (runningSig(store.get()) !== runningSig(prev)) ui.dismissed = null;
|
|
2737
2740
|
renderBanner();
|
|
2738
2741
|
}).catch(() => {
|
|
@@ -2751,7 +2754,7 @@ return {
|
|
|
2751
2754
|
}
|
|
2752
2755
|
}
|
|
2753
2756
|
function bannerNote(lang) {
|
|
2754
|
-
return lang === "zh" ? "\u5173\u95ED\u9875\u9762/\u9000\u51FA\u524D\u8BF7\u5148\u786E\u8BA4\u2014\u2014\u4EFB\u52A1\u4E2D\u65AD\u53EF\u80FD\u4E22\u5931\u8FDB\u5EA6(\u684C\u9762\u7AEF\
|
|
2757
|
+
return lang === "zh" ? "\u5173\u95ED\u9875\u9762/\u9000\u51FA\u524D\u8BF7\u5148\u786E\u8BA4\u2014\u2014\u4EFB\u52A1\u4E2D\u65AD\u53EF\u80FD\u4E22\u5931\u8FDB\u5EA6(\u684C\u9762\u7AEF\u4E0D\u6B66\u88C5\u539F\u751F\u9000\u51FA\u786E\u8BA4,\u8BF7\u4EE5\u6B64\u6A2A\u5E45\u4E3A\u51C6)" : "Confirm before closing/exiting \u2014 interrupting work may lose progress (no native exit prompt on Desktop; rely on this banner)";
|
|
2755
2758
|
}
|
|
2756
2759
|
function renderBanner() {
|
|
2757
2760
|
const cfg = getConfig();
|
|
@@ -2893,6 +2896,7 @@ return {
|
|
|
2893
2896
|
if (store.isArmed()) return;
|
|
2894
2897
|
const kind = classifySnapshot(store.get());
|
|
2895
2898
|
if (kind === "unknown") return;
|
|
2899
|
+
if (!quitVetoOf(store.get())) return;
|
|
2896
2900
|
try {
|
|
2897
2901
|
event.preventDefault?.();
|
|
2898
2902
|
} catch {
|
package/lib/http.js
CHANGED
|
@@ -32,6 +32,13 @@
|
|
|
32
32
|
* — 关闭守卫 V2 :全会话运行中清单 { running: [...] }(或单会话
|
|
33
33
|
* runningState)——client beforeunload 拦截的同步读缓存源(纯读,复用
|
|
34
34
|
* lib/close-guard.js runningSessions)。
|
|
35
|
+
* 全会话形状另带宿主承载面 `{ surface, quitVeto }`(2026-09-18 外部 issue #1:
|
|
36
|
+
* 桌面端托盘退出死锁)。surface 由**宿主**按请求头 + 宿主痕迹判定
|
|
37
|
+
* (lib/close-guard.js pageSurfaceOf);客户端只在 quitVeto === true 时武装原生
|
|
38
|
+
* beforeunload 门 —— 桌面 Electron 页面回报 false:退出入口随 Desktop 版本/
|
|
39
|
+
* 平台而变,会经过 beforeunload 的那类版本(外部报告的 0.9.0)上否决被壳静默
|
|
40
|
+
* 吞掉、退出卡住;不经过它的版本(2.0.9 的 requestQuit → app.exit)上那扇门
|
|
41
|
+
* 本来就是空转。
|
|
35
42
|
*
|
|
36
43
|
* Per PLAN.md §4.6 the client carries its localStorage config on every
|
|
37
44
|
* request as `x-retrace-config: {"versioning":bool,"git":bool,
|
|
@@ -42,7 +49,7 @@ import { createEditorApi } from './host-core.js'
|
|
|
42
49
|
import { dshAdapter } from './adapter/dsh.js'
|
|
43
50
|
// span 显式状态 → 业务层判定输入(与 index.js harness 入口同一处理)
|
|
44
51
|
import { spanMissArgsOf } from './span-semantics.js'
|
|
45
|
-
import { runningSessions, sessionRunningState } from './close-guard.js'
|
|
52
|
+
import { runningSessions, sessionRunningState, guardSurfaceOf } from './close-guard.js'
|
|
46
53
|
import { latestByBoundary, readSummaries, renderSummariesMarkdown } from './summary-store.js'
|
|
47
54
|
import { boundaryTreeOf } from './boundary-tree.js'
|
|
48
55
|
|
|
@@ -289,6 +296,8 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback
|
|
|
289
296
|
* 异步查询,client 每 GUARD_POLL_MS 拉一次缓存快照)。复用关闭守卫检测:
|
|
290
297
|
* runningSessions(ctx) 全会话运行中清单(agent/queued/jobs/未闭合轮)。
|
|
291
298
|
* 无 sessionId → { running: [...] };有 → 单会话 runningState。
|
|
299
|
+
* 全会话形状另带 { surface, quitVeto }(承载面,见文件头):**这一段是宿主侧唯一的
|
|
300
|
+
* 承载面证据来源** —— 请求头带 Desktop renderer 能力头就是 Electron 页面。
|
|
292
301
|
* 纯读,无副作用(守卫只检测+确认,绝不中断/写事件)。
|
|
293
302
|
*/
|
|
294
303
|
function handleRunningState(req, res, hostCtx, sessions, agents, searchParams) {
|
|
@@ -299,7 +308,7 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback
|
|
|
299
308
|
const sessionId = searchParams.get('sessionId') ?? ''
|
|
300
309
|
const value = sessionId
|
|
301
310
|
? sessionRunningState(guardCtx, sessionId)
|
|
302
|
-
: { running: runningSessions(guardCtx) }
|
|
311
|
+
: { running: runningSessions(guardCtx), ...guardSurfaceOf(hostCtx, req?.headers) }
|
|
303
312
|
sendJson(res, 200, { ok: true, value })
|
|
304
313
|
} catch (error) {
|
|
305
314
|
sendError(res, error, log)
|
|
@@ -501,7 +510,9 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback
|
|
|
501
510
|
return handleJsonPost(req, res, async (args) => {
|
|
502
511
|
const guardCtx = { sessions, agents, jobs: ctx?.jobs }
|
|
503
512
|
const sid = String(args?.sessionId ?? '')
|
|
504
|
-
return sid
|
|
513
|
+
return sid
|
|
514
|
+
? sessionRunningState(guardCtx, sid)
|
|
515
|
+
: { running: runningSessions(guardCtx), ...guardSurfaceOf(ctx, req?.headers) }
|
|
505
516
|
})
|
|
506
517
|
}
|
|
507
518
|
// 客户端半自报(2026-09-14 事故 2 的可见性补丁):证明"客户端半装载了 + 注册了
|
package/lib/index.js
CHANGED
|
@@ -26,8 +26,9 @@ import { createVersioningSeam } from './versioning.js'
|
|
|
26
26
|
import { createRollbackExecutor } from './rollback.js'
|
|
27
27
|
import { createMarkerGuard } from './prewrite-guard.js'
|
|
28
28
|
import { createWatchdog } from './watchdog.js'
|
|
29
|
+
import { createBootPinRetry } from './boot-pin.js'
|
|
29
30
|
import { attachExitWarning } from './interrupt-guard.js'
|
|
30
|
-
import { attachCloseGuard, runningSessions, sessionRunningState } from './close-guard.js'
|
|
31
|
+
import { attachCloseGuard, runningSessions, sessionRunningState, guardSurfaceOf } from './close-guard.js'
|
|
31
32
|
import { sessionBadge as fnvBadge } from './badge.js'
|
|
32
33
|
import { dshAdapter, semanticBadgeOf } from './adapter/dsh.js'
|
|
33
34
|
// span 显式状态结果 → 业务层判定输入的统一下传(单一真相模块)
|
|
@@ -308,37 +309,28 @@ export function apply(ctx) {
|
|
|
308
309
|
}
|
|
309
310
|
}
|
|
310
311
|
/**
|
|
311
|
-
* 启动批量 pin:DSH
|
|
312
|
+
* 启动批量 pin(重试调度在 lib/boot-pin.js):DSH 会话渐进驻留,定期重试直到
|
|
313
|
+
* 覆盖(幂等可重复,alreadyTagged 跳过)。
|
|
312
314
|
* 0.4.19 缺陷(用户实测前发现):`bootPin(99)` 被 `retries>=8` 挡住 → 30s 重跑永不执行;
|
|
313
315
|
* 启动 24s 窗口内会话未驻留 → 永久放弃 → 短码永不显示(全天 0 条 bootPin 日志)。
|
|
314
|
-
* 修法:每次(成功或失败)都安排下一次,共
|
|
316
|
+
* 修法:每次(成功或失败)都安排下一次,共 maxAttempts 次(30s×20 ≈ 启动后 10 分钟
|
|
315
317
|
* 窗口,覆盖渐进驻留);错误打日志不静默。
|
|
318
|
+
* 2026-09-18(外部 issue #1):那串重试定时器的句柄此前被丢弃、不 unref 也不在
|
|
319
|
+
* dispose 里清 —— 30s 递归会把宿主事件循环钉到启动后 10 分钟,退出/关机被它拖住。
|
|
320
|
+
* 调度整体迁到 lib/boot-pin.js(可注入 schedule/unschedule ⇒ 句柄可被用例捕获),
|
|
321
|
+
* 句柄 unref + dispose 撤销,并由下面的 disposers 收口。
|
|
316
322
|
*/
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
schedule()
|
|
329
|
-
return
|
|
330
|
-
}
|
|
331
|
-
pinAllResident()
|
|
332
|
-
.then((result) => {
|
|
333
|
-
log(`retrace: bootPin(${attempts}) → ${result?.value?.total ?? 0} 个驻留会话标题已处理`)
|
|
334
|
-
schedule()
|
|
335
|
-
})
|
|
336
|
-
.catch((error) => {
|
|
337
|
-
log(`retrace: bootPin error: ${String(error).slice(0, 160)}`)
|
|
338
|
-
schedule()
|
|
339
|
-
})
|
|
340
|
-
}
|
|
341
|
-
bootPin(0)
|
|
323
|
+
const bootPinRetry = createBootPinRetry({
|
|
324
|
+
run: () => pinAllResident(),
|
|
325
|
+
hasResidentSessions: () => {
|
|
326
|
+
const sessionStore = ctx.sessions
|
|
327
|
+
return Boolean(sessionStore && (
|
|
328
|
+
(typeof sessionStore.list === 'function' && sessionStore.list().length > 0) ||
|
|
329
|
+
(typeof sessionStore.values === 'function' && sessionStore.values().length > 0)
|
|
330
|
+
))
|
|
331
|
+
},
|
|
332
|
+
log,
|
|
333
|
+
})
|
|
342
334
|
const disposers = [
|
|
343
335
|
// 2026-09-07:recall 语义 = 遮蔽目标轮及之后全部(编辑=从此处分叉)
|
|
344
336
|
// ——不再只遮蔽目标轮(缺陷①断层);tail 在官方 foldSurface
|
|
@@ -411,12 +403,17 @@ export function apply(ctx) {
|
|
|
411
403
|
if (sid) {
|
|
412
404
|
return { ok: true, value: sessionRunningState(ctx, sid) }
|
|
413
405
|
}
|
|
414
|
-
|
|
406
|
+
// 负载形状与 HTTP 面同构(含宿主承载面)。wire 通道拿不到请求对象 ⇒
|
|
407
|
+
// guardSurfaceOf(ctx, undefined) 只能靠宿主痕迹判:桌面宿主 → unknown
|
|
408
|
+
// (客户端不武装原生门,安全侧),web 宿主 → browser。
|
|
409
|
+
return { ok: true, value: { running: runningSessions(ctx), ...guardSurfaceOf(ctx, undefined) } }
|
|
415
410
|
} catch (error) {
|
|
416
411
|
return { ok: false, error: { code: 'internal', message: String(error) } }
|
|
417
412
|
}
|
|
418
413
|
}),
|
|
419
414
|
]
|
|
415
|
+
// 启动重试链的定时器收口(不再额外钉住宿主事件循环;dispose 时撤销挂着的那个)。
|
|
416
|
+
disposers.push(() => bootPinRetry.dispose())
|
|
420
417
|
return () => disposers.forEach((dispose) => dispose())
|
|
421
418
|
})()
|
|
422
419
|
|
package/lib/watchdog.js
CHANGED
|
@@ -49,6 +49,7 @@ const SNAPSHOT_DIR = 'dsh-retrace/snapshots'
|
|
|
49
49
|
* @param {(cb: () => void, ms: number) => unknown} [options.schedule]
|
|
50
50
|
* 定时器创建(默认全局 setInterval;⚠️ 不要用 ctx.setInterval——cordis ctx 是
|
|
51
51
|
* Proxy,访问未 inject 的 `timer` 属性会抛 "cannot get property ... without inject")。
|
|
52
|
+
* 返回的句柄上若有 `unref`,本模块会调用它(宿主退出不被轮询钉住;测试可用假句柄断言)。
|
|
52
53
|
* @param {(handle: unknown) => void} [options.unschedule]
|
|
53
54
|
* 定时器销毁(默认全局 clearInterval)。
|
|
54
55
|
* @returns {{ dispose(): void }}
|
|
@@ -117,6 +118,11 @@ export function createWatchdog(ctx, log = () => {}, options = {}) {
|
|
|
117
118
|
// 直接抛 "cannot get property \"timer\" without inject"(实测启动崩溃)。
|
|
118
119
|
// 全局 setInterval 由 host-runner sandbox 重定向(sandbox.js:92 TIMER_REDIRECT)。
|
|
119
120
|
timer = schedule(tick, intervalMs)
|
|
121
|
+
// 2026-09-18(外部 issue #1:桌面端托盘退出死锁):引用型定时器会把宿主进程钉在
|
|
122
|
+
// 事件循环上——会话早就跑完、插件也 dispose 过,宿主仍等这个 interval,退出/关机
|
|
123
|
+
// 被它拖住。宿主是 Node 侧 ⇒ unref(浏览器侧没有 unref,那里也不存在这个问题)。
|
|
124
|
+
// 注入的 schedule 同样走这一行,所以用例能用一个假句柄捕获到 unref 调用。
|
|
125
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
120
126
|
// 立即跑一次,避免启动后首个周期空窗。
|
|
121
127
|
tick().catch((error) => log(`retrace-watchdog: 首轮检查失败:${String(error)}`))
|
|
122
128
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-retrace",
|
|
3
3
|
"description": "Retrace · 回溯 — Recall, edit-and-resend, regenerate, and conversation/artifact versioning (timeline, rollback, fork map) for DeepSeek Harness — Web and Desktop",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.29",
|
|
5
5
|
"packageManager": "pnpm@11.7.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|