dsh-session-superagent 0.8.20 → 0.8.27

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 CHANGED
@@ -2,6 +2,134 @@
2
2
 
3
3
  格式参照 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.8.27] - 2026-11-02 —— 文案核对 + 把 0.8.21–0.8.27 一次发到 npm
6
+
7
+ ### 文案
8
+ - 「已添加,30 秒内开始轮询」不准确:`startWatcher()` 会**立刻**跑一次 tick(不等 30 秒的下限),
9
+ 改成「已添加,首轮立即开始轮询」。
10
+
11
+ ### 发布
12
+ - 一次把 0.8.21–0.8.27 发出去(npm 上原来停在 0.8.20)。这一批里有三个真机事故修复:
13
+ `inactive context`(唤醒会失败)、未预期异常不退避、热重载后重复路由导致面板全灭。
14
+
15
+ ### 测试
16
+ - 合计 **89 例**。
17
+
18
+ ## [0.8.26] - 2026-11-02 —— 卸载时真的把路由摘掉(重复路由的根因)
19
+
20
+ 0.8.25 让「一条重复不再全灭」,这一版处理根因:**卸载时要把旧路由摘掉**,否则宿主重载后
21
+ 永远是新旧两套并存,面板读到的是旧 handler(改了密码却还是老数据那种感觉)。
22
+
23
+ ### 修复
24
+ - 把清理逻辑抽成 `teardown()`,同时挂在 **`ctx.effect(() => teardown)`** 与 `ctx.on('dispose', …)` 两条路上:
25
+ effect 作用域销毁时一定调用,事件广播方式也能覆盖。`teardown()` 本身可重复调用(摘完就清空列表)。
26
+
27
+ ### 测试
28
+ - 新增用例:卸载时六条路由都被 unregister;重复卸载不会重复 unregister。合计 **89 例**。
29
+
30
+ ## [0.8.25] - 2026-11-02 —— 热重载时一条重复路由会让**全部**接口消失
31
+
32
+ ### 真机现场
33
+ ```
34
+ server = {at: 19:38:26Z, error: 'webserver: duplicate exact route "/session-superagent/state"'}
35
+ ```
36
+ 宿主热重载时,旧实例的路由还没被 dispose,新实例注册第一条就抛 —— 而注册是**一整块 try**,
37
+ 于是六条路由全军覆没,面板一片空白(数据接口全 404),只有重启才能恢复。
38
+
39
+ ### 修复
40
+ 每条路由改成单独容错(`addRoute()`):重复的那条把原因记进 `state.server.failed`,
41
+ 其余照常注册。单条重复不再等于全站不可用。
42
+
43
+ ### 守门
44
+ 新增 `test/route-resilience.test.mjs`:让宿主替身在注册 `client-error` 时抛重复路由,
45
+ 断言其余 5 条仍然注册成功、`/state` 仍在、失败原因被记录下来。
46
+
47
+ ### 测试
48
+ - 合计 **88 例**。
49
+
50
+ ## [0.8.24] - 2026-11-02 —— 安装说明是错的(`npm i` 不会让 DSH 加载插件)
51
+
52
+ ### 修复
53
+ README 原来写「方式一:npm,`npm i dsh-session-superagent`」,但 **DSH 插件必须装进 profile**
54
+ 才会被加载(包里的 `dsh.bundle.patch` 由 profile 的安装流程读取);`npm i` 只是下载到本地
55
+ `node_modules`,什么都不会发生。另一条「`dsh plugin add < GitHub URL>`」本机也没有这个子命令。
56
+
57
+ 对照同机的其它插件(`dsh-unlag` 用 `dsh plugin install dsh-unlag`、
58
+ `dsh-better-sidebar` 用 `dsh plugin --profile web add …@latest`)改成:
59
+
60
+ ```bash
61
+ dsh plugin install dsh-session-superagent
62
+ dsh plugin --profile desktop add dsh-session-superagent@latest
63
+ dsh plugin install /path/to/dsh-session-superagent # 本地开发
64
+ ```
65
+
66
+ 并写清「`npm i` 不会生效」与「装进 profile 后是硬链接、新增文件要 `sync:profile`」。
67
+
68
+ ### 测试
69
+ - 合计 **87 例**。
70
+
71
+ ## [0.8.23] - 2026-11-02 —— README 补「故障排查」,并把链接到的文档发进包
72
+
73
+ ### 文档
74
+ - README 新增**故障排查**表,全部来自真实事故:新增文件没进 profile 导致插件不挂载、
75
+ 硬链接断了导致「改了但没生效」、`inactive context`、幽灵会话不退避、
76
+ npm 刚发布时的 `ETARGET`、以及「有动静但没被叫醒」该看哪里(`watch_status` 的 `deferred=`、面板的「最近被过滤」)。
77
+ - README 底部链接的 `UPDATING.md` / `CHECKLIST.md` / `MAINTENANCE.md` 之前**不在 `files` 里**,
78
+ npm 页面上点了是 404。现在一起发出去(`npm pack` 10 → 13 个文件)。
79
+
80
+ ### 测试
81
+ - 合计 **87 例**。
82
+
83
+ ## [0.8.22] - 2026-11-02 —— 未预期异常也要退避(0.8.21 的同一类问题再补一刀)
84
+
85
+ 0.8.21 修的是「读 `ctx.agents` 会抛」。修完回头看 tick 的兜底:
86
+
87
+ ```js
88
+ } catch (error) {
89
+ entry.lastError = String(error.message) // 只记错误
90
+ }
91
+ ```
92
+
93
+ 也就是说 **任何**未预期异常都会让 watcher 按 `intervalSec` 无限重试 —— 正是 inactive-context
94
+ 那次的症状(`consecutiveFailures: 0`、`nextAt: None`)。现在 catch 里也 `bumpFailure(entry, every)`,
95
+ 坏掉的东西最多 30 分钟试一次,不会持续打 GitHub。
96
+
97
+ ### 说明
98
+ 这属于「同类问题系统性收口」,不是新功能:凡是走出 `pollOnce` 正常分支的失败,现在都会进退避窗口。
99
+
100
+ ### 测试
101
+ - 合计 **87 例**(该分支需要真实定时器上下文,靠重载后的真机复验确认)。
102
+
103
+ ## [0.8.21] - 2026-11-02 —— 真机复验抓到的**唤醒致命 bug**:inactive context
104
+
105
+ 宿主重载后(`routes: 6`,0.8.14–0.8.20 全部生效)做了真机复验,造了一条绑定「不存在会话」的 watcher:
106
+
107
+ ```
108
+ lastError: cannot get required service "agents" in inactive context
109
+ consecutiveFailures: 0 ← 没进退避
110
+ nextAt: None ← 每 intervalSec 都会再打一次 GitHub
111
+ ```
112
+
113
+ ### 根因
114
+ 定时器回调跑在**非活跃上下文**里,直接读 `ctx.agents` 会抛错。读 `goals` 一直用的是
115
+ `ctx.get('goals')`(不抛),唯独 `agents` 用的是 `ctx.agents` —— 这行代码在**每一次真唤醒**、
116
+ 每一次「会话还在不在」判断上都会执行。
117
+
118
+ ### 影响
119
+ - 幽灵会话判断(0.8.11)在本机其实从没跑到过:轮询在更早的一行就抛了。
120
+ - 更严重的是:**如果宿主对定时器上下文同样报错,真实唤醒也会失败**(看起来"在盯",其实一次都没成)。
121
+
122
+ ### 修复
123
+ 新增 `agentsService(ctx)`:优先 `ctx.get('agents')`,取不到再兜底直接读;`wake()`、
124
+ `pollOnce()` 与两个工具(`watch_start` / `watch_stop`)全部改走它。
125
+
126
+ ### 守门
127
+ 新增 `test/agents-context.test.mjs`:用一个**读 `ctx.agents` 就抛**的宿主替身,验证
128
+ `watch_start` / `watch_status` 仍然正常,并断言不会把宿主的上下文错误当成轮询错误。这条路以前完全没测过。
129
+
130
+ ### 测试
131
+ - 合计 **87 例**。
132
+
5
133
  ## [0.8.20] - 2026-11-02 —— 冷启动演练:工具返回要能证明「选项生效了」
