dsh-llm-workbuddy 0.1.3 → 0.1.5

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 CHANGED
@@ -92,33 +92,53 @@ CLI 会把依赖写进 profile 并把 `dsh-llm-workbuddy` 追加到 `dsh.profile
92
92
 
93
93
  ## 安装代理(先决条件,第三方)
94
94
 
95
- 插件只提供模型路由与登录;真正的协议转换由 **workbuddy2api** 完成。请单独安装并
96
- 启动它(以 127.0.0.1:8787 为例):
95
+ 插件只提供模型路由与登录;真正的协议转换由 **workbuddy2api**(第三方开源项目)完成。
96
+ 它**不在本插件包里**,需要单独安装并启动在 `127.0.0.1:8787`。分两种情况:
97
+
98
+ ### 全新机器 / 标准安装(任何能跑 Python 的机器)
99
+
100
+ 别人或你自己在**新机器**上用 npm 版插件时,按以下 3 步装代理:
97
101
 
98
102
  ```sh
99
- # 1. 安装 uv(本机 Python 工具)
103
+ # 1. 安装 uv(本机 Python 工具,若已装可跳过)
100
104
  curl -LsSf https://astral.sh/uv/install.sh | sh
101
105
 
102
106
  # 2. 拉取 workbuddy2api 主分支源码
103
107
  git clone https://github.com/hawklithm/workbuddy2api.git
104
108
  cd workbuddy2api
105
109
 
106
- # 3. 启动代理(先完成下方「登录」后再请求)
110
+ # 3. 启动代理(监听 127.0.0.1:8787;先完成下方「登录」后再真正调用模型)
107
111
  uv run python -u -m codebuddy_proxy --desensitize \
108
112
  --session-file ~/.codebuddy-session.json \
109
113
  --log-file ~/.codebuddy-proxy.jsonl
110
- # 监听 http://127.0.0.1:8787
111
114
  ```
112
115
 
113
- > 仓库根目录也有 `start-workbuddy.sh` 脚本(面向仓库本地开发),发布版插件不依赖它。
116
+ > 之后任何时候想重启代理,就在 `workbuddy2api` 目录里重跑上面第 3 条命令。
117
+ > 若提示 `address already in use`,先停掉旧代理:
118
+ > `lsof -tiTCP:8787 -sTCP:LISTEN | xargs kill`,再重跑。
119
+
120
+ ### 本仓库本地开发(可选快捷方式)
114
121
 
115
- 验证:
122
+ 仓库根目录提供 `start-workbuddy.sh`,它会用仓库固定的 `main` 分支源码 + 本地
123
+ `.tools/uv` 工具链 + 仓库内的 `.workbuddy/session.json` 启动代理。它**不随 npm
124
+ 包发布**,仅面向本仓库开发时方便:
125
+
126
+ ```sh
127
+ cd /path/to/dsh-workbuddy
128
+ ./start-workbuddy.sh
129
+ ```
130
+
131
+ ### 验证代理是否真的能用
116
132
 
117
133
  ```sh
118
134
  curl http://127.0.0.1:8787/health # {"status":"ok","authenticated":true,...}
119
135
  curl http://127.0.0.1:8787/v1/models # 模型列表(含 glm-5.2 / deepseek-v4-pro ...)
