dsh-llm-workbuddy 0.1.16 → 0.1.17

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
@@ -4,6 +4,56 @@
4
4
  每次发版请同步 `package.json` 的 `version` 并打一个 `git tag`(如 `v0.1.13`),
5
5
  在 GitHub 创建 Release 时本文件即为更新说明来源。
6
6
 
7
+ ## [0.1.17] - 2026-09-11
8
+
9
+ 修复社区反馈的两个 issue([#3](https://github.com/zdk119746/dsh-llm-workbuddy/issues/3)、
10
+ [#4](https://github.com/zdk119746/dsh-llm-workbuddy/issues/4))。
11
+
12
+ ### Fixed
13
+ - **登录态永远判定为有效,且「登录」按钮无法恢复(issue #3)**。
14
+ 代理(workbuddy2api)写入的 `~/.codebuddy-session.json` 里**没有
15
+ `auth.expiresAt`**,而插件把"字段缺失"当作"未过期",于是:
16
+ `authenticated` 恒为 `true`、`POST /api/workbuddy/login` 恒返回
17
+ `alreadyLoggedIn`、诊断面板的「登录状态/登录令牌」恒显示"有效";一旦 token
18
+ 真失效,用户点登录没有任何反应,只能回终端手跑脚本。
19
+ 现改为**多源推导过期时间**(按优先级):
20
+ 1. `auth.expiresAt`(本插件脚本写入;秒/毫秒自动归一化);
21
+ 2. `refreshToken` 的 JWT `exp`——代理会自动 refresh,所以 refreshToken 的
22
+ 有效期才是会话真正的边界;
23
+ 3. `accessToken` 的 JWT `exp`(解码 payload,不校验签名);
24
+ 4. `auth.expiresIn` + 会话文件 mtime(兜底,只会低估、不会高估)。
25
+ 全部失败时返回 `expiryKnown: false` + `reloginRecommended: true`,
26
+ **不再假装有效**。`/status` 与诊断面板都会带上 `expiresAtSource` 说明来源。
27
+ - **`authUrl` 永远为 `null`,点「登录」在新标签页打不开任何页面(issue #3 相关)**。
28
+ 登录路由 `spawn` 后**立刻** `res.end()`,而设备流链接是子进程稍后才打印到
29
+ stdout 的,因此返回的 `authUrl` 恒为 `null`,Web 登录实际上从来没成功打开过
30
+ 授权页。现改为等待链接出现(或脚本早退/15 秒超时)再应答,失败时返回结构化
31
+ `error`,前端弹窗提示而不是静默无反应。子进程 stdout 在应答后继续被 drain,
32
+ 避免缓冲写满阻塞。
33
+ - **「🎁 签到」必然 404,且每天写入一条失败记录(issue #4)**。
34
+ 插件请求代理的 `/checkin-status` 与 `/checkin`,但 workbuddy2api **从未实现**
35
+ 这两个端点(已核对 PyPI 2.0.0–2.0.4 与 `main` / `dsh` / `codex` 全部分支),
36
+ 且 `autoCheckin` 默认 `true`,于是每天 10 点后必然失败一次并把失败结果持久化。
37
+ 现改为**探测 + 优雅降级**:`/checkin-status` 返回 404/405 时判定该代理
38
+ **不支持签到**,隐藏签到入口、自动签到直接跳过(**不写任何状态**)。探测结果
39
+ 缓存 30 分钟,`/status` 通过 `checkin.supported` 三态(`true`/`false`/`null`)
40
+ 暴露给胶囊。代理补齐端点后入口会自动出现,无需升级插件。
41
+
42
+ ### Added
43
+ - **🔑 重新登录(强制重登)**:`POST /api/workbuddy/login?force=1` 忽略本地会话
44
+ 判定直接重跑设备流;胶囊**始终**提供该入口(不做条件显示),「判定错误 →
45
+ 无法恢复」的耦合从此断开。
46
+
47
+ ### Changed
48
+ - 诊断面板新增「令牌过期」(含来源)与重新登录提示;`login` 失败会弹窗说明原因。
49
+ - **README 代理安装指引更正**:原先要求"必须用 GitHub 主分支源码、不能用 PyPI
50
+ 2.0.3",实测 PyPI **2.0.4**(2026-09-10 发布)已包含该登录修复(
51
+ `X-Product-Code` / `_enterprise_headers` / `X-Domain` 均在包内),且 GitHub
52
+ `main` 与 2.0.4 **内容完全一致**;而 2.0.3 确实缺少该修复。现改为推荐
53
+ `uv tool install -U workbuddy2api`(`>= 2.0.4`),源码方式作为等价备选。
54
+ - 诊断面板的「重启命令」同步改为 `uv tool install` 方式。
55
+ - README 补充「登录态判定」章节与签到依赖说明。
56
+
7
57
  ## [0.1.16] - 2026-09-10
8
58
 
9
59
  ### Fixed
package/README.md CHANGED
@@ -62,13 +62,23 @@
62
62
  1. **代理必须独立安装并运行**:本插件**不打包**第三方代理 workbuddy2api。
63
63
  请在你的机器上单独安装并启动它(见下方「安装代理」),它把 WorkBuddy/CodeBuddy
64
64
  的私有协议转成标准 OpenAI chat-completions 格式。
65
- 2. 代理必须跑在 **workbuddy2api 主分支源码**上,不能用 PyPI 的 2.0.3:
66
- 旧版缺少 `X-Product-Code` / Genie-IDE 等请求头,登录会在最后一步 401。
65
+ 2. 代理版本需 **`workbuddy2api >= 2.0.4`**(推荐直接从 PyPI 装,
66
+ 见下方「安装代理」):
67
+ - **2.0.3 及更早不可用**:缺少 `X-Product-Code` / Genie-IDE 等请求头与
68
+ 企业认证头(`_enterprise_headers` / `X-Domain`),登录会在最后一步 401;
69
+ - **2.0.4 已包含该修复**("修复登录账户轮询与企业认证 headers 传递"),
70
+ 登录成功;GitHub `main` 当前与 2.0.4 内容一致,两者皆可。
67
71
  3. 登录使用**插件内置的 `login_workbuddy.py`**(本包自带,依赖系统 `python3`,
68
72
  纯标准库,Python 3.7+ 即可),**不需要**代理自带的 `--login`(VSCode platform
69
73
  会 401)。
70
74
  4. 插件装入 profile 后需要**重启 `dsh web`** 才能加载新的 bundle(包括本小部件)。
71
75
 
76
+ > **关于「🎁 签到」**:该功能依赖代理提供 `/v1/checkin-status` 与 `/v1/checkin`
77
+ > 转发。截至 `workbuddy2api` 2.0.4,**代理并未实现这两个端点**(任何分支/PyPI
78
+ > 版本都没有)。插件会自动探测:探测到 404 时隐藏签到入口、并且不再每天自动
79
+ > 尝试,因此不会产生无效的失败记录。若将来代理补齐了这两个端点,签到入口会
80
+ > 自动重新出现,无需升级插件。
81
+
72
82
  ---
73
83
 
74
84
  ## 安装插件
@@ -102,24 +112,35 @@ CLI 会把依赖写进 profile 并把 `dsh-llm-workbuddy` 追加到 `dsh.profile
102
112
 
103
113
  ### 全新机器 / 标准安装(任何能跑 Python 的机器)
104
114
 
105
- 别人或你自己在**新机器**上用 npm 版插件时,按以下 3 步装代理:
115
+ 别人或你自己在**新机器**上用 npm 版插件时,推荐直接用 PyPI 装(版本 `>= 2.0.4`):
106
116
 
107
117
  ```sh
108
118
  # 1. 安装 uv(本机 Python 工具,若已装可跳过)
109
119
  curl -LsSf https://astral.sh/uv/install.sh | sh
110
120
 
111
- # 2. 拉取 workbuddy2api 主分支源码
112
- git clone https://github.com/hawklithm/workbuddy2api.git
113
- cd workbuddy2api
121
+ # 2. 安装代理(PyPI,需 >= 2.0.4;2.0.3 及更早缺登录所需请求头)
122
+ uv tool install -U workbuddy2api
114
123
 
115
124
  # 3. 启动代理(监听 127.0.0.1:8787;先完成下方「登录」后再真正调用模型)
125
+ workbuddy2api --desensitize \
126
+ --session-file ~/.codebuddy-session.json \
127
+ --log-file ~/.codebuddy-proxy.jsonl
128
+ ```
129
+
130
+ <details>
131
+ <summary>或者:从 GitHub 源码运行(等价,main 与 2.0.4 内容一致)</summary>
132
+
133
+ ```sh
134
+ git clone https://github.com/hawklithm/workbuddy2api.git
135
+ cd workbuddy2api
116
136
  uv run python -u -m codebuddy_proxy --desensitize \
117
137
  --session-file ~/.codebuddy-session.json \
118
138
  --log-file ~/.codebuddy-proxy.jsonl
119
139
  ```
140
+ </details>
120
141
 
121
- > 之后任何时候想重启代理,就在 `workbuddy2api` 目录里重跑上面第 3 条命令。
122
- > 若提示 `address already in use`,先停掉旧代理:
142
+ > 之后任何时候想重启代理,重跑上面第 3 条命令即可(用 `uv tool` 安装时直接跑
143
+ > `workbuddy2api ...`)。若提示 `address already in use`,先停掉旧代理:
123
144
  > `lsof -tiTCP:8787 -sTCP:LISTEN | xargs kill`,再重跑。
124
145
 
125
146
  ### 本仓库本地开发(可选快捷方式)
@@ -161,12 +182,34 @@ curl http://127.0.0.1:8787/v1/models # 模型列表(含 glm-5.2 / deepseek-v4-
161
182
  解析它打印的设备流链接(`authUrl`)返回给前端;前端用 `window.open` 在**新标签页**
162
183
  打开该登录页。
163
184
  4. 在新标签页用 WorkBuddy / CodeBuddy 账号(腾讯账号)完成扫码/授权。
164
- 5. 胶囊会自动从每 5 秒轮询加快到每 2 秒(最多 30 次),一旦检测到
165
- `.workbuddy/session.json` `auth.expiresAt` 未过期且含 `accessToken`,
166
- 就切回 🟢 绿态,显示账号昵称。
185
+ 5. 胶囊会自动从每 5 秒轮询加快到每 2 秒(最多 30 次),一旦会话的过期时间
186
+ (见下方「登录态判定」)显示仍有效且含 `accessToken`,就切回绿态,
187
+ 显示账号昵称。
167
188
 
168
189
  整个过程**不需要离开浏览器、不需要回终端**。
169
190
 
191
+ > **🔑 重新登录(强制重登)**:胶囊 **⚙️ → 🔑 重新登录** 会带 `?force=1` 调用
192
+ > 登录接口,**忽略本地会话判定**直接重跑设备流。该入口**始终可用**:本地判定
193
+ > 依赖从会话文件推导的过期时间,只是启发式(见下),token 被提前吊销时它仍会
194
+ > 显示"有效",此时普通「登录」只会拿到 `alreadyLoggedIn`。不做条件显示是为了
195
+ > 避免"判定错误 → 无法恢复"的耦合;判定不可靠时 title 会提示"当前建议执行"。
196
+
197
+ ### 登录态判定(插件如何判断"是否已登录")
198
+
199
+ 代理写入的 `~/.codebuddy-session.json` **没有 `expiresAt` 字段**,所以插件按以下
200
+ 优先级推导过期时间,而不是把"字段缺失"当作"永不过期":
201
+
202
+ | 优先级 | 来源 | 说明 |
203
+ |---|---|---|
204
+ | 1 | `auth.expiresAt` | 本插件自带 `login_workbuddy.py` 写入;秒/毫秒都会归一化 |
205
+ | 2 | `refreshToken` 的 JWT `exp` | 代理会自动 refresh accessToken,因此 **refreshToken 的有效期才是会话真正的边界** |
206
+ | 3 | `accessToken` 的 JWT `exp` | 解码 JWT payload(不校验签名,只读声明) |
207
+ | 4 | `auth.expiresIn` + 会话文件 mtime | 兜底;只会低估有效期,不会高估 |
208
+
209
+ 若以上都无法得出结果,接口会返回 `expiryKnown: false` 并给出
210
+ `reloginRecommended: true`(而不是假装有效);`expiresAtSource` 会说明用的是
211
+ 哪一来源,🔍 诊断面板里也会显示。
212
+
170
213
  ### 方式 B(传统):在终端手动跑
171
214
 
172
215
  使用本包内置的登录脚本(纯标准库,依赖系统 `python3`):
@@ -197,10 +240,13 @@ Web 登录后终端脚本也读得到同一份会话。
197
240
 
198
241
  | 方法 + 路径 | 行为 |
199
242
  |---|---|
200
- | `GET /api/workbuddy/status` | 读取会话文件(默认 `~/.codebuddy-session.json`,或配置的 `sessionFile`)的 `auth.expiresAt` 判断会话是否有效,并 `fetch` 代理 `/health` 判断 `proxyUp`;返回 JSON:`{ sessionFile, authenticated, expiresAt, account, proxyUp, tokenValid, loginScriptAvailable }`;非 GET 返回 405 |
201
- | `POST /api/workbuddy/login` | 若已有有效会话则直接返回 `alreadyLoggedIn`;否则用系统 `python3` `spawn` 包内 `login_workbuddy.py --session-file <sessionFile>`,从子进程 stdout 解析出 `authUrl` 立即返回 `{ authUrl, pending:true }`(设备流在后台继续,前端轮询 status 感知完成);非 POST 返回 405 |
202
- | `POST /api/workbuddy/diagnose` | **一键诊断**:真实探测健康状态,返回 `{ ok, session, health, chat, loginScriptAvailable, restartCommand }`。与 `/status` 不同,它除了探 `/health`,还会**真实发一次最小模型请求**(`chat.chatWorking`),能戳穿"胶囊显示成功但模型全 500"的假象;`ok:false` 时附带 `restartCommand`(自动区分本地 monorepo 布局与标准安装);非 POST 返回 405 |
243
+ | `GET /api/workbuddy/status` | 读取会话文件(默认 `~/.codebuddy-session.json`,或配置的 `sessionFile`),**多源推导**会话过期时间(`expiresAt` → refreshToken/accessToken 的 JWT `exp` `expiresIn`+mtime)判断会话是否有效,并 `fetch` 代理 `/health` 判断 `proxyUp`;返回 `{ sessionFile, authenticated, expiresAt, expiresAtSource, expiryKnown, expired, tokenPresent, reloginRecommended, account, proxyUp, tokenValid, loginScriptAvailable, checkin: { supported, autoEnabled, handledToday, lastResult } }`;非 GET 返回 405 |
244
+ | `POST /api/workbuddy/login` | 已有有效会话且未指定 `?force=1` 时直接返回 `alreadyLoggedIn`;否则用系统 `python3` `spawn` 包内 `login_workbuddy.py --session-file <sessionFile>`,等它打印出设备流链接后返回 `{ authUrl, pending:true }`(最长等 15 秒,超时/脚本早退返回 `{ error }`);设备流在后台继续,前端轮询 status 感知完成。`?force=1` 强制重跑登录(见「🔑 重新登录」);非 POST 返回 405 |
245
+ | `POST /api/workbuddy/diagnose` | **一键诊断**:真实探测健康状态,返回 `{ ok, session, health, chat, loginScriptAvailable, restartCommand }`。与 `/status` 不同,它除了探 `/health`,还会**真实发一次最小模型请求**(`chat.chatWorking`),能戳穿"胶囊显示成功但模型全 500"的假象;`session` 里带 `expiresAtSource` / `expiryKnown` / `reloginRecommended`;`ok:false` 时附带 `restartCommand`(自动区分本地 monorepo 布局与标准安装);非 POST 返回 405 |
203
246
  | `GET /api/workbuddy/usage` | **用量统计**:读取本地 token 用量台账(`$DSH_HOME/llm-workbuddy/usage.jsonl`),返回 `{ today, byModel, total }`(今日/按模型/累计的 input/output tokens、请求次数,以及**积分消耗 `credit`**);非 GET 返回 405 |
247
+ | `GET /api/workbuddy/checkin` | **签到状态**:返回 `{ supported, handledToday, lastResult, official }`;`supported` 为三态——`true`(代理有签到接口)/ `false`(探测到 404/405,代理未实现)/ `null`(尚未探测出结论)。探测结果缓存 30 分钟;非 GET/POST 返回 405 |
248
+ | `POST /api/workbuddy/checkin` | **立即签到**:透传代理 `/v1/checkin`(幂等,官方对已签到返回业务拒绝)。代理未实现签到接口时返回 `{ ok:false, supported:false, message }` 且**不写入任何失败记录** |
249
+ | `POST /api/workbuddy/refresh-models` | 清空模型发现缓存并重读代理 `/v1/models`,同时发布 `llm/adapters-updated` 让模型选择器立即重载;返回 `{ ok, announced, count, models }` |
204
250
 
205
251
  > 会话文件与登录脚本路径的解析顺序:
206
252
  > 1. 配置里显式指定的 `sessionFile` / `loginScript`;
@@ -214,8 +260,13 @@ Web 登录后终端脚本也读得到同一份会话。
214
260
  - 每 **5 秒** `GET /api/workbuddy/status`;点「登录」后加快到每 **2 秒**轮询、
215
261
  最多 30 次,直到 `authenticated:true`。
216
262
  - 胶囊外面只显示一个 **⚙️ 设置** 按钮(带 title「WorkBuddy 设置」)。点击展开菜单,
217
- 内含四个带明确 title 的操作:**🔍 诊断 / 📊 用量 / 🎁 签到 / 🔄 刷新模型**;
263
+ 内含 **🔍 诊断 / 📊 用量 / 🎁 签到(按条件)/ 🔄 刷新模型 / 🔑 重新登录**;
218
264
  点击页面空白或选中某项后菜单自动收起。
265
+ - **🎁 签到**:**仅当代理真的实现了签到接口时才出现**(`checkin.supported === true`)。
266
+ `workbuddy2api` 至今没有 `/v1/checkin-status` / `/v1/checkin`,此时入口不显示,
267
+ 也不会每天自动尝试——避免"点了必然 404"和长期挂着的失败记录。
268
+ - **🔑 重新登录**:**始终显示**,点击后带 `?force=1` 强制重跑设备流,忽略本地
269
+ 会话判定(详见「登录态判定」)。
219
270
  - **🔍 诊断**:点它 `POST /api/workbuddy/diagnose`,弹出一个面板
220
271
  显示**真实健康状态**(登录、会话文件、代理进程、登录令牌、模型能否出字),
221
272
  发现问题时附带**可复制的重启命令**(一键复制到终端执行)。
@@ -318,8 +369,13 @@ DSH 的 `dsh.client` 机制只要求 `package.json` 里:
318
369
  | 胶囊一直 `…`(加载中) | `GET /api/workbuddy/status` 失败 → 确认 `dsh web` 在跑、端口正确 |
319
370
  | 胶囊红 + `代理未运行` | workbuddy2api 代理没起或挂了 → 按「安装代理」章节启动 |
320
371
  | 胶囊显示登录成功但模型用不了 | 典型的"代理进程活着但指向旧路径/文件缺失"假象(`/health` 仍显示 ok)。点胶囊 **🔍 诊断**,看 `模型出字` 是否失败;按面板给出的重启命令重启代理 |
321
- | 点「登录」没反应 / 按钮灰 | `loginScriptAvailable:false` → 包内 `login_workbuddy.py` 缺失或系统无 `python3`;检查安装 |
322
- | 新标签页打开后登录完成,胶囊仍是红 | 会话文件(默认 `~/.codebuddy-session.json`)未刷新或 `expiresAt` 已过期刷新页面或重跑登录 |
372
+ | 点「登录」没反应 / 按钮灰 | `loginScriptAvailable:false` → 包内 `login_workbuddy.py` 缺失或系统无 `python3`;检查安装。若返回了 `error`(脚本 15 秒内没打印授权链接、或退出码非 0),页面会弹出具体原因 |
373
+ | 点「登录」弹出 `alreadyLoggedIn`,但 token 实际已失效 | 本地判定是启发式(会话文件可能没有 `expiresAt`)→ **⚙️ 🔑 重新登录** 强制重跑登录 |
374
+ | 胶囊绿但所有模型 401 | accessToken/refreshToken 都已过期但判定未察觉 → **⚙️ → 🔑 重新登录**;或先 **🔍 诊断** 看「令牌过期」一行 |
375
+ | 设置菜单里没有「🎁 签到」 | **正常现象**:该代理未实现 `/v1/checkin-status` / `/v1/checkin`(`workbuddy2api` 至今未提供),插件探测到 404 后自动隐藏入口并停止每日自动尝试 |
376
+ | 设置菜单里的「🔑 重新登录」 | 该入口**始终可用**;判定结果不可靠时 title 会提示"当前建议执行" |
377
+ | 新标签页打开后登录完成,胶囊仍是红 | 会话文件(默认 `~/.codebuddy-session.json`)未刷新或已过期 → 刷新页面或点「🔑 重新登录」 |
378
+ | 代理版本报错 / 登录最后一步 401 | 代理过旧(< 2.0.4,缺 `X-Product-Code`、`_enterprise_headers` 等)→ `uv tool install -U workbuddy2api` 升级到 >= 2.0.4 |
323
379
  | 启动 `dsh web` 报 `EPERM ... cordis.yml` | `.dsh` 所在系统卷受保护(`/System/Volumes/Data` 带 `protect`)。解决:`sudo chown -R $(whoami) /Users/jiyunyang/.dsh`,或 `export DSH_HOME=$HOME/dsh-home` 后重新 `dsh plugin --profile web add` 并把插件链接进新 home |
324
380
  | 模型请求 `TRANSPORT` 错误 | 代理未运行或端口不对(连接被拒绝) |
325
381
 
@@ -336,8 +392,13 @@ DSH 的 `dsh.client` 机制只要求 `package.json` 里:
336
392
  会透传给代理;若某模型平台侧只接受平台默认、忽略该参数,则退化为平台默认强度,不影响出字。
337
393
  - 代理未运行时,模型请求会以 `TRANSPORT` 错误快速失败(连接被拒绝);但状态
338
394
  小组件本身不依赖代理——代理挂了它仍能显示「代理未运行」并允许触发登录。
339
- - 登录态有效期由 WorkBuddy 云端决定;过期后胶囊变红,重新点「登录」即可,
340
- 代理无需重启。
395
+ - 登录态有效期由 WorkBuddy 云端决定。插件会尽力从会话文件推导过期时间
396
+ (见「登录态判定」),但**推导不出时只能标记为"未知"而不是失败**;真正的
397
+ 失效检测以一次真实模型请求(🔍 诊断的「模型出字」)为准,恢复手段是
398
+ **🔑 重新登录**,代理无需重启。
399
+ - **签到功能依赖代理实现 `/v1/checkin-status` 与 `/v1/checkin`**;截至
400
+ `workbuddy2api` 2.0.4(含 GitHub `main`、`dsh`、`codex` 各分支)均未提供,
401
+ 因此默认不显示签到入口。代理侧补齐后无需升级插件,入口会自动出现。
341
402
 
342
403
  ---
343
404
 
package/lib/client.js CHANGED
@@ -128,25 +128,36 @@ function runWidget() {
128
128
  }
129
129
  // 只外显一个设置按钮:点击展开诊断/用量/签到/刷新模型四个操作。
130
130
  html += '<span class="wb-settle">' +
131
- '<button class="wb-setbtn" id="wb-settings" title="WorkBuddy 设置(诊断 / 用量 / 签到 / 刷新模型)">⚙️</button>';
131
+ '<button class="wb-setbtn" id="wb-settings" title="WorkBuddy 设置(诊断 / 用量 / 签到 / 刷新模型 / 重新登录)">⚙️</button>';
132
132
  if (menuOpen) {
133
- // 签到按钮:✅ 今日已处理 / 🎁 今日未处理(点击立即领取;10 点后也会自动领取)。
133
+ var items =
134
+ '<button class="wb-item" id="wb-diagnose" title="一键诊断:真实探测登录、代理、令牌与模型出字状态">🔍 诊断</button>' +
135
+ '<button class="wb-item" id="wb-usage" title="用量统计:查看今日/累计 token 与积分消耗、按模型明细">📊 用量</button>';
136
+ // 签到按钮只在此代理真的实现了签到接口时出现。workbuddy2api 从未提供
137
+ // /checkin-status 与 /checkin,显示一个必然 404 的入口只会误导用户;
138
+ // supported 为 null 表示还没探测出结论(代理未启动等),此时保守隐藏。
134
139
  var ck = status.checkin;
135
- var ckTitle = "今日还没签到(每天 10 点后自动领取)\n点击立即领取";
136
- var ckIcon = "🎁";
137
- var ckDisabled = "";
138
- if (ck) {
140
+ if (ck && ck.supported === true) {
141
+ // 今日已处理 / 🎁 今日未处理(点击立即领取;10 点后也会自动领取)。
142
+ var ckTitle = "今日还没签到(每天 10 点后自动领取)\n点击立即领取";
143
+ var ckIcon = "🎁";
144
+ var ckDisabled = "";
139
145
  var ckMsg = ck.lastResult && ck.lastResult.message ? "\n" + ck.lastResult.message : "";
140
146
  ckTitle = ck.handledToday ? "今日已处理签到" + ckMsg : ckTitle;
141
147
  ckIcon = ck.handledToday && ck.lastResult && ck.lastResult.ok ? "✅" : "🎁";
142
148
  ckDisabled = ck.handledToday ? " disabled" : "";
149
+ items += '<button class="wb-item" id="wb-checkin" title="' + esc(ckTitle) + '"' + ckDisabled + '>' + ckIcon + " 签到</button>";
143
150
  }
144
- html += '<span class="wb-menu">' +
145
- '<button class="wb-item" id="wb-diagnose" title="一键诊断:真实探测登录、代理、令牌与模型出字状态">🔍 诊断</button>' +
146
- '<button class="wb-item" id="wb-usage" title="用量统计:查看今日/累计 token 与积分消耗、按模型明细">📊 用量</button>' +
147
- '<button class="wb-item" id="wb-checkin" title="' + esc(ckTitle) + '"' + ckDisabled + '>' + ckIcon + " 签到</button>" +
148
- '<button class="wb-item" id="wb-models" title="刷新模型列表:从代理重读模型并让模型选择器立即重载">🔄 刷新模型</button>' +
149
- "</span>";
151
+ items +=
152
+ '<button class="wb-item" id="wb-models" title="刷新模型列表:从代理重读模型并让模型选择器立即重载">🔄 刷新模型</button>';
153
+ // 强制重登入口**始终存在**:本地判定是从会话文件推导的启发式(代理写入
154
+ // session.json 根本没有 expiresAt),token 在有效期内被吊销时判定仍是
155
+ // "有效",此时普通「登录」只会拿到 alreadyLoggedIn。若把入口做成有条件的,
156
+ // "判定错误 → 无法恢复"的耦合就依然存在,所以这里不做条件渲染,只在
157
+ // 判定不可靠时用 title 提示"建议"。
158
+ items += '<button class="wb-item" id="wb-relogin" title="强制重新登录:忽略本地会话判定,重跑设备流登录' +
159
+ (status.reloginRecommended ? '(当前建议执行:本地判定不可靠)' : '') + '">🔑 重新登录</button>';
160
+ html += '<span class="wb-menu">' + items + "</span>";
150
161
  }
151
162
  html += "</span>";
152
163
  if (!status.proxyUp) html += '<span class="wb-warn">代理未运行</span>';
@@ -170,6 +181,8 @@ function runWidget() {
170
181
  if (ckBtn) ckBtn.addEventListener("click", function () { menuOpen = false; render(); onCheckin(); });
171
182
  var mdBtn = host.querySelector("#wb-models");
172
183
  if (mdBtn) mdBtn.addEventListener("click", function () { menuOpen = false; render(); onRefreshModels(); });
184
+ var rlBtn = host.querySelector("#wb-relogin");
185
+ if (rlBtn) rlBtn.addEventListener("click", function () { menuOpen = false; render(); onLogin(true); });
173
186
  }
174
187
 
175
188
  // 点击页面其它位置关闭设置菜单。
@@ -189,15 +202,27 @@ function runWidget() {
189
202
  .catch(function () { /* leave last known state on screen */ });
190
203
  }
191
204
 
192
- function onLogin() {
205
+ /**
206
+ * 触发登录。`force` 为真时忽略本地会话判定,强制重跑设备流——用于
207
+ * 本地判定为"仍有效"但 token 实际已失效(被吊销)的场景,否则用户点了
208
+ * 登录只会拿到 alreadyLoggedIn,没有任何恢复手段。
209
+ */
210
+ function onLogin(force) {
193
211
  if (busy) return;
194
212
  busy = true;
195
213
  render();
196
- fetch("/api/workbuddy/login", { method: "POST" })
214
+ fetch("/api/workbuddy/login" + (force ? "?force=1" : ""), { method: "POST" })
197
215
  .then(function (r) { return r.json(); })
198
216
  .then(function (data) {
199
217
  if (data && data.authUrl) {
200
218
  window.open(data.authUrl, "_blank", "noopener,noreferrer");
219
+ } else if (data && data.error) {
220
+ // e.g. no python3, or no URL within the timeout: surface it instead
221
+ // of silently doing nothing (the old "点了没反应" symptom).
222
+ busy = false;
223
+ render();
224
+ window.alert("WorkBuddy 登录失败:" + data.error);
225
+ return;
201
226
  }
202
227
  var i = 0;
203
228
  var tick = function () {
@@ -233,10 +258,19 @@ function runWidget() {
233
258
  var lines = [];
234
259
  lines.push("登录状态: " + (d.session && d.session.authenticated ? "有效" : "无效"));
235
260
  lines.push("会话文件: " + (d.session && d.session.file ? "存在" : "缺失"));
261
+ // 过期时间是多源推导出来的(代理写入的 session.json 没有 expiresAt),
262
+ // 把来源一并显示,便于判断"未知"到底是没探测到还是真的没写。
263
+ var exp = d.session && d.session.expiresAt;
264
+ lines.push("令牌过期: " + (exp
265
+ ? fmtExpiry(exp) + "(来源: " + ((d.session && d.session.expiresAtSource) || "?") + ")"
266
+ : "未知(无法从会话文件推导)"));
236
267
  lines.push("代理进程: " + (d.health && d.health.proxyUp ? "在运行" : "未运行"));
237
268
  lines.push("登录令牌: " + (d.health && d.health.tokenValid ? "有效" : "无效"));
238
269
  lines.push("模型出字: " + (d.chat && d.chat.chatWorking ? "正常" : "失败"));
239
270
  if (d.chat && d.chat.chatError) lines.push("出字错误: " + d.chat.chatError);
271
+ if (d.session && d.session.reloginRecommended) {
272
+ lines.push("提示: 建议点 ⚙️ → 🔑 重新登录(本地判定不可靠,可强制重跑登录)");
273
+ }
240
274
  var body = el.querySelector(".wb-panel-body");
241
275
  var html = '<div style="font-weight:700;margin-bottom:4px">' + esc(title) + '</div>' +
242
276
  '<div>' + lines.map(esc).join("\n") + "</div>";
@@ -351,6 +385,9 @@ function runWidget() {
351
385
  fetch("/api/workbuddy/checkin", { method: "POST" })
352
386
  .then(function (r) { return r.json(); })
353
387
  .then(function (d) {
388
+ // 代理没实现签到接口:刷新状态让入口按 supported 自行消失,
389
+ // 而不是留一个点了就报错的按钮。
390
+ if (d && d.supported === false) { refresh(); return; }
354
391
  btn.textContent = d && d.ok ? "✅" : "🎁";
355
392
  btn.title = (d && d.message) || "签到结果未知";
356
393
  refresh();
package/lib/index.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * to a shipped static catalog when the proxy is not running.
18
18
  */
19
19
  import { spawn } from "node:child_process";
20
- import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, truncateSync, writeSync, closeSync, writeFileSync } from "node:fs";
20
+ import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, statSync, truncateSync, writeSync, closeSync, writeFileSync } from "node:fs";
21
21
  import { dirname, join, resolve } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import z from "@deepseek-ai/schemastery";
@@ -1051,6 +1051,8 @@ const CHECKIN_FILE_NAME = "checkin.json";
1051
1051
  const CHECKIN_INTERVAL_MS = 30 * 60_000;
1052
1052
  /** Request timeout for the proxy checkin endpoints. */
1053
1053
  const CHECKIN_TIMEOUT_MS = 10_000;
1054
+ /** How long the login route waits for the device-flow URL before giving up. */
1055
+ const LOGIN_URL_TIMEOUT_MS = 15_000;
1054
1056
 
1055
1057
  /** Resolve the checkin state file path (same convention as usageLedgerPath). */
1056
1058
  function checkinStatePath() {
@@ -1092,7 +1094,14 @@ function localDateStr(date = new Date()) {
1092
1094
 
1093
1095
  /**
1094
1096
  * Call a proxy checkin endpoint. `method` defaults to GET (status probe);
1095
- * claiming passes "POST". Returns the parsed body or an error shape.
1097
+ * claiming passes "POST". Returns the parsed body (with the HTTP status under
1098
+ * `httpStatus`) or an error shape.
1099
+ *
1100
+ * `missing: true` marks the specific "this proxy does not implement the
1101
+ * checkin API" case (HTTP 404/405 on the probe). It is kept distinct from a
1102
+ * transport error because the two need opposite handling: a missing endpoint is
1103
+ * permanent for the running proxy (hide the feature), a transport error is
1104
+ * transient (retry later).
1096
1105
  */
1097
1106
  async function proxyCheckinCall(baseURL, path, method = "GET") {
1098
1107
  const controller = new AbortController();
@@ -1105,9 +1114,25 @@ async function proxyCheckinCall(baseURL, path, method = "GET") {
1105
1114
  });
1106
1115
  const body = await response.json().catch(() => null);
1107
1116
  if (!response.ok) {
1108
- return { error: `HTTP ${response.status} (${method} ${path})`, ...(body ? { body } : {}) };
1117
+ const missing = response.status === 404 || response.status === 405;
1118
+ return {
1119
+ httpStatus: response.status,
1120
+ error: `HTTP ${response.status} (${method} ${path})`,
1121
+ ...(missing ? { missing: true } : {}),
1122
+ // Body fields ride along both flattened and under `body`: the old shape
1123
+ // kept them under `body`, and `runCheckinOnce` still reads
1124
+ // `claim.body.detail` for its failure message.
1125
+ ...(body !== null && typeof body === "object" ? body : {}),
1126
+ ...(body !== null && typeof body === "object" ? { body } : {}),
1127
+ };
1109
1128
  }
1110
- return body ?? { error: "empty response" };
1129
+ // `httpStatus` rides along even on success so callers can classify the
1130
+ // endpoint by HTTP status rather than by "did an error string appear".
1131
+ // It must NOT be named `status`: the proxy's own checkin body already has a
1132
+ // `status: "ok"` field that would clobber it when spread.
1133
+ return body !== null && typeof body === "object"
1134
+ ? { httpStatus: response.status, ...body }
1135
+ : { httpStatus: response.status, error: "empty response" };
1111
1136
  } catch (error) {
1112
1137
  return { error: String(error?.cause?.message ?? error?.message ?? error) };
1113
1138
  } finally {
@@ -1115,6 +1140,53 @@ async function proxyCheckinCall(baseURL, path, method = "GET") {
1115
1140
  }
1116
1141
  }
1117
1142
 
1143
+ /**
1144
+ * Cached answer to "does this proxy implement the checkin API at all?".
1145
+ *
1146
+ * workbuddy2api has never shipped `/checkin-status` / `/checkin` on any branch
1147
+ * or PyPI release, so the plugin probes once (and then at most every
1148
+ * {@link CHECKIN_CAPABILITY_TTL_MS}) and hides the feature instead of
1149
+ * hammering a 404 and recording a fresh failure every day.
1150
+ *
1151
+ * `supported` is tri-state: `true` (endpoint answered, whatever the status),
1152
+ * `false` (404/405 — the route does not exist), `null` (never probed, or the
1153
+ * proxy was unreachable so the answer is genuinely unknown).
1154
+ */
1155
+ const CHECKIN_CAPABILITY_TTL_MS = 30 * 60_000;
1156
+ let _checkinCapability = { supported: null, error: null, checkedAt: 0 };
1157
+
1158
+ /** Last known checkin-support verdict, without touching the network. */
1159
+ function checkinCapability() {
1160
+ return _checkinCapability;
1161
+ }
1162
+
1163
+ /** Probe checkin support, reusing the cached verdict until it goes stale. */
1164
+ async function probeCheckinCapability(baseURL, options = {}) {
1165
+ const { force = false } = options;
1166
+ const cached = _checkinCapability;
1167
+ if (!force && cached.checkedAt > 0 && Date.now() - cached.checkedAt < CHECKIN_CAPABILITY_TTL_MS) {
1168
+ return cached;
1169
+ }
1170
+ const probe = await proxyCheckinCall(baseURL, "/checkin-status");
1171
+ // Classify by HTTP status, not by the presence of an error string:
1172
+ // - 404/405 → the route does not exist on this proxy (conclusive);
1173
+ // - any other HTTP status (including 401/503 from an unauthenticated proxy)
1174
+ // proves the endpoint exists, so the feature stays offered;
1175
+ // - no status at all → transport failure; the answer is unknown, and a
1176
+ // transient outage must never permanently hide the feature.
1177
+ const supported = probe?.missing === true
1178
+ ? false
1179
+ : typeof probe?.httpStatus === "number"
1180
+ ? true
1181
+ : null;
1182
+ _checkinCapability = {
1183
+ supported,
1184
+ error: supported === false ? probe.error : null,
1185
+ checkedAt: Date.now(),
1186
+ };
1187
+ return _checkinCapability;
1188
+ }
1189
+
1118
1190
  /** Whether an upstream claim rejection means "already claimed today". */
1119
1191
  function alreadyClaimedMessage(msg) {
1120
1192
  return typeof msg === "string" && /(已签到|已经签到|already)/i.test(msg);
@@ -1123,6 +1195,7 @@ function alreadyClaimedMessage(msg) {
1123
1195
  /**
1124
1196
  * Run one auto-claim pass. Idempotent per local calendar day:
1125
1197
  * - before `checkinAfterHour` (default 10:00) local time: no-op;
1198
+ * - proxy does not implement the checkin API (404/405): no-op, no state change;
1126
1199
  * - already handled today (persisted state): no-op;
1127
1200
  * - official status says already checked in: mark handled, done;
1128
1201
  * - activity not open (no status data): mark handled so we stop retrying today;
@@ -1131,6 +1204,16 @@ function alreadyClaimedMessage(msg) {
1131
1204
  * @returns the resulting state snapshot, or undefined when skipped/in-flight.
1132
1205
  */
1133
1206
  async function runCheckinOnce(baseURL, config, logger) {
1207
+ // Learn whether this proxy can check in at all *before* any gate: the verdict
1208
+ // is what the widget uses to decide whether to offer the 「签到」 entry, so it
1209
+ // must be populated on startup — and also when auto-claim is disabled, where
1210
+ // this pass does nothing else. The probe is cached (30 min), so this is at
1211
+ // most one cheap GET per scheduler tick.
1212
+ const capability = await probeCheckinCapability(baseURL);
1213
+ if (capability.supported === false) {
1214
+ logger?.debug?.(`dsh-llm-workbuddy: checkin API unavailable on this proxy (${capability.error})`);
1215
+ return undefined;
1216
+ }
1134
1217
  if (!config.autoCheckin) return undefined;
1135
1218
  const now = new Date();
1136
1219
  if (now.getHours() < config.checkinAfterHour) return undefined;
@@ -1141,6 +1224,13 @@ async function runCheckinOnce(baseURL, config, logger) {
1141
1224
  runCheckinOnce._inFlight = true;
1142
1225
  try {
1143
1226
  const status = await proxyCheckinCall(baseURL, "/checkin-status");
1227
+ // The probe can disagree with the cached verdict (older proxy, route added
1228
+ // mid-flight): trust the live 404 and stop instead of recording a failure.
1229
+ if (status?.missing === true) {
1230
+ _checkinCapability = { supported: false, error: status.error, checkedAt: Date.now() };
1231
+ logger?.debug?.(`dsh-llm-workbuddy: checkin API unavailable on this proxy (${status.error})`);
1232
+ return undefined;
1233
+ }
1144
1234
  const data = status?.upstream?.data;
1145
1235
  if (!data) {
1146
1236
  // Activity closed / proxy unreachable / not authenticated: stop for today.
@@ -1259,28 +1349,135 @@ function resolveLoginPaths(config) {
1259
1349
  return { sessionFile, loginScript };
1260
1350
  }
1261
1351
 
1352
+ /**
1353
+ * Decode a JWT payload without verifying its signature. The session's tokens
1354
+ * are issued by the WorkBuddy IdP and we only read claims (`exp`) to recover an
1355
+ * expiry the writer omitted — never to trust the token. Returns null for an
1356
+ * opaque (non-JWT) or malformed token.
1357
+ */
1358
+ function decodeJwtPayload(token) {
1359
+ if (typeof token !== "string") return null;
1360
+ const parts = token.split(".");
1361
+ if (parts.length !== 3) return null;
1362
+ try {
1363
+ const json = Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8");
1364
+ const claims = JSON.parse(json);
1365
+ return claims !== null && typeof claims === "object" ? claims : null;
1366
+ } catch {
1367
+ return null;
1368
+ }
1369
+ }
1370
+
1371
+ /**
1372
+ * Normalize a numeric instant to epoch milliseconds. Values below this floor
1373
+ * cannot be a millisecond epoch this century, so the writer must have used
1374
+ * seconds (the IdP's own unit for JWT `exp`).
1375
+ */
1376
+ function toEpochMs(value) {
1377
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
1378
+ return value < 1e11 ? Math.round(value * 1000) : value;
1379
+ }
1380
+
1381
+ /** A JWT's `exp` claim, in epoch ms. Null when absent/opaque. */
1382
+ function jwtExpiresAt(token) {
1383
+ return toEpochMs(decodeJwtPayload(token)?.exp);
1384
+ }
1385
+
1386
+ /**
1387
+ * Derive when the stored session actually stops working.
1388
+ *
1389
+ * The proxy (workbuddy2api) writes the IdP token payload verbatim, which has no
1390
+ * `expiresAt` — judging "no expiresAt ⇒ still valid" made the login state
1391
+ * permanently green. Sources are tried most authoritative first:
1392
+ *
1393
+ * 1. the **refresh** token's JWT `exp` — the proxy refreshes an expired access
1394
+ * token automatically, so the refresh token's lifetime is what really
1395
+ * bounds the session. Judging by the access token alone would turn the
1396
+ * capsule red while requests still succeed;
1397
+ * 2. `auth.expiresAt` — written by this plugin's own login script (also the
1398
+ * access token's lifetime, hence below the refresh bound);
1399
+ * 3. the **access** token's JWT `exp`;
1400
+ * 4. `auth.expiresIn` counted from the session file's mtime — the last resort
1401
+ * for opaque tokens; the mtime is refreshed whenever the proxy re-writes
1402
+ * the session, so this can only under-estimate, never over-estimate.
1403
+ *
1404
+ * Yielding no instant at all means the validity is genuinely unknown
1405
+ * (`source: null`) and must be reported as such rather than assumed valid.
1406
+ */
1407
+ function resolveSessionExpiry(raw, sessionFile) {
1408
+ const accessJwt = jwtExpiresAt(raw?.auth?.accessToken);
1409
+ const refreshJwt = jwtExpiresAt(raw?.auth?.refreshToken);
1410
+ const explicit = toEpochMs(raw?.auth?.expiresAt);
1411
+
1412
+ if (refreshJwt !== null) {
1413
+ return { accessExpiresAt: accessJwt ?? explicit, refreshExpiresAt: refreshJwt, expiresAt: refreshJwt, source: "jwt-refresh" };
1414
+ }
1415
+ if (explicit !== null) {
1416
+ return { accessExpiresAt: explicit, refreshExpiresAt: null, expiresAt: explicit, source: "expiresAt" };
1417
+ }
1418
+ if (accessJwt !== null) {
1419
+ return { accessExpiresAt: accessJwt, refreshExpiresAt: null, expiresAt: accessJwt, source: "jwt" };
1420
+ }
1421
+
1422
+ const expiresIn = raw?.auth?.expiresIn;
1423
+ if (typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0) {
1424
+ try {
1425
+ const fromMtime = Math.round(statSync(sessionFile).mtimeMs) + expiresIn * 1000;
1426
+ return { accessExpiresAt: fromMtime, refreshExpiresAt: null, expiresAt: fromMtime, source: "expiresIn" };
1427
+ } catch {
1428
+ // fall through to "unknown"
1429
+ }
1430
+ }
1431
+
1432
+ return { accessExpiresAt: null, refreshExpiresAt: null, expiresAt: null, source: null };
1433
+ }
1434
+
1262
1435
  /**
1263
1436
  * Read the local WorkBuddy session file and derive its validity. Returns a
1264
1437
  * normalized status object the Web widget polls. Never throws — missing or
1265
1438
  * malformed state is reported as `authenticated: false` so the UI can prompt.
1439
+ *
1440
+ * `expiresAt` is the instant that bounds the session (see
1441
+ * {@link resolveSessionExpiry}); `expiryKnown: false` means no expiry could be
1442
+ * derived, and `reloginRecommended` is the recovery hint the widget shows.
1266
1443
  */
1267
1444
  function readSessionStatus(sessionFile) {
1268
- if (!existsSync(sessionFile)) {
1269
- return { sessionFile: false, authenticated: false, expiresAt: null, account: null };
1270
- }
1445
+ const empty = {
1446
+ sessionFile: false,
1447
+ authenticated: false,
1448
+ expiresAt: null,
1449
+ accessExpiresAt: null,
1450
+ refreshExpiresAt: null,
1451
+ expiresAtSource: null,
1452
+ expiryKnown: false,
1453
+ expired: false,
1454
+ tokenPresent: false,
1455
+ reloginRecommended: true,
1456
+ account: null,
1457
+ };
1458
+ if (!existsSync(sessionFile)) return empty;
1271
1459
  try {
1272
1460
  const raw = JSON.parse(readFileSync(sessionFile, "utf-8"));
1273
- const expiresAt = raw?.auth?.expiresAt ?? null;
1274
- const now = Date.now();
1275
- const expired = expiresAt !== null && expiresAt <= now;
1461
+ const tokenPresent = Boolean(raw?.auth?.accessToken);
1462
+ const expiry = resolveSessionExpiry(raw, sessionFile);
1463
+ const expired = expiry.expiresAt !== null && expiry.expiresAt <= Date.now();
1276
1464
  return {
1277
1465
  sessionFile: true,
1278
- authenticated: !expired && Boolean(raw?.auth?.accessToken),
1279
- expiresAt,
1466
+ authenticated: tokenPresent && !expired,
1467
+ expiresAt: expiry.expiresAt,
1468
+ accessExpiresAt: expiry.accessExpiresAt,
1469
+ refreshExpiresAt: expiry.refreshExpiresAt,
1470
+ expiresAtSource: expiry.source,
1471
+ expiryKnown: expiry.expiresAt !== null,
1472
+ expired,
1473
+ tokenPresent,
1474
+ // The login button must stay a way out: no token, a passed expiry, or an
1475
+ // expiry we cannot derive all mean "offer a re-login".
1476
+ reloginRecommended: !tokenPresent || expired || expiry.expiresAt === null,
1280
1477
  account: raw?.account ?? null,
1281
1478
  };
1282
1479
  } catch {
1283
- return { sessionFile: true, authenticated: false, expiresAt: null, account: null };
1480
+ return { ...empty, sessionFile: true };
1284
1481
  }
1285
1482
  }
1286
1483
 
@@ -1383,9 +1580,9 @@ function diagnoseRestartCommand() {
1383
1580
  "# 停掉旧代理(若 8787 被占用)",
1384
1581
  "lsof -tiTCP:8787 -sTCP:LISTEN | xargs kill",
1385
1582
  "",
1386
- "# 拉取并运行代理(详见 README「安装代理」)",
1387
- "git clone https://github.com/hawklithm/workbuddy2api.git && cd workbuddy2api",
1388
- "uv run python -u -m codebuddy_proxy --desensitize \\",
1583
+ "# 安装/升级代理(需 >= 2.0.4;详见 README「安装代理」)",
1584
+ "uv tool install -U workbuddy2api",
1585
+ "workbuddy2api --desensitize \\",
1389
1586
  " --session-file ~/.codebuddy-session.json --log-file ~/.codebuddy-proxy.jsonl",
1390
1587
  ].join("\n");
1391
1588
  }
@@ -1431,8 +1628,11 @@ function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
1431
1628
  const session = readSessionStatus(sessionFile);
1432
1629
  const proxy = await probeProxy(baseURL);
1433
1630
  // Checkin digest comes from the persisted state only — never block the
1434
- // 5s status poll with an upstream call.
1631
+ // 5s status poll with an upstream call. Support comes from the cached
1632
+ // capability verdict (also populated by the scheduler and the checkin
1633
+ // route), so the widget can hide an entry this proxy cannot serve.
1435
1634
  const checkinState = readCheckinState();
1635
+ const capability = checkinCapability();
1436
1636
  const today = localDateStr();
1437
1637
  res.writeHead(200, { "content-type": "application/json" });
1438
1638
  res.end(JSON.stringify({
@@ -1440,6 +1640,7 @@ function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
1440
1640
  ...proxy,
1441
1641
  loginScriptAvailable: existsSync(loginScript),
1442
1642
  checkin: {
1643
+ supported: capability.supported,
1443
1644
  autoEnabled: config?.autoCheckin ?? true,
1444
1645
  handledToday: checkinState.handledDate === today,
1445
1646
  lastResult: checkinState.lastResult ?? null,
@@ -1461,9 +1662,20 @@ function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
1461
1662
  res.end(JSON.stringify({ error: "login_workbuddy.py not found" }));
1462
1663
  return;
1463
1664
  }
1665
+ // `force` re-runs the device flow even when the stored session still
1666
+ // looks valid. Status judgement is a heuristic (the proxy's session
1667
+ // file carries no `expiresAt`), so the login button must never be a
1668
+ // dead end when a token is wrong/revoked but still not-yet-expired.
1669
+ let force = false;
1670
+ try {
1671
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
1672
+ force = url.searchParams.get("force") === "1" || url.searchParams.get("force") === "true";
1673
+ } catch {
1674
+ // malformed URL: treat as non-forced
1675
+ }
1464
1676
  // Existing session is fine; no need to re-run the flow.
1465
1677
  const existing = readSessionStatus(sessionFile);
1466
- if (existing.authenticated) {
1678
+ if (existing.authenticated && !force) {
1467
1679
  res.writeHead(200, { "content-type": "application/json" });
1468
1680
  res.end(JSON.stringify({ alreadyLoggedIn: true, authUrl: null, ...existing }));
1469
1681
  return;
@@ -1473,17 +1685,55 @@ function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
1473
1685
  const args = ["-u", loginScript, "--session-file", sessionFile];
1474
1686
  const child = spawn("python3", args, { env: { ...process.env, PYTHONUNBUFFERED: "1" } });
1475
1687
  let stdout = "";
1476
- let authUrl = null;
1477
- child.stdout.on("data", (chunk) => {
1478
- stdout += chunk.toString();
1479
- const m = stdout.match(/https?:\/\/\S+/);
1480
- if (m && authUrl === null) authUrl = m[0];
1688
+
1689
+ // The device-flow URL is printed as soon as the flow starts (before the
1690
+ // long poll for completion), but it is never available synchronously —
1691
+ // answering immediately always returned `authUrl: null`, which the
1692
+ // widget turned into "clicked login, nothing happened". Resolve on the
1693
+ // first URL, on child exit, or on the timeout, whichever comes first.
1694
+ const outcome = await new Promise((resolve) => {
1695
+ let done = false;
1696
+ const finish = (value) => {
1697
+ if (done) return;
1698
+ done = true;
1699
+ clearTimeout(timer);
1700
+ resolve(value);
1701
+ };
1702
+ // Bounded wait. This timer is deliberately NOT unref'd: it is the only
1703
+ // thing guaranteeing the route answers even when nothing else holds
1704
+ // the event loop open. It is cleared the moment the child answers, so
1705
+ // it never outlives the request.
1706
+ const timer = setTimeout(
1707
+ () => finish({ authUrl: null, error: `登录脚本未在 ${Math.round(LOGIN_URL_TIMEOUT_MS / 1000)} 秒内输出授权链接(检查系统 python3 是否可用)` }),
1708
+ LOGIN_URL_TIMEOUT_MS,
1709
+ );
1710
+ child.stdout.on("data", (chunk) => {
1711
+ stdout += chunk.toString();
1712
+ const m = stdout.match(/https?:\/\/\S+/);
1713
+ if (m) finish({ authUrl: m[0] });
1714
+ });
1715
+ child.stderr.on("data", () => {});
1716
+ child.on("error", (error) => {
1717
+ finish({ authUrl: null, error: `无法启动 python3:${String(error?.message ?? error)}` });
1718
+ });
1719
+ child.on("close", (code) => {
1720
+ finish({
1721
+ authUrl: null,
1722
+ error: code === 0
1723
+ ? "登录脚本已结束,但没有输出授权链接"
1724
+ : `登录脚本异常退出(exit code ${code})`,
1725
+ });
1726
+ });
1727
+ // Keep the pipe drained so a chatty child cannot block on a full
1728
+ // stdout buffer after we have answered.
1729
+ child.stdout.resume();
1730
+ child.unref?.();
1481
1731
  });
1482
- child.stderr.on("data", () => {});
1483
- // The device flow blocks until login or timeout; we hand back the URL
1484
- // immediately and let the widget poll /status for completion.
1732
+
1485
1733
  res.writeHead(200, { "content-type": "application/json" });
1486
- res.end(JSON.stringify({ authUrl, pending: true }));
1734
+ res.end(JSON.stringify(outcome.authUrl === null
1735
+ ? { authUrl: null, pending: false, error: outcome.error }
1736
+ : { authUrl: outcome.authUrl, pending: true }));
1487
1737
  },
1488
1738
  }));
1489
1739
  webCtx.effect(() => webServer.register({
@@ -1506,7 +1756,15 @@ function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
1506
1756
  res.writeHead(200, { "content-type": "application/json" });
1507
1757
  res.end(JSON.stringify({
1508
1758
  ok,
1509
- session: { file: session.sessionFile, authenticated: session.authenticated, expiresAt: session.expiresAt },
1759
+ session: {
1760
+ file: session.sessionFile,
1761
+ authenticated: session.authenticated,
1762
+ expiresAt: session.expiresAt,
1763
+ expiresAtSource: session.expiresAtSource,
1764
+ expiryKnown: session.expiryKnown,
1765
+ refreshExpiresAt: session.refreshExpiresAt,
1766
+ reloginRecommended: session.reloginRecommended,
1767
+ },
1510
1768
  health,
1511
1769
  chat,
1512
1770
  loginScriptAvailable: existsSync(loginScript),
@@ -1563,8 +1821,14 @@ function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
1563
1821
  const state = readCheckinState();
1564
1822
  const upstream = await proxyCheckinCall(baseURL, "/checkin-status");
1565
1823
  const data = upstream?.upstream?.data ?? null;
1824
+ // A 404/405 proves the running proxy has no checkin API at all; the
1825
+ // widget hides the entry rather than offering a button that can only
1826
+ // fail.
1827
+ const supported = upstream?.missing === true ? false : true;
1828
+ _checkinCapability = { supported, error: supported ? null : upstream.error, checkedAt: Date.now() };
1566
1829
  res.writeHead(200, { "content-type": "application/json" });
1567
1830
  res.end(JSON.stringify({
1831
+ supported,
1568
1832
  handledToday: state.handledDate === localDateStr(),
1569
1833
  lastResult: state.lastResult ?? null,
1570
1834
  official: data === null ? null : {
@@ -1587,10 +1851,24 @@ function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
1587
1851
  return;
1588
1852
  }
1589
1853
  const claim = await proxyCheckinCall(baseURL, "/checkin", "POST");
1854
+ if (claim?.missing === true) {
1855
+ // Not a failure to report: this proxy simply has no checkin API
1856
+ // (workbuddy2api never shipped one). Mark it unsupported so the UI
1857
+ // hides the entry and stop writing failure records.
1858
+ _checkinCapability = { supported: false, error: claim.error, checkedAt: Date.now() };
1859
+ res.writeHead(200, { "content-type": "application/json" });
1860
+ res.end(JSON.stringify({
1861
+ ok: false,
1862
+ supported: false,
1863
+ message: `当前代理未实现签到接口(${claim.error})`,
1864
+ }));
1865
+ return;
1866
+ }
1590
1867
  const payload = claim?.upstream?.data;
1591
1868
  const ok = Boolean(payload) || alreadyClaimedMessage(claim?.upstream?.msg);
1592
1869
  const result = {
1593
1870
  ok,
1871
+ supported: true,
1594
1872
  message: ok
1595
1873
  ? payload?.credit !== undefined
1596
1874
  ? `签到成功:+${payload.credit} 积分`
@@ -1620,6 +1898,28 @@ function config_baseURL() {
1620
1898
 
1621
1899
  // #endregion
1622
1900
 
1901
+ // #region internals (exported for tests)
1902
+
1903
+ /**
1904
+ * Internals exposed for the test suite only. They are not part of the plugin's
1905
+ * public surface and may change without a major bump.
1906
+ */
1907
+ export const __internals = {
1908
+ readSessionStatus,
1909
+ resolveSessionExpiry,
1910
+ decodeJwtPayload,
1911
+ runCheckinOnce,
1912
+ probeCheckinCapability,
1913
+ checkinCapability,
1914
+ resetCheckinCapability: () => { _checkinCapability = { supported: null, error: null, checkedAt: 0 }; },
1915
+ registerWorkbuddyRoutes,
1916
+ // The routes read the module-level proxy URL that `apply()` commits, so a
1917
+ // test that mounts them directly must point it somewhere first.
1918
+ setBaseURL: (value) => { _baseURL = value; },
1919
+ };
1920
+
1921
+ // #endregion
1922
+
1623
1923
  // #region plugin
1624
1924
 
1625
1925
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-llm-workbuddy",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "WorkBuddy (via the local workbuddy2api proxy) LLM provider adapter for DeepSeek Harness, with a Web login-status widget",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",