6
134
 
7
135
  照 HANDOFF + README 走一遍「加 watcher → 观察 → 确认」(不看代码,只看文档),暴露的摩擦点:
@@ -16,6 +144,14 @@
16
144
  HTTP 只在面板/调试时用 —— 并写清端口每次重启都会变、怎么找(会话上下文里的 GUI URL 或按 PID 查 netstat)。
17
145
  冷启动演练时就是卡在「端口找不到」这一步。
18
146
 
147
+ ### 发布
148
+ - 已发 `dsh-session-superagent@0.8.20`(一次性带上 0.8.17–0.8.20 的改动;`latest = 0.8.20`)。
149
+ - tarball 直装验证:`version 0.8.20`、**routes 6**、`client.js` 语法通过、
150
+ 包内只有 7 项(CHANGELOG/LICENSE/README/client.js/cordis.patch.yml/lib/package.json)。
151
+ - **CI 已接入 `preflight --fast`**:GitHub Actions 在 Node 22 / 24 两个矩阵上跑
152
+ 「单测 / 语法 / 版本一致 / 路由面 / 包内容」,本地无 profile 时该检查自动 SKIP。
153
+ 运行记录:`gh run list` → `test (22)` 与 `test (24)` 均 success。
154
+
19
155
  ### 测试