120
136
  ```
121
137
 
138
+ > ⚠️ `/health` 只证明**代理进程活着**,不能证明模型能真正出字(代理进程指向旧
139
+ > 路径/文件缺失时 `/health` 仍返回 `authenticated: true`,但所有模型请求 500)。
140
+ > 想确认"模型真能用",用右下角胶囊的 **🔍 诊断** 按钮,它会真实发一次模型请求。
141
+
122
142
  > 登录态过期时 `authenticated` 变回 `false`,重跑登录即可,代理无需重启
123
143
  > (会话按请求读取)。
124
144
 
@@ -174,6 +194,7 @@ Web 登录后终端脚本也读得到同一份会话。
174
194
  |---|---|
175
195
  | `GET /api/workbuddy/status` | 读取会话文件(默认 `~/.codebuddy-session.json`,或配置的 `sessionFile`)的 `auth.expiresAt` 判断会话是否有效,并 `fetch` 代理 `/health` 判断 `proxyUp`;返回 JSON:`{ sessionFile, authenticated, expiresAt, account, proxyUp, tokenValid, loginScriptAvailable }`;非 GET 返回 405 |
176
196
  | `POST /api/workbuddy/login` | 若已有有效会话则直接返回 `alreadyLoggedIn`;否则用系统 `python3` `spawn` 包内 `login_workbuddy.py --session-file <sessionFile>`,从子进程 stdout 解析出 `authUrl` 立即返回 `{ authUrl, pending:true }`(设备流在后台继续,前端轮询 status 感知完成);非 POST 返回 405 |
197
+ | `POST /api/workbuddy/diagnose` | **一键诊断**:真实探测健康状态,返回 `{ ok, session, health, chat, loginScriptAvailable, restartCommand }`。与 `/status` 不同,它除了探 `/health`,还会**真实发一次最小模型请求**(`chat.chatWorking`),能戳穿"胶囊显示成功但模型全 500"的假象;`ok:false` 时附带 `restartCommand`(自动区分本地 monorepo 布局与标准安装);非 POST 返回 405 |
177
198
 
178
199
  > 会话文件与登录脚本路径的解析顺序:
179
200
  > 1. 配置里显式指定的 `sessionFile` / `loginScript`;
@@ -186,10 +207,36 @@ Web 登录后终端脚本也读得到同一份会话。
186
207
  - 启动时在 `document.body` 末尾挂一个 `position:fixed` 的胶囊(右下角)。
187
208
  - 每 **5 秒** `GET /api/workbuddy/status`;点「登录」后加快到每 **2 秒**轮询、
188
209
  最多 30 次,直到 `authenticated:true`。
210
+ - 胶囊里有个 **🔍 诊断** 按钮:点它 `POST /api/workbuddy/diagnose`,弹出一个面板
211
+ 显示**真实健康状态**(登录、会话文件、代理进程、登录令牌、模型能否出字),
212
+ 发现问题时附带**可复制的重启命令**(一键复制到终端执行)。
189
213
  - 状态映射:
190
214
  - `authenticated && proxyUp` → 🟢 绿,显示 `WorkBuddy · <昵称>`
191
215
  - 否则 → 🔴 红,显示「登录」按钮;`proxyUp` 为 false 时额外提示 `代理未运行`
192
216
 
217
+ > **关于 UI 挂载位置(临时做法说明)**
218
+ >
219
+ > 当前状态胶囊用 `position:fixed` 直接注入 `document.body`。DSH 官方的 UI
220
+ > 组合机制是 **slot 系统**(`@deepseek-ai/dsh-client-ui-slots`,`ctx.slots.register`
221
+ > 注册 React 组件进声明的 slot)。按官方「组合优先、不要假设/覆盖其他插件内部实现」
222
+ > 的原则,理想做法是注册进官方 slot,而非直接改产品外壳。
223
+ >
224
+ > 评估结论:**当前 shell 声明的 slot 里没有专门的「右下角状态位」**;最接近的
225
+ > `sidebar.footer.action`、`conversation.session.header.actions`、
226
+ > `conversation.input.overlay` 都是 **React 组件位**,迁移意味着要把本插件从
227
+ > 「零依赖原生 JS」改造成 **React + 构建流程**,并依赖 shell 暴露 `ctx.slots`
228
+ > 服务。因此现阶段保留 DOM 注入(机制允许),**待官方 shell 提供合适的状态胶囊
229
+ > slot 后再迁移**。以下是已知的迁移路径(供后续参考):
230
+ >
231
+ > ```js
232
+ > ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
233
+ > name: "sidebar.footer.action",
234
+ > id: "workbuddy-status",
235
+ > locale: NS,
236
+ > inject: () => ({ /* 轮询句柄 / 登录回调 */ })
237
+ > }, WorkBuddyStatusPill /* React 组件 */));
238
+ > ```
239
+
193
240
  ### 为什么不需要构建
194
241
 
195
242
  DSH 的 `dsh.client` 机制只要求 `package.json` 里:
@@ -239,6 +286,7 @@ DSH 的 `dsh.client` 机制只要求 `package.json` 里:
239
286
  | 右下角没有胶囊 | `dsh web` 没重启加载新 bundle → 重启 `dsh web`;或 `curl /plugins/dsh-llm-workbuddy/client.js` 应返回 200 |
240
287
  | 胶囊一直 `…`(加载中) | `GET /api/workbuddy/status` 失败 → 确认 `dsh web` 在跑、端口正确 |
241
288
  | 胶囊红 + `代理未运行` | workbuddy2api 代理没起或挂了 → 按「安装代理」章节启动 |
289
+ | 胶囊显示登录成功但模型用不了 | 典型的"代理进程活着但指向旧路径/文件缺失"假象(`/health` 仍显示 ok)。点胶囊 **🔍 诊断**,看 `模型出字` 是否失败;按面板给出的重启命令重启代理 |
242
290
  | 点「登录」没反应 / 按钮灰 | `loginScriptAvailable:false` → 包内 `login_workbuddy.py` 缺失或系统无 `python3`;检查安装 |
243
291
  | 新标签页打开后登录完成,胶囊仍是红 | 会话文件(默认 `~/.codebuddy-session.json`)未刷新或 `expiresAt` 已过期 → 刷新页面或重跑登录 |
244
292
  | 启动 `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 |
@@ -261,8 +309,8 @@ DSH 的 `dsh.client` 机制只要求 `package.json` 里:
261
309
 
262
310
  | 路径 | 作用 |
263
311
  |---|---|
264
- | `lib/index.js` | Cordis 插件主体:LLM 适配器 `WorkBuddyAdapter` + `/api/workbuddy/*` 路由注册 |
265
- | `lib/client.js` | 零依赖浏览器小部件(状态胶囊 + 登录流程),被 `dsh.client` 注入 |
312
+ | `lib/index.js` | Cordis 插件主体:LLM 适配器 `WorkBuddyAdapter` + `/api/workbuddy/*` 路由(status / login / **diagnose**) |
313
+ | `lib/client.js` | 零依赖浏览器小部件(状态胶囊 + 登录流程 + **🔍 诊断**),被 `dsh.client` 注入 |
266
314
  | `login_workbuddy.py` | 设备流登录脚本(随包发布,被后端路由用系统 `python3` spawn) |
267
315
  | `cordis.patch.yml` | 本包的 Cordis bundle 挂载声明(`id: llm-workbuddy`) |
268
316
  | `package.json` | 包元数据、`dsh.client` 浏览器入口声明、`llm-workbuddy` peer 依赖 |
package/lib/client.js CHANGED
@@ -105,11 +105,15 @@ function runWidget() {
105
105
  html += '<button class="wb-btn" id="wb-login"' + disabled + ">" +
106
106
  (busy ? "登录中…" : "登录") + "</button>";
107
107
  }
108
+ // 诊断按钮始终显示,便于随时自查真实健康状态。
109
+ html += '<button class="wb-btn wb-diagnose" id="wb-diagnose" title="一键诊断">🔍</button>';
108
110
  if (!status.proxyUp) html += '<span class="wb-warn">代理未运行</span>';
109
111
  html += "</div>";
110
112
  host.innerHTML = html;
111
113
  var btn = host.querySelector("#wb-login");
112
114
  if (btn) btn.addEventListener("click", onLogin);
115
+ var dg = host.querySelector("#wb-diagnose");
116
+ if (dg) dg.addEventListener("click", onDiagnose);
113
117
  }
114
118
 
115
119
  function refresh() {
@@ -151,6 +155,63 @@ function runWidget() {
151
155
  });
152
156
  }
153
157
 