20
156
  - 合计 **85 例**。
21
157
 
@@ -465,7 +601,7 @@ OK .dsh-zhuli 下正好一个副本
465
601
 
466
602
  ## [0.8.0] - 2026-11-01 —— 移植官方 superagent 的 GUI
467
603
 
468
- 把官方 `dsh-superagent`(D:\主工作区\projects\dsh-superagent-prototype)的**设计系统与信息架构**
604
+ 把官方 `dsh-superagent`(本地 checkout 的 dsh-superagent-prototype)的**设计系统与信息架构**
469
605
  搬进「主理鱼」标签页,数据接我们自己的 `/state`、`/watch`。
470
606
 
471
607
  ### 移植了什么
@@ -595,7 +731,7 @@ OK .dsh-zhuli 下正好一个副本
595
731
 
596
732
  ### 修复
597
733
  - **中文路径被写成乱码**:Windows 下 argv 里的中文常是 GBK 字节,按 UTF-8 解得到一串 U+FFFD
598
- (实测 `D:/主工作区/...` 存成 `D:/??????/...`,worktree 建不出来)。新增 `decodeBody()`:
734
+ (实测中文路径 `D:/某中文目录/...` 存成 `D:/??????/...`,worktree 建不出来)。新增 `decodeBody()`:
599
735
  UTF-8 解码出现替换字符就用 `TextDecoder('gbk')` 重解。
600
736
  - **同一个 watcher 并跑两轮**:首轮 tick 与手动「立刻」撞车会开两个隔离区。`entry.polling` 做并发闸,重复请求 409。
601
737
  - `/state` 补 `isolated` / `workdir` / `lastWorktree`。