158
+ /** 一键诊断:真实探测代理健康,必要时给出可复制的重启命令。 */
159
+ function onDiagnose() {
160
+ var panel = document.getElementById("wb-diagnose-panel");
161
+ if (panel) { panel.remove(); return; }
162
+ var el = document.createElement("div");
163
+ el.id = "wb-diagnose-panel";
164
+ el.style.cssText =
165
+ "position:fixed;right:12px;bottom:44px;z-index:2147483647;width:340px;max-height:340px;overflow:auto;" +
166
+ "background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:8px;" +
167
+ "padding:10px;font:12px/1.5 system-ui,sans-serif;box-shadow:0 6px 20px rgba(0,0,0,.5);";
168
+ el.innerHTML = '<div class="wb-dg-title" style="font-weight:700;margin-bottom:6px">WorkBuddy 诊断中…</div>' +
169
+ '<div class="wb-dg-body" style="white-space:pre-wrap;word-break:break-word;color:#94a3b8">正在探测…</div>';
170
+ document.body.appendChild(el);
171
+
172
+ fetch("/api/workbuddy/diagnose", { method: "POST" })
173
+ .then(function (r) { return r.json(); })
174
+ .then(function (d) {
175
+ if (!d) throw new Error("空响应");
176
+ var title = d.ok ? "✅ 一切正常" : "⚠️ 发现问题";
177
+ var lines = [];
178
+ lines.push("登录状态: " + (d.session && d.session.authenticated ? "有效" : "无效"));
179
+ lines.push("会话文件: " + (d.session && d.session.file ? "存在" : "缺失"));
180
+ lines.push("代理进程: " + (d.health && d.health.proxyUp ? "在运行" : "未运行"));
181
+ lines.push("登录令牌: " + (d.health && d.health.tokenValid ? "有效" : "无效"));
182
+ lines.push("模型出字: " + (d.chat && d.chat.chatWorking ? "正常" : "失败"));
183
+ if (d.chat && d.chat.chatError) lines.push("出字错误: " + d.chat.chatError);
184
+ var html = '<div class="wb-dg-title" style="font-weight:700;margin-bottom:6px">' + title + '</div>' +
185
+ '<div class="wb-dg-body" style="white-space:pre-wrap;word-break:break-word">' + lines.map(esc).join("\n") + "</div>";
186
+ if (!d.ok && d.restartCommand) {
187
+ html += '<div style="margin-top:8px;font-weight:700">重启命令(复制到终端执行):</div>' +
188
+ '<pre style="background:#1e293b;border-radius:6px;padding:8px;font-size:11px;overflow:auto;white-space:pre-wrap">' +
189
+ esc(d.restartCommand) + "</pre>" +
190
+ '<button class="wb-btn" id="wb-copy-cmd">复制重启命令</button>';
191
+ }
192
+ el.innerHTML = html;
193
+ var copy = document.getElementById("wb-copy-cmd");
194
+ if (copy) {
195
+ copy.addEventListener("click", function () {
196
+ navigator.clipboard.writeText(d.restartCommand).then(function () {
197
+ copy.textContent = "已复制 ✓";
198
+ setTimeout(function () { copy.textContent = "复制重启命令"; }, 1500);
199
+ });
200
+ });
201
+ }
202
+ })
203
+ .catch(function (err) {
204
+ el.innerHTML = '<div class="wb-dg-title" style="font-weight:700;color:#f87171">诊断失败</div>' +
205
+ '<div class="wb-dg-body">' + esc(String(err && err.message || err)) + "</div>";
206
+ });
207
+ }
208
+
209
+ function esc(s) {
210
+ return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
211
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
212
+ });
213
+ }
214
+
154
215
  refresh();
155
216
  timer = setInterval(refresh, POLL_MS);
156
217
  }
package/lib/index.js CHANGED
@@ -762,84 +762,192 @@ async function probeProxy(baseURL) {
762
762
  }
763
763
 
764
764
  /**
765
- * Register `GET /api/workbuddy/status` and `POST /api/workbuddy/login` on the
766
- * DSH web server. The login route spawns `login_workbuddy.py`, parses the
767
- * device-flow `authUrl` it prints to stdout, and returns it so the browser
768
- * can open it in a new tab. The widget then polls `/status` until the session
769
- * file appears and reports `authenticated: true`.
765
+ * Probe whether the proxy can actually complete a chat completion, not just
766
+ * answer `/health`. `/health` reflects the proxy process alone it stays
767
+ * `authenticated: true` even when the process points at a deleted/old path
768
+ * and every model request fails with `[Errno 2] No such file or directory`.
769
+ * Sending one minimal request is the only probe that catches that class of
770
+ * "capsule shows green but models are all down" false positive.
771
+ * @returns `{ chatWorking, chatError? }` where `chatWorking` is true only
772
+ * when the proxy returned a non-streamed completion.
773
+ */
774
+ async function probeChatWorking(baseURL) {
775
+ const controller = new AbortController();
776
+ const timer = setTimeout(() => controller.abort(), 15000);
777
+ try {
778
+ const endpoint = `${baseURL.replace(/\/v1\/?$/, "")}/v1/chat/completions`;
779
+ const response = await fetch(endpoint, {
780
+ method: "POST",
781
+ headers: { "content-type": "application/json" },
782
+ body: JSON.stringify({
783
+ model: "hy3",
784
+ messages: [{ role: "user", content: "ping" }],
785
+ max_tokens: 4,
786
+ stream: false,
787
+ }),
788
+ signal: controller.signal,
789
+ });
790
+ if (!response.ok) {
791
+ const text = await response.text().catch(() => "");
792
+ return { chatWorking: false, chatError: `HTTP ${response.status}${text ? `: ${text.slice(0, 200)}` : ""}` };
793
+ }
794
+ const body = await response.json().catch(() => null);
795
+ const content = body?.choices?.[0]?.message?.content;
796
+ if (typeof content === "string" && content.length > 0) {
797
+ return { chatWorking: true };
798
+ }
799
+ return { chatWorking: false, chatError: "代理返回了空响应" };
800
+ } catch (error) {
801
+ return { chatWorking: false, chatError: String(error?.message ?? error) };
802
+ } finally {
803
+ clearTimeout(timer);
804
+ }
805
+ }
806
+
807
+ /**
808
+ * The restart command shown by the diagnose route. It adapts to two layouts:
809
+ * - Local monorepo dev: the plugin sits inside a `dsh-workbuddy/` checkout with
810
+ * `start-workbuddy.sh` two levels up — use the workspace-local script.
811
+ * - Standard install (any other machine): no `start-workbuddy.sh`, so show the
812
+ * upstream README command for running the workbuddy2api proxy via uv.
813
+ */
814
+ function diagnoseRestartCommand() {
815
+ const workspaceRoot = resolve(PLUGIN_DIR, "..", "..");
816
+ const localScript = resolve(workspaceRoot, "start-workbuddy.sh");
817
+ if (existsSync(localScript)) {
818
+ return [
819
+ "# 检测到本机 monorepo 布局(dsh-workbuddy/),用仓库脚本重启代理",
820
+ "# 停掉旧代理(若 8787 被占用)",
821
+ "lsof -tiTCP:8787 -sTCP:LISTEN | xargs kill",
822
+ "",
823
+ "# 重启代理(脚本路径自动定位到本仓库)",
824
+ `cd ${workspaceRoot} && ./start-workbuddy.sh`,
825
+ ].join("\n");
826
+ }
827
+ return [
828
+ "# 用 workbuddy2api 代理(标准安装)重启:",
829
+ "# 停掉旧代理(若 8787 被占用)",
830
+ "lsof -tiTCP:8787 -sTCP:LISTEN | xargs kill",
831
+ "",
832
+ "# 拉取并运行代理(详见 README「安装代理」)",
833
+ "git clone https://github.com/hawklithm/workbuddy2api.git && cd workbuddy2api",
834
+ "uv run python -u -m codebuddy_proxy --desensitize \\",
835
+ " --session-file ~/.codebuddy-session.json --log-file ~/.codebuddy-proxy.jsonl",
836
+ ].join("\n");
837
+ }
838
+
839
+ /**
840
+ * Register `GET /api/workbuddy/status`, `POST /api/workbuddy/login`, and
841
+ * `POST /api/workbuddy/diagnose` on the DSH web server. The login route spawns
842
+ * `login_workbuddy.py`, parses the device-flow `authUrl` it prints to stdout,
843
+ * and returns it so the browser can open it in a new tab. The widget then
844
+ * polls `/status` until the session file appears and reports
845
+ * `authenticated: true`.
770
846
  */