package/CHECKLIST.md ADDED
@@ -0,0 +1,26 @@
1
+ # CHECKLIST —— 两张表,两个交付门
2
+
3
+ ## A 表 · 代码交付前
4
+
5
+ - [ ] `node --test` 全绿,且新增行为有测试
6
+ - [ ] 所有返回字符串都过 `redact()`(新增输出路径重点查)
7
+ - [ ] 写操作仍要求 `confirm: true`
8
+ - [ ] 唤醒仍受 `agent.status === 'idle'` 约束
9
+ - [ ] 唤醒失败时不推进水位(`entry.seen` 不动)
10
+ - [ ] `package.json` 的 `files` 覆盖所有要发布的文件(`lib` + 文档 + patch)
11
+ - [ ] 没有把本地路径、个人 token、机器名写进仓库
12
+
13
+ ## B 表 · 发版前
14
+
15
+ - [ ] 从最新 `main` 起功能分支
16
+ - [ ] `CHANGELOG.md` 增加条目,`package.json` bump 版本
17
+ - [ ] 本地 `node --test` 通过
18
+ - [ ] PR 合并到 `main`
19
+ - [ ] 打 annotated tag `vX.Y.Z` 并推送
20
+ - [ ] 创建 GitHub Release,说明取自 CHANGELOG
21
+ - [ ] 安装验证:`dsh plugin --profile <p> add github:MYCF711/dsh-session-superagent`
22
+
23
+ ## 发完之后
24
+
25
+ - [ ] 在测试仓库 `watch_start` 跑一轮,确认唤醒可达
26
+ - [ ] 记录一次真实运行的观察结果到本仓库 issue
package/MAINTENANCE.md ADDED
@@ -0,0 +1,35 @@
1
+ # MAINTENANCE —— 维护台账(给接手的 agent 看)
2
+
3
+ ## 一、身份
4
+
5
+ - 包名:`dsh-session-superagent`
6
+ - 形态:DSH 插件(tools-only + 自带 `cordis.patch.yml`)
7
+ - 目标宿主:DeepSeek Harness ≥ 0.2.1-rc.5
8
+ - 依赖:宿主已安装并登录 `gh` CLI;Node ≥ 22.19
9
+
10
+ ## 二、当前状态(2026-11-01)
11
+
12
+ - [x] 七个工具可用(github / repo_here / watch_start / watch_status / watch_stop / watch_ack / watch_doctor),`node --test` 88/88 通过
13
+ - [x] 自回声抑制(逐条水位)
14
+ - [x] 仅空闲注入(避免草稿回放)
15
+ - [x] 唤醒失败不推进水位
16
+ - [ ] webhook 代替轮询(未做)
17
+ - [ ] 多仓库单会话(未做,当前每会话一仓库)
18
+
19
+ ## 三、下一步
20
+
21
+ 1. 真实仓库连跑 24h,统计 `lastWake` 与误唤醒次数;
22
+ 2. 把 `trigger.json` 从正式包移出(或加开关),它只是测试通道;
23
+ 3. 评估 `steer()` 与 `followup()` 在不同场景下的取舍。
24
+
25
+ ## 四、硬约束
26
+
27
+ 1. **不注入正在运行的会话**——会污染客户端输入框,可能导致重复发送。
28
+ 2. **凭证永不出插件**:所有返回必须过 `redact()`;新增输出路径要补测试。
29
+ 3. **不改 profile 的 `bundles`**:那是操作者动作,包只带 patch。
30
+ 4. **不删除用户文件**;状态文件写失败必须静默降级。
31
+ 5. **没跑过的写成“未验证”**,不写“通过”。
32
+
33
+ ## 五、最短接手路径
34
+
35
+ 读 `README.md`(做什么)→ 本文件(现状与约束)→ `CHECKLIST.md`(交付门)→ `UPDATING.md`(怎么发版)→ `lib/watch.js`(核心逻辑,纯函数,测试齐全)。
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # dsh-session-superagent
2
2
 
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
- [![Version](https://img.shields.io/badge/version-0.8.20-green.svg)](https://github.com/MYCF711/dsh-session-superagent/releases)
4
+ [![Version](https://img.shields.io/badge/version-0.8.27-green.svg)](https://github.com/MYCF711/dsh-session-superagent/releases)
5
5
  [![npm](https://img.shields.io/npm/v/dsh-session-superagent.svg)](https://www.npmjs.com/package/dsh-session-superagent)
6
6
  [![dsh](https://img.shields.io/badge/DeepSeek%20Harness-plugin-orange.svg)](https://github.com/topics/dsh-plugin)
7
7
 
@@ -51,15 +51,24 @@ GitHub 有新动态 → watcher 轮询 → Agent.followup(消息) → 会话跑
51
51
 
52
52
  ## 安装
53
53
 
54
+ **装进 profile**(DSH 插件不是 `npm i` 就生效,要装到 profile 里):
55
+
54
56
  ```bash
55
- # 方式一:npm(已发布)
56
- npm i dsh-session-superagent
57
+ # 默认 profile
58
+ dsh plugin install dsh-session-superagent
59
+
60
+ # 指定 profile / 锁定版本
61
+ dsh plugin --profile desktop add dsh-session-superagent@latest
57
62
 
58
- # 方式二:从 GitHub 源码装(会跟踪 main)
59
- dsh plugin add https://github.com/MYCF711/dsh-session-superagent
63
+ # 本地开发:直接装这个目录(会以 file: 硬链接方式挂进 profile)
64
+ dsh plugin install /path/to/dsh-session-superagent
60
65
  ```
61
66
 
62
- 装进 profile 时是 `file:` 硬链接,改代码即改线上;**但新增文件不会被自动带过去**,
67
+ > `npm i dsh-session-superagent` 只会把它下载到本地 node_modules,**不会**让 DSH 加载它 ——
68
+ > 包里的 `dsh.bundle.patch` 要由 profile 的安装流程来读。
69
+ > 也可以在应用内的插件市场里搜索安装。
70
+
71
+ 装进 profile 后是 `file:` 硬链接:改源码即改线上;**但新增文件不会被自动带过去**,
63
72
  加完文件要跑 `pnpm run sync:profile`(否则下次重启插件会整体不挂载,这坑踩过一次)。
64
73
 
65
74
  ## 一次真实运行长什么样
@@ -88,11 +97,24 @@ dsh plugin add https://github.com/MYCF711/dsh-session-superagent
88
97
  ## 开发
89
98
 
90
99
  ```sh
91
- node --test # 85 项单测
100
+ node --test # 88 项单测
92
101
  ```
93
102
 
94
103
  版本与发布流程见 [UPDATING.md](UPDATING.md),交付门见 [CHECKLIST.md](CHECKLIST.md),接手台账见 [MAINTENANCE.md](MAINTENANCE.md)。
95
104
 
105
+ ## 故障排查(都是真实踩过的)
106
+
107
+ | 症状 | 原因 | 处理 |
108
+ |---|---|---|
109
+ | 面板空白、`watch_*` 工具消失、`/session-superagent/*` 全 404 | 新增的 `lib/*.js` 没同步进 profile(`file:` 安装只对**已有**文件建硬链接),宿主 import 失败 → 整个插件不挂载 | `pnpm run sync:profile`,然后重载插件 |
110
+ | 改了代码但线上行为没变 | 硬链接断了 —— **原子写、`git checkout/restore`、某些编辑器的保存**都会换 inode,profile 里还是旧文件 | `pnpm run sync:profile:check` 看漂移,`sync:profile:repair` 修 |
111
+ | 轮询报 `cannot get required service "agents" in inactive context` | 0.8.21 之前的 bug:定时器里直接读 `ctx.agents` | 升级到 ≥0.8.21 |
112
+ | 绑定的会话已删除,watcher 还在打 GitHub | 0.8.14 之前的 bug:该类失败没进退避 | 升级到 ≥0.8.14(0.8.22 起未预期异常也会退避) |
113
+ | `npm i dsh-session-superagent@<新版本>` 报 `ETARGET` | 刚发布,npm 客户端元数据缓存还没刷新 | 等 1-2 分钟,或 `npm i <tarball URL>` / `--prefer-online` |
114
+ | 有动静但会话没被叫醒 | 会话忙 / 时段外 / 每小时上限 / 被子过滤器刷掉 | `watch_status` 会显示 `deferred=` / `paused`;面板「总览」的「最近被过滤」给原因 |
115
+
116
+ `pnpm run preflight` 一条命令可自查:单测、语法、profile 一致性、版本一致、路由面、包内容、冒烟。
117
+
96
118
  ## 许可
97
119
 
98
120
  MIT,见 [LICENSE](LICENSE)。
package/UPDATING.md ADDED
@@ -0,0 +1,43 @@
1
+ # 更新与发布
2
+
3
+ ## 铁律
4
+
5
+ 默认分支只接受通过门禁的 PR;`main` 永远可安装。
6
+
7
+ ## 发一个版本
8
+
9
+ ```bash
10
+ git checkout main && git pull
11
+ git checkout -b feat/<主题>
12
+
13
+ # 1) 改代码 + 补测试
14
+ node --test
15
+
16
+ # 2) bump 版本 + 写 CHANGELOG
17
+ # 语义化:修 bug → patch;加能力 → minor;改默认行为/改 patch 行 id → major
18
+
19
+ # 3) 提交并推分支
20
+ git add -A && git commit -m "feat: ..."
21
+ git push -u origin feat/<主题>
22
+
23
+ # 4) PR 全绿后合并
24
+ gh pr create --fill && gh pr merge --squash --delete-branch
25
+
26
+ # 5) 回到 main 打 tag 并发 Release
27
+ git checkout main && git pull
28
+ git tag -a v0.1.1 -m "v0.1.1"
29
+ git push origin v0.1.1
30
+ gh release create v0.1.1 --title "v0.1.1" --notes-from-tag
31
+ ```
32
+
33
+ ## 用户怎么升级
34
+
35
+ ```bash
36
+ dsh plugin --profile <p> update dsh-session-superagent
37
+ ```
38
+
39
+ pnpm 依据 `github:` 规格重新解析;或重新 `add` 一次覆盖。
40
+
41
+ ## 兼容性
42
+
43
+ 插件依赖宿主内部 API(`ctx.tools`、`ctx.agents`)。升级到新的 DSH 内部 API 视为 **major**,因为旧宿主可能不再提供同样的服务。
package/client.js CHANGED
@@ -14,7 +14,7 @@ window.__ModuleLoader__.load({
14
14
  const { useCallback, useEffect, useRef, useState } = React
15
15
 
16
16
  // 客户端版本探针:每次模块加载写一次,用来确认浏览器里跑的是哪一版
17
- const CLIENT_VERSION = '0.8.20'
17
+ const CLIENT_VERSION = '0.8.27'
18
18
  const BASE = '/session-superagent'
19
19
  const ENDPOINT = BASE + '/state'
20
20
  const POLL_MS = 5000
@@ -739,7 +739,7 @@ window.__ModuleLoader__.load({
739
739
  }),
740
740
  })
741
741
  setForm({ repo: '', intervalSec: form.intervalSec, goal: '', labels: '', onlyAssigned: false, activeHours: form.activeHours, isolated: form.isolated, workdir: form.workdir, observe: form.observe })
742
- }, '已添加,30 秒内开始轮询')
742
+ }, '已添加,首轮立即开始轮询')
743
743
 
744
744
  const saveCredentials = () => run(async () => {
745
745
  const payload = {}
package/lib/index.js CHANGED
@@ -182,6 +182,22 @@ function ghEnv() {
182
182
  return { ...process.env, GH_TOKEN: String(s.token) }
183
183
  }
184
184
 
185
+ /**
186
+ * 安全地取 agents 服务。
187
+ * 定时器回调跑在「非活跃上下文」里,直接读 `ctx.agents` 会抛
188
+ * `cannot get required service "agents" in inactive context`(2026-11-02 真机复验踩到);
189
+ * `ctx.get(name)` 不抛,取不到就返回 undefined。
190
+ */
191
+ function agentsService(ctx) {
192
+ try {
193
+ if (typeof ctx.get === 'function') {
194
+ const viaGet = ctx.get('agents')
195
+ if (viaGet) return viaGet
196
+ }
197
+ } catch {}
198
+ try { return ctx.agents } catch { return undefined }
199
+ }
200
+
185
201
  /** gh CLI 可用性探针:5 分钟缓存,别让面板每 5 秒都 spawn 一次 gh。 */
186
202
  const ghHealthCache = { at: 0, value: null }
187
203
  async function ghHealth() {
@@ -339,7 +355,8 @@ function defineTool(options) {
339
355
  * inbox alone only parks the message (the client shows it as a pending draft).
340
356
  */
341
357
  function wake(ctx, sessionId, text) {
342
- const agent = ctx.agents && typeof ctx.agents.get === 'function' ? ctx.agents.get(sessionId) : undefined
358
+ const agents = agentsService(ctx)
359
+ const agent = agents && typeof agents.get === 'function' ? agents.get(sessionId) : undefined
343
360
  if (!agent) return { ok: false, reason: 'session not live' }
344
361
 
345
362
  const allowed = decideWake(agent.status)
@@ -468,7 +485,8 @@ async function pollOnce(ctx, entry, baseMs) {
468
485
  return
469
486
  }
470
487
  // 会话忙就别开隔离区了:白建一个没人用的 worktree
471
- const liveAgent = ctx.agents && typeof ctx.agents.get === 'function' ? ctx.agents.get(entry.sessionId) : undefined
488
+ const liveAgents = agentsService(ctx)
489
+ const liveAgent = liveAgents && typeof liveAgents.get === 'function' ? liveAgents.get(entry.sessionId) : undefined
472
490
  if (!liveAgent) {
473
491
  // 会话已经不在了(被删/换机器):别开隔离区,也别假装在盯。
474
492
  // 必须走 bumpFailure —— 否则这条会每 intervalSec 打一次 GitHub,永不退避(第四轮审核指出)。
@@ -540,7 +558,10 @@ export function apply(ctx) {
540
558
  try {
541
559
  await pollOnce(ctx, entry, every)
542
560
  } catch (error) {
561
+ // 未预期的异常(比如宿主上下文/服务不可用)也必须退避:
562
+ // 之前只写 lastError,于是坏掉的 watcher 会按 intervalSec 无限重试(真机踩到过)。
543
563
  entry.lastError = String((error && error.message) || error)
564
+ bumpFailure(entry, every)
544
565
  } finally {
545
566
  inFlight.delete(entry.id)
546
567
  persistEntry(entry)
@@ -569,6 +590,17 @@ export function apply(ctx) {
569
590
  const registerRoutes = (httpServer) => {
570
591
  if (!httpServer || typeof httpServer.register !== 'function') return
571
592
  const routes = []
593
+ // 宿主热重载时旧实例的路由可能还没 dispose;一条重复会让整个 try 崩掉,
594
+ // 面板和所有接口一起 404(2026-11-02 真机踩到 webserver: duplicate exact route)。
595
+ // 改成每条路由单独容错:重复的那条记下来,其余照常注册。
596
+ const failedRoutes = []
597
+ const addRoute = (options) => {
598
+ try {
599
+ routes.push(httpServer['register'](options))
600
+ } catch (error) {
601
+ failedRoutes.push(String((error && error.message) || error).slice(0, 120))
602
+ }
603
+ }
572
604
  const record = (kind, detail) => {
573
605
  const state = readState()
574
606
  state.client = state.client || {}
@@ -576,7 +608,7 @@ export function apply(ctx) {
576
608
  writeState(state)
577
609
  }
578
610
  try {
579
- routes.push(httpServer.register({
611
+ addRoute({
580
612
  kind: 'exact',
581
613
  path: '/session-superagent/state',
582
614
  handler: async (req, res) => {
@@ -627,8 +659,8 @@ export function apply(ctx) {
627
659
  watchers: watchers.map((w) => ({ ...w, goal: goalView(ctx, w.sessionId) })),
628
660
  }))
629
661
  },
630
- }))
631
- routes.push(httpServer.register({
662
+ })
663
+ addRoute({
632
664
  kind: 'exact',
633
665
  path: '/session-superagent/client-note',
634
666
  handler: (req, res) => {
@@ -640,12 +672,12 @@ export function apply(ctx) {
640
672
  }
641
673
  res.statusCode = 204; res.end()
642
674
  },
643
- }))
644
- routes.push(httpServer.register({
675
+ })
676
+ addRoute({
645
677
  kind: 'exact',
646
678
  path: '/session-superagent/client-error',
647
679
  handler: (req, res) => { record('error', String(req.url).slice(0, 400)); res.statusCode = 204; res.end() },
648
- }))
680
+ })
649
681
  const readBody = (req) => new Promise((resolve) => {
650
682
  const chunks = []
651
683
  req.on('data', (chunk) => chunks.push(chunk))
@@ -666,7 +698,7 @@ export function apply(ctx) {
666
698
 
667
699
  // Create or update a watcher from the panel. The panel is session-scoped,
668
700
  // so it must send the session id it is bound to.
669
- routes.push(httpServer.register({
701
+ addRoute({
670
702
  kind: 'exact',
671
703
  path: '/session-superagent/watch',
672
704
  handler: async (req, res) => {
@@ -747,11 +779,11 @@ export function apply(ctx) {
747
779
  }
748
780
  json(res, 200, { ok: true, id, repo, intervalSec, enabled: entry.enabled })
749
781
  },
750
- }))
782
+ })
751
783
 
752
784
  // Credentials typed by the operator. Stored 0600 under the state dir and
753
785
  // never echoed back; the answer carries only a masked summary.
754
- routes.push(httpServer.register({
786
+ addRoute({
755
787
  kind: 'exact',
756
788
  path: '/session-superagent/credentials',
757
789
  handler: async (req, res) => {
@@ -775,12 +807,12 @@ export function apply(ctx) {
775
807
  writeSecrets(next)
776
808
  json(res, 200, { credentials: secretsSummary() })
777
809
  },
778
- }))
810
+ })
779
811
 
780
812
  // Set / edit / clear the session's REAL goal through ctx.goals — the same
781
813
  // service the /goal command drives, so the panel and the conversation are
782
814
  // one source of truth (polling reads it back).
783
- routes.push(httpServer.register({
815
+ addRoute({
784
816
  kind: 'exact',
785
817
  path: '/session-superagent/goal',
786
818
  handler: async (req, res) => {
@@ -812,23 +844,29 @@ export function apply(ctx) {
812
844
  return json(res, 400, { error: String((error && error.message) || error) })
813
845
  }
814
846
  },
815
- }))
847
+ })
816
848
 
817
849
  const state = readState()
818
- state.server = { at: new Date().toISOString(), routes: routes.length }
850
+ state.server = { at: new Date().toISOString(), routes: routes.length, failed: failedRoutes }
819
851
  writeState(state)
820
852
  } catch (error) {
821
853
  const state = readState()
822
- state.server = { at: new Date().toISOString(), error: String((error && error.message) || error).slice(0, 300) }
854
+ state.server = { at: new Date().toISOString(), routes: routes.length, failed: failedRoutes, error: String((error && error.message) || error).slice(0, 300) }
823
855
  writeState(state)
824
856
  }
825
- ctx.on('dispose', () => {
857
+ const teardown = () => {
826
858
  for (const dispose of routes) { try { dispose() } catch {} }
859
+ routes.length = 0
827
860
  // 插件卸载/热重载后旧定时器不能继续跑:它们还持有已 dispose 的 ctx
828
861
  for (const handle of timers.values()) { try { clearInterval(handle) } catch {} }
829
862
  timers.clear()
830
863
  try { clearInterval(sweepHandle) } catch {}
831
- })
864
+ }
865
+ // 两条路都挂上:ctx.effect 的清理函数在 effect 作用域销毁时一定被调用;
866
+ // ctx.on('dispose') 覆盖宿主走事件广播的卸载方式。真机踩过「路由没 dispose →
867
+ // 重载后重复路由 → 六条接口全灭」,多挂一条成本很低。
868
+ try { ctx.effect(() => teardown) } catch {}
869
+ ctx.on('dispose', teardown)
832
870
  }
833
871
 
834
872
  // Routes for the panel. `webServer` is the host's route registry (the same
@@ -1032,7 +1070,8 @@ export function apply(ctx) {
1032
1070
  goal: { type: 'string', description: 'Optional per-watcher goal appended to the wake message.' },
1033
1071
  },
1034
1072
  async execute(args) {
1035
- const agent = ctx.agents && typeof ctx.agents.currentInitiator === 'function' ? ctx.agents.currentInitiator() : undefined
1073
+ const toolAgents = agentsService(ctx)
1074
+ const agent = toolAgents && typeof toolAgents.currentInitiator === 'function' ? toolAgents.currentInitiator() : undefined
1036
1075
  const sessionId = agent && agent.id
1037
1076
  if (!sessionId) return '[error: no live session — watch_start must be called from a session turn]'
1038
1077
 
@@ -1135,7 +1174,8 @@ export function apply(ctx) {
1135
1174
  description: 'Stop watching a repository for the current session.',
1136
1175
  parameters: { repo: { type: 'string', required: true, description: 'owner/name' } },
1137
1176
  async execute(args) {
1138
- const agent = ctx.agents && typeof ctx.agents.currentInitiator === 'function' ? ctx.agents.currentInitiator() : undefined
1177
+ const toolAgents = agentsService(ctx)
1178
+ const agent = toolAgents && typeof toolAgents.currentInitiator === 'function' ? toolAgents.currentInitiator() : undefined
1139
1179
  const sessionId = agent && agent.id
1140
1180
  const id = `${args.repo}#${sessionId}`
1141
1181
  const state = readState()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-superagent",
3
- "version": "0.8.20",
3
+ "version": "0.8.27",
4
4
  "description": "Session-level superagent for DeepSeek Harness: watch a GitHub repository and wake a session when something needs doing. Credentials never reach the model.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -17,7 +17,10 @@
17
17
  "README.md",
18
18
  "CHANGELOG.md",
19
19
  "LICENSE",
20
- "client.js"
20
+ "client.js",
21
+ "UPDATING.md",
22
+ "CHECKLIST.md",
23
+ "MAINTENANCE.md"
21
24
  ],
22
25
  "engines": {
23
26
  "node": ">=22.19.0",