771
847
  function registerWorkbuddyRoutes(ctx, config) {
772
- // Probe the optional webServer service. In a headless profile it is absent
773
- // and we skip the HTTP surface entirely — the adapter (registered in apply)
774
- // still works. Because `webServer` is not in `inject`, this must be a
775
- // runtime probe, not a dereference.
776
- const webServer = ctx.get("webServer");
777
- if (webServer === undefined) return;
778
- const baseURL = config_baseURL();
779
- const { sessionFile, loginScript } = resolveLoginPaths(config);
780
- // The register() call returns a disposer. Wrapping it in ctx.effect ties it
781
- // to the plugin fiber so config hot-edit / reload cleans the routes up
782
- // instead of leaking them (a leaked route makes a duplicate re-register
783
- // throw). Method filtering must be done inside the handler — the route API
784
- // only accepts `{ kind, path, handler }`.
785
- ctx.effect(() => webServer.register({
786
- kind: "exact",
787
- path: "/api/workbuddy/status",
788
- async handler(req, res) {
789
- if (req.method !== "GET") {
790
- res.writeHead(405, { "content-type": "application/json" });
791
- res.end(JSON.stringify({ error: "method not allowed" }));
792
- return;
793
- }
794
- const session = readSessionStatus(sessionFile);
795
- const proxy = await probeProxy(baseURL);
796
- res.writeHead(200, { "content-type": "application/json" });
797
- res.end(JSON.stringify({
798
- ...session,
799
- ...proxy,
800
- loginScriptAvailable: existsSync(loginScript),
801
- }));
802
- },
803
- }));
804
- ctx.effect(() => webServer.register({
805
- kind: "exact",
806
- path: "/api/workbuddy/login",
807
- async handler(req, res) {
808
- if (req.method !== "POST") {
809
- res.writeHead(405, { "content-type": "application/json" });
810
- res.end(JSON.stringify({ error: "method not allowed" }));
811
- return;
812
- }
813
- if (!existsSync(loginScript)) {
814
- res.writeHead(503, { "content-type": "application/json" });
815
- res.end(JSON.stringify({ error: "login_workbuddy.py not found" }));
816
- return;
817
- }
818
- // Existing session is fine; no need to re-run the flow.
819
- const existing = readSessionStatus(sessionFile);
820
- if (existing.authenticated) {
848
+ // Delay registration until the webServer service exists. The plugin keeps
849
+ // `webServer` OUT of `inject` so headless profiles (no HTTP surface) still
850
+ // load but a synchronous `ctx.get("webServer")` probe at apply time races
851
+ // the webserver plugin's own mount and can observe the service before it is
852
+ // provided, silently dropping the routes. `ctx.inject` runs this callback
853
+ // only once webServer is available, and skips it entirely when the service
854
+ // never appears (headless), preserving the original intent.
855
+ ctx.inject(["webServer"], (webCtx) => {
856
+ const webServer = webCtx.webServer;
857
+ const baseURL = config_baseURL();
858
+ const { sessionFile, loginScript } = resolveLoginPaths(config);
859
+ // The register() call returns a disposer. Wrapping it in ctx.effect ties
860
+ // it to the plugin fiber so config hot-edit / reload cleans the routes up
861
+ // instead of leaking them (a leaked route makes a duplicate re-register
862
+ // throw). Method filtering must be done inside the handler — the route API
863
+ // only accepts `{ kind, path, handler }`.
864
+ webCtx.effect(() => webServer.register({
865
+ kind: "exact",
866
+ path: "/api/workbuddy/status",
867
+ async handler(req, res) {
868
+ if (req.method !== "GET") {
869
+ res.writeHead(405, { "content-type": "application/json" });
870
+ res.end(JSON.stringify({ error: "method not allowed" }));
871
+ return;
872
+ }
873
+ const session = readSessionStatus(sessionFile);
874
+ const proxy = await probeProxy(baseURL);
821
875
  res.writeHead(200, { "content-type": "application/json" });
822
- res.end(JSON.stringify({ alreadyLoggedIn: true, authUrl: null, ...existing }));
823
- return;
824
- }
825
- // login_workbuddy.py is dependency-free (stdlib only), so we invoke it
826
- // directly with the system Python — no uv runtime required.
827
- const args = ["-u", loginScript, "--session-file", sessionFile];
828
- const child = spawn("python3", args, { env: { ...process.env, PYTHONUNBUFFERED: "1" } });
829
- let stdout = "";
830
- let authUrl = null;
831
- child.stdout.on("data", (chunk) => {
832
- stdout += chunk.toString();
833
- const m = stdout.match(/https?:\/\/\S+/);
834
- if (m && authUrl === null) authUrl = m[0];
835
- });
836
- child.stderr.on("data", () => {});
837
- // The device flow blocks until login or timeout; we hand back the URL
838
- // immediately and let the widget poll /status for completion.
839
- res.writeHead(200, { "content-type": "application/json" });
840
- res.end(JSON.stringify({ authUrl, pending: true }));
841
- },
842
- }));
876
+ res.end(JSON.stringify({
877
+ ...session,
878
+ ...proxy,
879
+ loginScriptAvailable: existsSync(loginScript),
880
+ }));
881
+ },
882
+ }));
883
+ webCtx.effect(() => webServer.register({
884
+ kind: "exact",
885
+ path: "/api/workbuddy/login",
886
+ async handler(req, res) {
887
+ if (req.method !== "POST") {
888
+ res.writeHead(405, { "content-type": "application/json" });
889
+ res.end(JSON.stringify({ error: "method not allowed" }));
890
+ return;
891
+ }
892
+ if (!existsSync(loginScript)) {
893
+ res.writeHead(503, { "content-type": "application/json" });
894
+ res.end(JSON.stringify({ error: "login_workbuddy.py not found" }));
895
+ return;
896
+ }
897
+ // Existing session is fine; no need to re-run the flow.
898
+ const existing = readSessionStatus(sessionFile);
899
+ if (existing.authenticated) {
900
+ res.writeHead(200, { "content-type": "application/json" });
901
+ res.end(JSON.stringify({ alreadyLoggedIn: true, authUrl: null, ...existing }));
902
+ return;
903
+ }
904
+ // login_workbuddy.py is dependency-free (stdlib only), so we invoke it
905
+ // directly with the system Python — no uv runtime required.
906
+ const args = ["-u", loginScript, "--session-file", sessionFile];
907
+ const child = spawn("python3", args, { env: { ...process.env, PYTHONUNBUFFERED: "1" } });
908
+ let stdout = "";
909
+ let authUrl = null;
910
+ child.stdout.on("data", (chunk) => {
911
+ stdout += chunk.toString();
912
+ const m = stdout.match(/https?:\/\/\S+/);
913
+ if (m && authUrl === null) authUrl = m[0];
914
+ });
915
+ child.stderr.on("data", () => {});
916
+ // The device flow blocks until login or timeout; we hand back the URL
917
+ // immediately and let the widget poll /status for completion.
918
+ res.writeHead(200, { "content-type": "application/json" });
919
+ res.end(JSON.stringify({ authUrl, pending: true }));
920
+ },
921
+ }));
922
+ webCtx.effect(() => webServer.register({
923
+ kind: "exact",
924
+ path: "/api/workbuddy/diagnose",
925
+ async handler(req, res) {
926
+ if (req.method !== "POST") {
927
+ res.writeHead(405, { "content-type": "application/json" });
928
+ res.end(JSON.stringify({ error: "method not allowed" }));
929
+ return;
930
+ }
931
+ // Real health: probe /health AND actually complete a chat. /health
932
+ // alone stays green even when the proxy points at a deleted path and
933
+ // every model request 500s — the exact "capsule green but models dead"
934
+ // false positive the diagnose button exists to surface.
935
+ const session = readSessionStatus(sessionFile);
936
+ const health = await probeProxy(baseURL);
937
+ const chat = await probeChatWorking(baseURL);
938
+ const ok = Boolean(health.proxyUp && health.tokenValid && session.authenticated && chat.chatWorking);
939
+ res.writeHead(200, { "content-type": "application/json" });
940
+ res.end(JSON.stringify({
941
+ ok,
942
+ session: { file: session.sessionFile, authenticated: session.authenticated, expiresAt: session.expiresAt },
943
+ health,
944
+ chat,
945
+ loginScriptAvailable: existsSync(loginScript),
946
+ restartCommand: ok ? null : diagnoseRestartCommand(),
947
+ }));
948
+ },
949
+ }));
950
+ });
843
951
  }
844
952
 
845
953
  /** Lazily resolve the configured proxy base URL for the health probe. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-llm-workbuddy",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",