dsh-workspace-api-key 0.2.0 → 0.2.2
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 +9 -7
- package/README.zh.md +8 -6
- package/lib/client.js +259 -13
- package/lib/index.js +184 -15
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -31,17 +31,18 @@ Host half (`lib/index.js`):
|
|
|
31
31
|
- matches that directory against `workspaceRegistry.list()` after normalizing both sides (absolute, trailing separator removed, lower-cased on Windows);
|
|
32
32
|
- derives a stable ref per level — `DEEPSEEK_API_KEY_WS_<first 16 hex chars of sha1(normalized path), upper-cased>` for a workspace and `DEEPSEEK_API_KEY_SS_<first 16 hex chars of sha1(session id), upper-cased>` for a session (a session id contains `-` and cannot be a ref name) — writes the key through the credentials service, and serves that ref's value;
|
|
33
33
|
- rewrites only refs a provider actually reads: the default ref, every adapter's configured `apiKeyEnv`, and its own generated refs. Every other ref passes through untouched;
|
|
34
|
-
- builds the session list from the `sessionController` service (`list()`), so the panel
|
|
34
|
+
- builds the session list from the `sessionController` service (`list()`, unwrapping its `{items: […]}` envelope) and falls back to the live `sessions` service (`ctx.get('sessions').list()`), so the panel still has sessions when the controller is missing, throws, or returns an empty list — subagent sessions are filtered out of that fallback;
|
|
35
|
+
- reports its own version and a session probe (`available`, `shape`, `source`, `count`, `withoutCwd`, `samples`, `error`, plus sample workspace paths) in `/wsk-api/state`, so the panel can say *why* no sessions are listed: a stale host half (only a page reload instead of an app restart), a missing `sessionController`, a throwing `list()`, an unexpected envelope shape, or a `cwd` that matches no workspace path;
|
|
35
36
|
- keeps its own bookkeeping (which level maps to which ref, invalid flags, the provider a key was recorded for) in `$DSH_HOME/storages/dsh-workspace-api-key.json`;
|
|
36
37
|
- observes failures through the `agent/request-error` waterfall without ever returning `{kind: 'retry'}` — it only records the verdict and calls `next()`.
|
|
37
38
|
|
|
38
|
-
The browser half (`lib/client.js`) registers into the `sidebar.panellist` slot (`order: 1`), the `main` slot (key `workspace-api-key`) and the `shell.overlay` slot (the entering-a-session warning), the same slots `dsh-skill-mcp-panel` and `dsh-429-guard` use. It is plain `React.createElement` with no build step and no bundled dependencies. The overlay asks `/wsk-api/check
|
|
39
|
+
The browser half (`lib/client.js`) registers into the `sidebar.panellist` slot (`order: 1`), the `main` slot (key `workspace-api-key`) and the `shell.overlay` slot (the entering-a-session warning), the same slots `dsh-skill-mcp-panel` and `dsh-429-guard` use. It is plain `React.createElement` with no build step and no bundled dependencies. It merges the host rows with the browser-side `sessions` store (`ctx.get('sessions').list.getSnapshot()`, which is what the sidebar itself renders), so session titles come from `displayTitle` and sessions stay visible even when the host cannot list them. The overlay asks `/wsk-api/check?sessionId=…&path=…` about the session that currently owns the main view, asks at most once per session, and its buttons only ever navigate to the panel — the panel is where a key can actually be changed.
|
|
39
40
|
|
|
40
41
|
### HTTP API (loopback only)
|
|
41
42
|
|
|
42
43
|
| Method | Path | Purpose |
|
|
43
44
|
| --- | --- | --- |
|
|
44
|
-
| GET | `/wsk-api/state` | default ref status, workspaces with their sessions, overrides, invalid flags |
|
|
45
|
+
| GET | `/wsk-api/state` | default ref status, workspaces with their sessions, overrides, invalid flags, plugin version and session probe |
|
|
45
46
|
| GET | `/wsk-api/resolve` | which level and ref the current session resolves to right now |
|
|
46
47
|
| GET | `/wsk-api/check` | `?sessionId=…&path=…` — read-only verdict for one session: effective level, key, stale flag, provider mismatch, `needsAttention` |
|
|
47
48
|
| POST | `/wsk-api/set` | `{scope: 'workspace' \| 'session', path \| sessionId, title, key, provider?}` binds a key; `{useDefault: true}` or an empty key clears it |
|
|
@@ -70,21 +71,22 @@ Then add `"dsh-workspace-api-key"` to `dsh.profile.bundles` in that profile's `p
|
|
|
70
71
|
## Tests
|
|
71
72
|
|
|
72
73
|
```sh
|
|
73
|
-
node test/host.test.mjs #
|
|
74
|
-
node test/client.test.mjs #
|
|
74
|
+
node test/host.test.mjs # 125 assertions
|
|
75
|
+
node test/client.test.mjs # 121 assertions
|
|
75
76
|
```
|
|
76
77
|
|
|
77
|
-
Neither suite needs a running DSH process. The host suite drives `apply()` against a mock context (fake `credentials`, `workspaceRegistry`, `agents`, `sessionController`, `webServer`, stubbed `fetch`) and covers ref derivation, the session → workspace → default cascade, workspace matching, redirecting a second adapter's `apiKeyEnv`, clearing back to the default, 401/402/429/5xx classification, the loopback and origin checks, and
|
|
78
|
+
Neither suite needs a running DSH process. The host suite drives `apply()` against a mock context (fake `credentials`, `workspaceRegistry`, `agents`, `sessionController`, `sessions`, `webServer`, stubbed `fetch`) and covers ref derivation, the session → workspace → default cascade, workspace matching, redirecting a second adapter's `apiKeyEnv`, clearing back to the default, 401/402/429/5xx classification, the loopback and origin checks, state persistence, and the session probe including the `{items}` envelope and the live-session fallback. The client suite loads `lib/client.js` through a minimal `window.__ModuleLoader__` plus a small React runtime, renders the panel and the overlay, and asserts slot registration, the first render, session expand/collapse, per-session saving and testing, sessions recovered from the browser-side store when the host has none (with `displayTitle` winning and subagent sessions hidden), the ungrouped-directory card, the stale and mismatch prompts, the "go configure" hand-off, graceful degradation without a session list, and back-navigation when `ctx.get('layout')` only becomes available after the plugin was applied.
|
|
78
79
|
|
|
79
80
|
## Requirements and limits
|
|
80
81
|
|
|
81
82
|
- Verified against DSH Desktop 2.0.17 with `@deepseek-ai/dsh*` 0.2.0-rc.2 and `@deepseek-ai/cordis` 4.0.4. It has no runtime dependency and imports no `@deepseek-ai/*` package — only the host services (`credentials`, `workspaceRegistry`, `agents`, `sessionController`, `webServer`, `llm`, `settings`) and Node built-ins.
|
|
82
83
|
- One key per level: a session has a single key whichever provider route reads it, and a key recorded for one provider is reported as a mismatch when the session runs on another. Model changes inside one provider are not treated as a mismatch, because a DeepSeek key is account-wide.
|
|
83
|
-
- Sessions are matched to a workspace by their directory (`cwd`).
|
|
84
|
+
- Sessions are matched to a workspace by their directory (`cwd`). The host's own `list()` skips stored sessions that never got a `cwd`, and the live-session fallback only knows sessions that are currently loaded, so the panel may show fewer sessions than the sidebar. Sessions visible only in the browser-side store carry no `cwd` of their own beyond the one the sidebar knows.
|
|
84
85
|
- The connection test speaks the Anthropic Messages shape (`POST <baseURL>/messages` with `x-api-key` and `anthropic-version: 2023-06-01`), which is what the DeepSeek route uses. A provider that speaks a different protocol can still have keys bound per level, but the test may report a failure for a key that works.
|
|
85
86
|
- Keys are stored through the credentials service in `$DSH_HOME/.credentials.yaml` under `refs:`, in plaintext, exactly like the default key. They are not written into session logs, the plugin's state file, or the plugin directory.
|
|
86
87
|
- Matching is textual after normalization. If a session's `cwd` is a symlink or junction to a registered workspace path, it will not match and the level above is used.
|
|
87
88
|
- Uninstalling the plugin leaves the generated `*_WS_*` / `*_SS_*` refs in the credentials file; remove them from the Models page.
|
|
89
|
+
- If the panel lists workspaces but no expandable sessions, read the diagnostics line at the bottom of the panel. A red "the host half is still an older version" banner means the DSH process was never restarted: the host half is loaded once at startup, so closing the window or reloading the page is not enough — fully quit DSH Desktop and open it again. Otherwise the diagnostics line shows how many sessions the host sees (and from which source: `sessionController`, its live-session fallback, or the browser store), plus sample session `cwd` values next to sample workspace paths when none of them match.
|
|
88
90
|
|
|
89
91
|
## License
|
|
90
92
|
|
package/README.zh.md
CHANGED
|
@@ -31,11 +31,12 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
|
|
|
31
31
|
- 把该目录与 `workspaceRegistry.list()` 做规范化后比对(绝对化、去掉尾部分隔符、Windows 折大小写);
|
|
32
32
|
- 由每一层派生出稳定的 ref —— 工作区是 `DEEPSEEK_API_KEY_WS_<sha1(规范化路径) 前 16 位十六进制大写>`,会话是 `DEEPSEEK_API_KEY_SS_<sha1(会话 id) 前 16 位十六进制大写>`(会话 id 含 `-`,不能直接当 ref 名)——通过凭据服务写入 key,并返回这个 ref 的值;
|
|
33
33
|
- 只改写 provider 真正会读的 ref:默认 ref、每个适配器配置的 `apiKeyEnv`,以及插件自己生成的 ref;其它 ref 原样透传;
|
|
34
|
-
- 会话列表来自 `sessionController` 服务(`list()
|
|
34
|
+
- 会话列表来自 `sessionController` 服务(`list()`,会拆开它 `{items: […]}` 的信封),控件缺失 / 抛错 / 列表为空时再退到宿主 live 会话服务(`ctx.get('sessions').list()`),所以那种情况下面板仍能列出会话(该兜底会过滤掉子代理会话);
|
|
35
|
+
- `/wsk-api/state` 会带上插件自身版本与会话探针(`available`、`shape`、`source`、`count`、`withoutCwd`、`samples`、`error`,以及工作区路径样本),面板据此说明**为什么**没有列出会话:宿主半边是旧版本(只刷新了页面而没重启应用)、没有 `sessionController`、`list()` 抛错、信封形状不认识,还是会话的 `cwd` 与任何工作区路径都对不上;
|
|
35
36
|
- 自己的账本(哪一层对应哪个 ref、失效标记、key 是给哪个服务商记的)落在 `$DSH_HOME/storages/dsh-workspace-api-key.json`;
|
|
36
37
|
- 通过 `agent/request-error` waterfall 观察失败,但**从不**返回 `{kind: 'retry'}`——只记录判定结果并 `next()` 放行。
|
|
37
38
|
|
|
38
|
-
浏览器半边(`lib/client.js`)注册到 `sidebar.panellist` 槽(`order: 1`)、`main` 槽(key 为 `workspace-api-key`)与 `shell.overlay` 槽(点进会话时的提醒),和 `dsh-skill-mcp-panel`、`dsh-429-guard` 用的是同一套槽。全部是朴素的 `React.createElement
|
|
39
|
+
浏览器半边(`lib/client.js`)注册到 `sidebar.panellist` 槽(`order: 1`)、`main` 槽(key 为 `workspace-api-key`)与 `shell.overlay` 槽(点进会话时的提醒),和 `dsh-skill-mcp-panel`、`dsh-429-guard` 用的是同一套槽。全部是朴素的 `React.createElement`,没有构建步骤,也不打包任何依赖。它会把宿主行与浏览器端 `sessions` 存储(`ctx.get('sessions').list.getSnapshot()`,也就是侧栏自己渲染的数据)合并起来,因此标题取自 `displayTitle`,宿主列不出会话时面板也照样有会话可列。覆盖层向 `/wsk-api/check?sessionId=…&path=…` 查询当前占据主视图的会话,每个会话最多问一次,它的按钮只负责把你带到面板——改 key 只能由面板里的操作完成。
|
|
39
40
|
|
|
40
41
|
### HTTP 接口(仅限回环)
|
|
41
42
|
|
|
@@ -70,21 +71,22 @@ pnpm add "dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key"
|
|
|
70
71
|
## 测试
|
|
71
72
|
|
|
72
73
|
```sh
|
|
73
|
-
node test/host.test.mjs #
|
|
74
|
-
node test/client.test.mjs #
|
|
74
|
+
node test/host.test.mjs # 125 条断言
|
|
75
|
+
node test/client.test.mjs # 121 条断言
|
|
75
76
|
```
|
|
76
77
|
|
|
77
|
-
两个套件都不需要启动 DSH。宿主套件用一个假 ctx(假的 `credentials`、`workspaceRegistry`、`agents`、`sessionController`、`webServer`,以及打桩的 `fetch`)驱动 `apply()`,覆盖 ref 派生、会话→工作区→默认的级联、工作区匹配、把第二个适配器的 `apiKeyEnv` 一起重定向、清回默认、401/402/429/5xx
|
|
78
|
+
两个套件都不需要启动 DSH。宿主套件用一个假 ctx(假的 `credentials`、`workspaceRegistry`、`agents`、`sessionController`、`sessions`、`webServer`,以及打桩的 `fetch`)驱动 `apply()`,覆盖 ref 派生、会话→工作区→默认的级联、工作区匹配、把第二个适配器的 `apiKeyEnv` 一起重定向、清回默认、401/402/429/5xx 的判定、回环与来源校验、状态落盘,以及会话探针(含 `{items}` 信封与 live 兜底)。浏览器套件通过一个极小的 `window.__ModuleLoader__` 与自制 React 运行时加载 `lib/client.js`,渲染出面板与覆盖层,断言槽注册、首屏渲染、会话折叠/展开、会话级保存与测试、宿主列不出会话时从浏览器端存储恢复会话(标题取 `displayTitle`、隐藏子代理会话)、未分组目录卡片、失效与不匹配提醒、「去配置」的交接、没有会话列表时的降级,以及 `ctx.get('layout')` 在插件 apply 之后才可用时返回键仍然有效。
|
|
78
79
|
|
|
79
80
|
## 要求与限制
|
|
80
81
|
|
|
81
82
|
- 已在 DSH Desktop 2.0.17(`@deepseek-ai/dsh*` 0.2.0-rc.2、`@deepseek-ai/cordis` 4.0.4)上验证。插件没有运行时依赖,也不 import 任何 `@deepseek-ai/*` 包,只用宿主服务(`credentials`、`workspaceRegistry`、`agents`、`sessionController`、`webServer`、`llm`、`settings`)与 Node 内置模块。
|
|
82
83
|
- 每一层只有一把 key:一个会话无论被哪个 provider 路由读取,用的都是同一把 key;key 记下的服务商与会话当前服务商不一致时会被报为「不匹配」,但同一服务商内换模型不算不匹配,因为 DeepSeek key 是账号级的。
|
|
83
|
-
- 会话按目录(`cwd
|
|
84
|
+
- 会话按目录(`cwd`)归入工作区。宿主自己的 `list()` 会跳过从未存过 `cwd` 的历史会话,而 live 兜底只认识当前装载中的会话,所以面板列出的会话可能比侧栏少;只在浏览器端存储里可见的会话,除了侧栏知道的那个 `cwd` 之外没有别的路径信息。
|
|
84
85
|
- 「测试连接」说的是 Anthropic Messages 协议(`POST <baseURL>/messages` 带 `x-api-key` 与 `anthropic-version: 2023-06-01`),也就是 DeepSeek 路由用的协议;换了别的协议的 provider 一样可以按层绑 key,只是测试可能把有效的 key 报成失败。
|
|
85
86
|
- key 通过凭据服务存放在 `$DSH_HOME/.credentials.yaml` 的 `refs:` 里,明文,和默认 key 完全一样。它不会被写进会话日志、插件状态文件或插件目录。
|
|
86
87
|
- 匹配是规范化之后的文本比较。如果某个会话的 `cwd` 是指向已注册工作区路径的符号链接或 junction,则匹配不上,会走上层层级。
|
|
87
88
|
- 卸载插件不会删除已生成的 `*_WS_*` / `*_SS_*` ref,可在「设置 → 模型」里清掉。
|
|
89
|
+
- 如果面板列出了工作区却没有可展开的会话,请看面板底部的诊断行。出现红色「宿主端插件还是旧版本」横幅,说明 DSH 进程没有重启过:宿主半边只在启动时加载,关窗口和刷新页面都不算——请完全退出 DSH Desktop 再打开。其余情况诊断行会写明宿主看到多少个会话、会话列表来自哪个来源(`sessionController`、live 兜底,还是浏览器端存储);一个都对不上时,还会并排给出会话 `cwd` 样本与工作区路径样本,便于比对。
|
|
88
90
|
|
|
89
91
|
## 许可
|
|
90
92
|
|
package/lib/client.js
CHANGED
|
@@ -20,6 +20,19 @@ window.__ModuleLoader__.load({
|
|
|
20
20
|
// refresh —— 面板挂载后在这里登记自己的刷新函数
|
|
21
21
|
const bridge = { focus: null, refresh: null }
|
|
22
22
|
|
|
23
|
+
// apply 阶段拿到的 ctx。会话列表服务(browser 侧 "sessions")由
|
|
24
|
+
// dsh-api-session-controller 的客户端半边在 effect 里 provide,可能晚于
|
|
25
|
+
// 本插件注册,所以只在渲染/点击时惰性取,不在 apply 阶段捕获结果。
|
|
26
|
+
const host = { ctx: null }
|
|
27
|
+
function sessionsService(fallback) {
|
|
28
|
+
if (fallback !== undefined && fallback !== null) return fallback
|
|
29
|
+
try {
|
|
30
|
+
return host.ctx?.get?.('sessions')
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
// ─────────────────────────────── 样式 ───────────────────────────────
|
|
24
37
|
|
|
25
38
|
const CSS = `
|
|
@@ -68,6 +81,7 @@ window.__ModuleLoader__.load({
|
|
|
68
81
|
background: transparent; color: inherit; font: inherit; font-size: 12px; }
|
|
69
82
|
.wsk-input:focus { outline: 2px solid color-mix(in srgb, #2563eb 45%, transparent); outline-offset: -1px; }
|
|
70
83
|
.wsk-hint { color: var(--wsk-dim); font-size: 11px; line-height: 1.6; }
|
|
84
|
+
.wsk-diag { color: var(--wsk-dim); font-size: 10px; line-height: 1.6; margin-top: 4px; word-break: break-all; }
|
|
71
85
|
.wsk-msg { padding: 7px 10px; border-radius: 6px; font-size: 12px; line-height: 1.5; }
|
|
72
86
|
.wsk-msg.ok { background: color-mix(in srgb, var(--wsk-ok) 12%, transparent); color: var(--wsk-ok); }
|
|
73
87
|
.wsk-msg.bad { background: color-mix(in srgb, var(--wsk-bad) 12%, transparent); color: var(--wsk-bad); }
|
|
@@ -178,6 +192,153 @@ window.__ModuleLoader__.load({
|
|
|
178
192
|
return title ?? (session.blank === true ? '(空会话)' : String(session.sessionId).slice(0, 12))
|
|
179
193
|
}
|
|
180
194
|
|
|
195
|
+
// ─────────────────── 会话树:宿主行 + 浏览器端列表 ───────────────────
|
|
196
|
+
// 宿主 /state 里的会话行只有「带 cwd 的会话」才能被挂到目录下,而且不含标题
|
|
197
|
+
// (标题是浏览器端的 projection)。这里用浏览器端 sessions 服务补齐:
|
|
198
|
+
// · 标题(displayTitle)
|
|
199
|
+
// · 宿主漏掉的会话(例如 cwd 规范化差异导致的挂载失败)
|
|
200
|
+
// 合并后仍然保持宿主的 key 信息(level / configured / invalid / mismatch)。
|
|
201
|
+
|
|
202
|
+
function normKey(p) {
|
|
203
|
+
if (typeof p !== 'string' || p === '') return undefined
|
|
204
|
+
const trimmed = p.replace(/[\\/]+$/, '')
|
|
205
|
+
const base = trimmed === '' ? p : trimmed
|
|
206
|
+
return base.replace(/\//g, '\\').toLowerCase()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function dirName(p) {
|
|
210
|
+
if (typeof p !== 'string' || p === '') return ''
|
|
211
|
+
const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/)
|
|
212
|
+
return parts[parts.length - 1] ?? p
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 浏览器端 sessions 服务快照 → 顶层会话条目(子代理会话不算)。 */
|
|
216
|
+
function clientSessionEntries(sessions) {
|
|
217
|
+
const snap = sessions?.list?.getSnapshot?.()
|
|
218
|
+
if (snap === null || snap === undefined) return []
|
|
219
|
+
const ids = Array.isArray(snap.ids) ? snap.ids : Object.keys(snap.byId ?? {})
|
|
220
|
+
const entries = []
|
|
221
|
+
for (const id of ids) {
|
|
222
|
+
const item = snap.byId?.[id]
|
|
223
|
+
if (item === null || item === undefined) continue
|
|
224
|
+
// 子代理会话不作为可配置项列出(它们跟随父会话)。
|
|
225
|
+
if (item.parentId !== undefined || item.origin === 'subagent') continue
|
|
226
|
+
entries.push({
|
|
227
|
+
sessionId: typeof item.id === 'string' && item.id !== '' ? item.id : id,
|
|
228
|
+
cwd: typeof item.cwd === 'string' && item.cwd !== '' ? item.cwd : undefined,
|
|
229
|
+
title: typeof item.displayTitle === 'string' && item.displayTitle !== ''
|
|
230
|
+
? item.displayTitle
|
|
231
|
+
: typeof item.title === 'string' && item.title !== '' ? item.title : undefined,
|
|
232
|
+
updatedAt: typeof item.updatedAt === 'number' ? item.updatedAt : 0,
|
|
233
|
+
running: item.running === true,
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
return entries
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 用浏览器端的会话列表重建「工作区/目录 → 会话」的树。
|
|
241
|
+
* 宿主行(含 key 层级)按 sessionId 合并进来;宿主没给出的会话用
|
|
242
|
+
* /state 里的覆盖记录补出层级,保证按钮不会因为宿主列表为空而消失。
|
|
243
|
+
*/
|
|
244
|
+
function mergeSessions(state, sessions) {
|
|
245
|
+
const entries = clientSessionEntries(sessions)
|
|
246
|
+
if (state === null || state === undefined || entries.length === 0) return state
|
|
247
|
+
|
|
248
|
+
const hostRows = new Map()
|
|
249
|
+
for (const group of [...(state.workspaces ?? []), ...(state.ungrouped ?? [])]) {
|
|
250
|
+
for (const row of (Array.isArray(group?.sessions) ? group.sessions : [])) {
|
|
251
|
+
if (row?.sessionId !== undefined) hostRows.set(row.sessionId, row)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const overrideBySession = new Map()
|
|
255
|
+
for (const item of (Array.isArray(state.sessionOverrides) ? state.sessionOverrides : [])) {
|
|
256
|
+
if (item?.sessionId !== undefined) overrideBySession.set(item.sessionId, item)
|
|
257
|
+
}
|
|
258
|
+
const workspaceByPath = new Map()
|
|
259
|
+
for (const ws of (state.workspaces ?? [])) {
|
|
260
|
+
const key = normKey(ws?.path)
|
|
261
|
+
if (key !== undefined) workspaceByPath.set(key, ws)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const buckets = new Map()
|
|
265
|
+
const extras = new Map()
|
|
266
|
+
for (const entry of entries) {
|
|
267
|
+
const host = hostRows.get(entry.sessionId)
|
|
268
|
+
const cwd = typeof host?.cwd === 'string' && host.cwd !== '' ? host.cwd : entry.cwd
|
|
269
|
+
const pathKey = normKey(cwd)
|
|
270
|
+
const ws = pathKey !== undefined ? workspaceByPath.get(pathKey) : undefined
|
|
271
|
+
let row = host
|
|
272
|
+
if (row === undefined) {
|
|
273
|
+
const own = overrideBySession.get(entry.sessionId)
|
|
274
|
+
const level = own !== undefined ? 'session' : ws?.configured === true ? 'workspace' : 'default'
|
|
275
|
+
row = {
|
|
276
|
+
sessionId: entry.sessionId,
|
|
277
|
+
title: entry.title,
|
|
278
|
+
cwd,
|
|
279
|
+
updatedAt: entry.updatedAt,
|
|
280
|
+
running: entry.running,
|
|
281
|
+
blank: false,
|
|
282
|
+
parentSessionId: undefined,
|
|
283
|
+
provider: own?.provider ?? ws?.overrideProvider,
|
|
284
|
+
model: own?.model ?? ws?.overrideModel,
|
|
285
|
+
ref: own?.ref,
|
|
286
|
+
configured: own !== undefined,
|
|
287
|
+
keyMasked: own?.keyMasked,
|
|
288
|
+
overrideProvider: own?.provider,
|
|
289
|
+
overrideModel: own?.model,
|
|
290
|
+
level,
|
|
291
|
+
effectiveRef: level === 'session' ? own?.ref : level === 'workspace' ? ws?.ref : undefined,
|
|
292
|
+
effectiveKeyMasked: level === 'session'
|
|
293
|
+
? own?.keyMasked
|
|
294
|
+
: level === 'workspace' ? ws?.keyMasked : state.defaultRef?.keyMasked,
|
|
295
|
+
effectiveProvider: own?.provider ?? ws?.overrideProvider,
|
|
296
|
+
effectiveModel: own?.model ?? ws?.overrideModel,
|
|
297
|
+
effectiveTitle: undefined,
|
|
298
|
+
invalid: (level === 'session' ? own?.invalid : level === 'workspace' ? ws?.invalid : null) ?? null,
|
|
299
|
+
mismatch: null,
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
const patch = {}
|
|
303
|
+
if ((row.title === undefined || row.title === '') && entry.title !== undefined) patch.title = entry.title
|
|
304
|
+
if ((row.cwd === undefined || row.cwd === '') && cwd !== undefined) patch.cwd = cwd
|
|
305
|
+
if (Object.keys(patch).length > 0) row = { ...row, ...patch }
|
|
306
|
+
}
|
|
307
|
+
const bucketKey = pathKey ?? ''
|
|
308
|
+
const target = ws !== undefined ? buckets : extras
|
|
309
|
+
const list = target.get(ws !== undefined ? ws.path : bucketKey) ?? []
|
|
310
|
+
list.push(row)
|
|
311
|
+
target.set(ws !== undefined ? ws.path : bucketKey, list)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const sorted = (rows) => rows.slice().sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
|
315
|
+
const workspaces = (state.workspaces ?? []).map((ws) => (
|
|
316
|
+
{ ...ws, sessions: sorted(buckets.get(ws.path) ?? []) }
|
|
317
|
+
))
|
|
318
|
+
const ungrouped = []
|
|
319
|
+
for (const [key, rows] of extras) {
|
|
320
|
+
const known = (state.ungrouped ?? []).find((group) => normKey(group?.path) === key)
|
|
321
|
+
const path = known?.path ?? rows[0]?.cwd ?? undefined
|
|
322
|
+
ungrouped.push({
|
|
323
|
+
...(known ?? {
|
|
324
|
+
id: undefined,
|
|
325
|
+
path,
|
|
326
|
+
title: path !== undefined && path !== '' ? dirName(path) : '(没有目录)',
|
|
327
|
+
kind: 'directory',
|
|
328
|
+
configured: false,
|
|
329
|
+
exists: true,
|
|
330
|
+
}),
|
|
331
|
+
sessions: sorted(rows),
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
for (const group of (state.ungrouped ?? [])) {
|
|
335
|
+
if (!ungrouped.some((item) => item.path === group.path)) {
|
|
336
|
+
ungrouped.push({ ...group, sessions: sorted(buckets.get(group.path) ?? []) })
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return { ...state, workspaces, ungrouped, sessionSource: 'client' }
|
|
340
|
+
}
|
|
341
|
+
|
|
181
342
|
function modelText(session) {
|
|
182
343
|
if (typeof session.provider !== 'string' || session.provider === '') return '模型未知'
|
|
183
344
|
return `${session.provider} / ${session.model ?? '默认模型'}`
|
|
@@ -321,6 +482,16 @@ window.__ModuleLoader__.load({
|
|
|
321
482
|
|
|
322
483
|
useEffect(() => { refresh() }, [refresh])
|
|
323
484
|
|
|
485
|
+
// 浏览器端的会话列表(含标题、cwd)会随时变化:变了就重渲染。
|
|
486
|
+
const sessionStore = sessionsService(props?.sessions)
|
|
487
|
+
const [sessionTick, setSessionTick] = useState(0)
|
|
488
|
+
useEffect(() => {
|
|
489
|
+
const store = sessionStore?.list
|
|
490
|
+
if (store === undefined || store === null || typeof store.subscribe !== 'function') return undefined
|
|
491
|
+
const off = store.subscribe(() => setSessionTick((tick) => tick + 1))
|
|
492
|
+
return () => { if (typeof off === 'function') off() }
|
|
493
|
+
}, [sessionStore])
|
|
494
|
+
|
|
324
495
|
// 让覆盖层能立刻刷新面板(点「去配置」时面板可能已经挂载着)。
|
|
325
496
|
useEffect(() => {
|
|
326
497
|
bridge.refresh = refresh
|
|
@@ -417,12 +588,55 @@ window.__ModuleLoader__.load({
|
|
|
417
588
|
}
|
|
418
589
|
}
|
|
419
590
|
|
|
420
|
-
|
|
591
|
+
// 把浏览器端的会话列表并进宿主状态:宿主列表为空时(例如 list() 信封形状
|
|
592
|
+
// 不匹配)界面仍然能列出会话,并且会话标题来自浏览器端 projection。
|
|
593
|
+
const view = mergeSessions(state, sessionStore)
|
|
594
|
+
const workspaces = view?.workspaces ?? []
|
|
421
595
|
const grouped = workspaces.filter((ws) => ws.kind === 'workspace')
|
|
422
596
|
const orphans = workspaces.filter((ws) => ws.kind !== 'workspace')
|
|
423
|
-
const ungrouped =
|
|
424
|
-
const defaultRef =
|
|
425
|
-
const providers =
|
|
597
|
+
const ungrouped = view?.ungrouped ?? []
|
|
598
|
+
const defaultRef = view?.defaultRef
|
|
599
|
+
const providers = view?.providers ?? []
|
|
600
|
+
|
|
601
|
+
// ── 诊断 ──────────────────────────────────────────────────────────────
|
|
602
|
+
// 面板里「看不到可展开的会话」有两个完全不同的原因,修法也不同:
|
|
603
|
+
// 1) 宿主半边还是旧版本(只刷新了页面 / 没重启应用)→ 响应里没有 storeVersion;
|
|
604
|
+
// 2) 宿主是新版本,但会话列表服务不可用或返回 0 行 → 看 sessionProbe。
|
|
605
|
+
// 这里把两者都显式画出来,用户截一张图就能定位。
|
|
606
|
+
const probe = state?.sessionProbe
|
|
607
|
+
const hostIsStale = state !== null && state.storeVersion === undefined
|
|
608
|
+
const clientCount = clientSessionEntries(sessionStore).length
|
|
609
|
+
const sessionTotal = [...grouped, ...ungrouped].reduce(
|
|
610
|
+
(sum, ws) => sum + (Array.isArray(ws.sessions) ? ws.sessions.length : 0), 0)
|
|
611
|
+
|
|
612
|
+
const diagText = () => {
|
|
613
|
+
if (state === null) return undefined
|
|
614
|
+
const hostVersion = typeof state.pluginVersion === 'string'
|
|
615
|
+
? state.pluginVersion
|
|
616
|
+
: '旧版(未重启应用)'
|
|
617
|
+
const sessions = probe === undefined
|
|
618
|
+
? '未知'
|
|
619
|
+
: probe.available !== true
|
|
620
|
+
? '不可用'
|
|
621
|
+
: `${probe.count} 个${probe.shape !== undefined ? `(${probe.shape})` : ''}`
|
|
622
|
+
+ (probe.source === 'sessions' ? '(live 兜底)' : '')
|
|
623
|
+
const listError = probe?.error !== undefined ? ` · list 报错:${probe.error}` : ''
|
|
624
|
+
const noCwd = probe?.withoutCwd > 0 ? ` · ${probe.withoutCwd} 个没有 cwd` : ''
|
|
625
|
+
return `插件 v${hostVersion} · 状态存储 v${state.storeVersion ?? '—'}`
|
|
626
|
+
+ ` · 宿主会话列表:${sessions}${listError}${noCwd}`
|
|
627
|
+
+ ` · 浏览器会话列表:${clientCount} 个`
|
|
628
|
+
+ ` · 工作区 ${grouped.length} + 未分组 ${ungrouped.length} · 挂在目录下的会话 ${sessionTotal}`
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const diagSamples = () => {
|
|
632
|
+
if (state === null || probe === undefined) return null
|
|
633
|
+
const cwds = Array.isArray(probe.samples) ? probe.samples : []
|
|
634
|
+
const paths = Array.isArray(state.workspacePathSample) ? state.workspacePathSample : []
|
|
635
|
+
if (cwds.length === 0 && paths.length === 0) return null
|
|
636
|
+
return h('div', { className: 'wsk-hint', style: { marginTop: 4 } },
|
|
637
|
+
cwds.length > 0 ? h('div', null, `会话 cwd 样本:${cwds.join(' | ')}`) : null,
|
|
638
|
+
paths.length > 0 ? h('div', null, `工作区路径样本:${paths.join(' | ')}`) : null)
|
|
639
|
+
}
|
|
426
640
|
|
|
427
641
|
const providerOptions = (current, fallback) => {
|
|
428
642
|
const list = [...providers]
|
|
@@ -482,7 +696,9 @@ window.__ModuleLoader__.load({
|
|
|
482
696
|
type: 'button', className: 'wsk-btn',
|
|
483
697
|
onClick: () => setEditing(null),
|
|
484
698
|
}, '取消')),
|
|
485
|
-
h('div', { className: 'wsk-hint' },
|
|
699
|
+
h('div', { className: 'wsk-hint' }, target.ref === undefined || target.ref === null
|
|
700
|
+
? '凭据引用:保存时由宿主按这个会话/目录的 ID 生成,保存后这里会显示出来'
|
|
701
|
+
: `凭据引用:${target.ref}`))
|
|
486
702
|
}
|
|
487
703
|
|
|
488
704
|
const renderSession = (ws, session) => {
|
|
@@ -704,9 +920,31 @@ window.__ModuleLoader__.load({
|
|
|
704
920
|
`读取失败:${error}`,
|
|
705
921
|
h('div', { className: 'wsk-hint' }, '请确认 DSH Web 服务正在运行、且本插件的宿主半边已加载。'))
|
|
706
922
|
: null,
|
|
707
|
-
|
|
923
|
+
hostIsStale
|
|
924
|
+
? h('div', { className: 'wsk-msg bad', style: { marginBottom: 12 } },
|
|
925
|
+
'宿主端插件还是旧版本:所以看不到会话列表,也没有按会话配置。',
|
|
926
|
+
h('div', { className: 'wsk-hint' },
|
|
927
|
+
'请「完全退出」DSH Desktop 再重新打开(关闭窗口 / 刷新页面都不够:宿主代码只在启动时加载)。'),
|
|
928
|
+
h('div', { className: 'wsk-hint' }, diagText()))
|
|
929
|
+
: null,
|
|
930
|
+
state !== null && state.sessionListAvailable === false && clientCount === 0
|
|
931
|
+
? h('div', { className: 'wsk-msg info', style: { marginBottom: 12 } },
|
|
932
|
+
'当前 DSH 版本没有提供会话列表服务,只能按工作区配置 key。',
|
|
933
|
+
h('div', { className: 'wsk-hint' }, diagText()))
|
|
934
|
+
: null,
|
|
935
|
+
!hostIsStale && probe?.available === true && probe.count === 0 && clientCount === 0
|
|
936
|
+
? h('div', { className: 'wsk-msg info', style: { marginBottom: 12 } },
|
|
937
|
+
'宿主报告 0 个会话,浏览器端也没有拿到会话列表,所以没有可展开的列表。',
|
|
938
|
+
h('div', { className: 'wsk-hint' },
|
|
939
|
+
'只有「会话记录里带 cwd」的会话才能挂到工作区/未分组目录下。'),
|
|
940
|
+
h('div', { className: 'wsk-hint' }, diagText()),
|
|
941
|
+
diagSamples())
|
|
942
|
+
: null,
|
|
943
|
+
!hostIsStale && sessionTotal === 0 && clientCount === 0 && (probe?.count ?? 0) > 0
|
|
708
944
|
? h('div', { className: 'wsk-msg info', style: { marginBottom: 12 } },
|
|
709
|
-
'
|
|
945
|
+
'拿到会话了,但没有一个能对上工作区/目录(路径不匹配)。',
|
|
946
|
+
h('div', { className: 'wsk-hint' }, diagText()),
|
|
947
|
+
diagSamples())
|
|
710
948
|
: null,
|
|
711
949
|
|
|
712
950
|
h('div', { className: 'wsk-card' },
|
|
@@ -758,7 +996,8 @@ window.__ModuleLoader__.load({
|
|
|
758
996
|
|
|
759
997
|
h('div', { className: 'wsk-hint' },
|
|
760
998
|
'专属 key 存进 DSH 凭据库中的独立引用,因此换 key 不写进会话日志、也不影响历史回放。',
|
|
761
|
-
state?.statePath ? h('div', null, `状态文件:${state.statePath}`) : null
|
|
999
|
+
state?.statePath ? h('div', null, `状态文件:${state.statePath}`) : null,
|
|
1000
|
+
diagText() !== undefined ? h('div', { className: 'wsk-diag' }, diagText()) : null)),
|
|
762
1001
|
|
|
763
1002
|
renderPrompt())
|
|
764
1003
|
}
|
|
@@ -776,25 +1015,31 @@ window.__ModuleLoader__.load({
|
|
|
776
1015
|
if (sessions === undefined || sessions === null) return undefined
|
|
777
1016
|
|
|
778
1017
|
let disposed = false
|
|
779
|
-
const
|
|
1018
|
+
const currentSession = () => {
|
|
780
1019
|
try {
|
|
781
1020
|
const snapshot = sessions.list?.getSnapshot?.()
|
|
782
1021
|
const byId = snapshot?.byId ?? {}
|
|
783
1022
|
for (const [id, item] of Object.entries(byId)) {
|
|
784
|
-
if (Number(item?.retainedBy?.mainView) > 0) return id
|
|
1023
|
+
if (Number(item?.retainedBy?.mainView) > 0) return { id, cwd: item?.cwd }
|
|
785
1024
|
}
|
|
786
1025
|
} catch {
|
|
787
1026
|
/* 快照不可用时静默跳过 */
|
|
788
1027
|
}
|
|
789
1028
|
return undefined
|
|
790
1029
|
}
|
|
1030
|
+
const currentSessionId = () => currentSession()?.id
|
|
791
1031
|
|
|
792
1032
|
const evaluate = async () => {
|
|
793
|
-
const
|
|
1033
|
+
const current = currentSession()
|
|
1034
|
+
const sessionId = current?.id
|
|
794
1035
|
if (sessionId === undefined || asked.current.has(sessionId)) return
|
|
795
1036
|
asked.current.add(sessionId)
|
|
796
1037
|
try {
|
|
797
|
-
|
|
1038
|
+
// 一并带上 cwd:宿主拿到路径后,即使会话列表为空也能定位到工作区。
|
|
1039
|
+
const cwd = typeof current?.cwd === 'string' && current.cwd !== '' ? current.cwd : undefined
|
|
1040
|
+
const query = `/check?sessionId=${encodeURIComponent(sessionId)}`
|
|
1041
|
+
+ (cwd === undefined ? '' : `&path=${encodeURIComponent(cwd)}`)
|
|
1042
|
+
const res = await api(query)
|
|
798
1043
|
if (disposed) return
|
|
799
1044
|
if (res.needsAttention === true) setPrompt(res)
|
|
800
1045
|
} catch {
|
|
@@ -889,6 +1134,7 @@ window.__ModuleLoader__.load({
|
|
|
889
1134
|
ensureStyles()
|
|
890
1135
|
const slots = ctx.get('slots')
|
|
891
1136
|
if (slots === undefined || slots === null) return
|
|
1137
|
+
host.ctx = ctx
|
|
892
1138
|
|
|
893
1139
|
// 惰性取 layout:点击时才读,避免服务尚未就绪时把 undefined 缓存下来。
|
|
894
1140
|
const back = () => {
|
|
@@ -915,7 +1161,7 @@ window.__ModuleLoader__.load({
|
|
|
915
1161
|
slots.register({
|
|
916
1162
|
name: 'main',
|
|
917
1163
|
key: PANEL_ID,
|
|
918
|
-
inject: () => ({ back }),
|
|
1164
|
+
inject: () => ({ back, sessions: ctx.get('sessions') }),
|
|
919
1165
|
}, WorkspaceKeyPage)), 'workspace-api-key: main page')
|
|
920
1166
|
|
|
921
1167
|
ctx.effect(() => slots.inject('shell.overlay', () =>
|
package/lib/index.js
CHANGED
|
@@ -57,6 +57,20 @@ function log(...args) {
|
|
|
57
57
|
if (DEBUG) console.log('[workspace-api-key]', ...args)
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* 运行中的宿主半边版本(读自己的 package.json)。
|
|
62
|
+
* 界面把它显示出来是为了区分「宿主半边是旧版本(例如只刷新了页面而没重启应用)」
|
|
63
|
+
* 和「宿主是新版本、只是拿不到会话列表」——这两种情况的修法完全不同。
|
|
64
|
+
*/
|
|
65
|
+
const PLUGIN_VERSION = (() => {
|
|
66
|
+
try {
|
|
67
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
68
|
+
return typeof pkg?.version === 'string' ? pkg.version : undefined
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined
|
|
71
|
+
}
|
|
72
|
+
})()
|
|
73
|
+
|
|
60
74
|
// ─────────────────────────── 路径 / 工作区键 ───────────────────────────
|
|
61
75
|
|
|
62
76
|
/** 统一的路径比较用规范形:绝对化 + 分隔符统一 + Windows 折大小写。 */
|
|
@@ -188,9 +202,9 @@ function effectiveOverride(ctx, state, { sessionId, cwd }) {
|
|
|
188
202
|
return { level: 'default', ref: undefined, entry: undefined }
|
|
189
203
|
}
|
|
190
204
|
|
|
191
|
-
// ───────────────── 会话列表(宿主 sessionController
|
|
205
|
+
// ───────────────── 会话列表(宿主 sessionController + live 兜底) ─────────────────
|
|
192
206
|
|
|
193
|
-
/** 宿主是否挂载了会话列表 API
|
|
207
|
+
/** 宿主是否挂载了会话列表 API(仅诊断用;界面看 sessionProbe.available)。 */
|
|
194
208
|
function sessionListAvailable(ctx) {
|
|
195
209
|
try {
|
|
196
210
|
const controller = ctx.get('sessionController')
|
|
@@ -200,21 +214,93 @@ function sessionListAvailable(ctx) {
|
|
|
200
214
|
}
|
|
201
215
|
}
|
|
202
216
|
|
|
203
|
-
/**
|
|
204
|
-
|
|
217
|
+
/**
|
|
218
|
+
* 会话摘要 + 诊断信息。界面靠 available / count / error / 样本路径来解释
|
|
219
|
+
* 「为什么面板里看不到会话」:服务没挂载、list() 抛错、行里没有 cwd,还是
|
|
220
|
+
* 会话的 cwd 与工作区路径对不上。
|
|
221
|
+
*/
|
|
222
|
+
async function sessionProbe(ctx) {
|
|
223
|
+
let controller
|
|
224
|
+
let controllerError
|
|
205
225
|
try {
|
|
206
226
|
controller = ctx.get('sessionController')
|
|
207
|
-
} catch {
|
|
208
|
-
controller = undefined
|
|
209
|
-
}
|
|
210
|
-
if (controller === undefined || typeof controller.list !== 'function') return []
|
|
211
|
-
try {
|
|
212
|
-
const rows = await controller.list()
|
|
213
|
-
return Array.isArray(rows) ? rows : []
|
|
214
227
|
} catch (err) {
|
|
215
|
-
|
|
216
|
-
|
|
228
|
+
controllerError = String(err?.message ?? err)
|
|
229
|
+
}
|
|
230
|
+
if (controller !== undefined && typeof controller.list !== 'function') {
|
|
231
|
+
controllerError = 'sessionController.list is not a function'
|
|
232
|
+
}
|
|
233
|
+
let list = []
|
|
234
|
+
let shape
|
|
235
|
+
let probeError = controllerError
|
|
236
|
+
if (controller !== undefined && typeof controller.list === 'function') {
|
|
237
|
+
try {
|
|
238
|
+
// 宿主 SessionController.list(request, signal) 返回的是 { items: [...] } 信封,
|
|
239
|
+
// 不是数组(远端 remote.session.list 也是同一形状)。早期版本只认数组,
|
|
240
|
+
// 于是「信封」被当成空列表 —— 界面因此看不到任何会话。
|
|
241
|
+
// 这里同时容忍裸数组与小写 value.items(不同 DSH 版本的形状差异)。
|
|
242
|
+
const raw = await controller.list()
|
|
243
|
+
shape = Array.isArray(raw)
|
|
244
|
+
? 'array'
|
|
245
|
+
: raw === null || raw === undefined
|
|
246
|
+
? String(raw)
|
|
247
|
+
: typeof raw === 'object'
|
|
248
|
+
? 'object'
|
|
249
|
+
: typeof raw
|
|
250
|
+
list = Array.isArray(raw)
|
|
251
|
+
? raw
|
|
252
|
+
: Array.isArray(raw?.items)
|
|
253
|
+
? raw.items
|
|
254
|
+
: Array.isArray(raw?.value?.items)
|
|
255
|
+
? raw.value.items
|
|
256
|
+
: []
|
|
257
|
+
if (list.length === 0 && shape === 'object') {
|
|
258
|
+
probeError = `sessionController.list() 返回了 object(keys: ${Object.keys(raw).slice(0, 8).join(',') || '—'}),既不是数组也没有 items`
|
|
259
|
+
}
|
|
260
|
+
} catch (err) {
|
|
261
|
+
log('sessionController.list failed:', String(err?.message ?? err))
|
|
262
|
+
probeError = String(err?.message ?? err)
|
|
263
|
+
shape = 'throw'
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// sessionController 给不出任何会话时(服务缺失、list 抛错、列表为空……),
|
|
267
|
+
// 用宿主 live 会话服务兜底,让界面至少能列出「当前装载中的会话」。
|
|
268
|
+
let rows = list
|
|
269
|
+
let source = rows.length > 0 ? 'sessionController' : undefined
|
|
270
|
+
if (rows.length === 0) {
|
|
271
|
+
const live = liveSessionRows(ctx)
|
|
272
|
+
if (live.length > 0) {
|
|
273
|
+
rows = live
|
|
274
|
+
source = 'sessions'
|
|
275
|
+
log('sessionController gave no rows, falling back to sessions service:', live.length, 'rows')
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const samples = []
|
|
279
|
+
let withoutCwd = 0
|
|
280
|
+
for (const row of rows) {
|
|
281
|
+
const cwd = typeof row?.cwd === 'string' && row.cwd !== '' ? row.cwd : undefined
|
|
282
|
+
if (cwd === undefined) {
|
|
283
|
+
withoutCwd += 1
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
if (samples.length < 5 && !samples.includes(cwd)) samples.push(cwd)
|
|
217
287
|
}
|
|
288
|
+
log('sessionProbe:', rows.length, 'rows from', source ?? 'none', ',', withoutCwd, 'without cwd, shape', shape)
|
|
289
|
+
return {
|
|
290
|
+
available: source !== undefined,
|
|
291
|
+
rows,
|
|
292
|
+
count: rows.length,
|
|
293
|
+
withoutCwd,
|
|
294
|
+
samples,
|
|
295
|
+
shape,
|
|
296
|
+
source,
|
|
297
|
+
error: probeError,
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** 只要会话摘要(含冷会话)。服务缺失 / 出错时为空数组,界面退化成只列工作区。 */
|
|
302
|
+
async function sessionSummaries(ctx) {
|
|
303
|
+
return (await sessionProbe(ctx)).rows
|
|
218
304
|
}
|
|
219
305
|
|
|
220
306
|
/** 从摘要里取「当前会话在用的模型选择」,形状 {provider, model}。 */
|
|
@@ -242,6 +328,69 @@ function titleOf(row) {
|
|
|
242
328
|
return undefined
|
|
243
329
|
}
|
|
244
330
|
|
|
331
|
+
// ───────────────── 兜底:宿主 live 会话服务(ctx.get('sessions')) ─────────────────
|
|
332
|
+
// sessionController.list() 依赖 sessionQuery / 持久化绑定,某些情况下会返回空列表;
|
|
333
|
+
// 而 dsh-session 的 sessions 服务始终有「当前装载中的会话」。用它兜底能让
|
|
334
|
+
// /check(进入会话时的提醒)与 /state 在那种情况下仍然工作。
|
|
335
|
+
|
|
336
|
+
/** 单个 live 会话的 cwd 与当前模型选择。 */
|
|
337
|
+
function liveSessionInfo(ctx, sessionId) {
|
|
338
|
+
if (sessionId === undefined) return undefined
|
|
339
|
+
try {
|
|
340
|
+
const session = ctx.get('sessions')?.get?.(sessionId)
|
|
341
|
+
if (session === undefined || session === null) return undefined
|
|
342
|
+
const cwd = typeof session.header?.cwd === 'string' && session.header.cwd !== ''
|
|
343
|
+
? session.header.cwd
|
|
344
|
+
: undefined
|
|
345
|
+
let config
|
|
346
|
+
try {
|
|
347
|
+
config = session.requestHeader?.()?.config
|
|
348
|
+
} catch {
|
|
349
|
+
config = undefined
|
|
350
|
+
}
|
|
351
|
+
const provider = typeof config?.provider === 'string' && config.provider !== '' ? config.provider : undefined
|
|
352
|
+
const model = typeof config?.model === 'string' && config.model !== '' ? config.model : undefined
|
|
353
|
+
const selection = provider === undefined && model === undefined ? undefined : { provider, model }
|
|
354
|
+
if (cwd === undefined && selection === undefined) return undefined
|
|
355
|
+
return { cwd, selection }
|
|
356
|
+
} catch {
|
|
357
|
+
return undefined
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** 用 live 会话服务凑出与 sessionController 行兼容的摘要(仅在没有其它来源时使用)。 */
|
|
362
|
+
function liveSessionRows(ctx) {
|
|
363
|
+
let sessions = []
|
|
364
|
+
try {
|
|
365
|
+
const service = ctx.get('sessions')
|
|
366
|
+
if (service === undefined || typeof service.list !== 'function') return []
|
|
367
|
+
sessions = service.list() ?? []
|
|
368
|
+
} catch {
|
|
369
|
+
return []
|
|
370
|
+
}
|
|
371
|
+
const rows = []
|
|
372
|
+
for (const session of sessions) {
|
|
373
|
+
const sessionId = session?.id ?? session?.header?.id
|
|
374
|
+
if (typeof sessionId !== 'string' || sessionId === '') continue
|
|
375
|
+
// 子代理会话不作为可配置项列出(与 sessionController 的 remote 列表不同,
|
|
376
|
+
// live 会话服务包含子代理会话)。
|
|
377
|
+
const origin = session?.header?.origin
|
|
378
|
+
if (origin === 'subagent' || session?.header?.parentSession !== undefined) continue
|
|
379
|
+
const info = liveSessionInfo(ctx, sessionId)
|
|
380
|
+
const values = {}
|
|
381
|
+
if (info?.selection !== undefined) values.modelSelection = { ...info.selection }
|
|
382
|
+
rows.push({
|
|
383
|
+
sessionId,
|
|
384
|
+
cwd: info?.cwd,
|
|
385
|
+
updatedAt: 0,
|
|
386
|
+
running: session?.getSnapshot?.()?.running === true,
|
|
387
|
+
blank: false,
|
|
388
|
+
projections: Object.keys(values).length === 0 ? undefined : { values },
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
return rows
|
|
392
|
+
}
|
|
393
|
+
|
|
245
394
|
/** 所有 provider 路由名(用于「这把 key 是给哪个 provider 的」选择项)。 */
|
|
246
395
|
function providerList(ctx) {
|
|
247
396
|
const names = new Set(['deepseek-official'])
|
|
@@ -895,7 +1044,8 @@ async function snapshot(ctx, state) {
|
|
|
895
1044
|
const defaultRefName = 'DEEPSEEK_API_KEY'
|
|
896
1045
|
const defaultInfo = await describeKey(ctx, defaultRefName)
|
|
897
1046
|
const defaultHit = await resolveKey(ctx, defaultRefName)
|
|
898
|
-
const
|
|
1047
|
+
const probe = await sessionProbe(ctx)
|
|
1048
|
+
const rows = probe.rows
|
|
899
1049
|
const byCwd = new Map()
|
|
900
1050
|
for (const row of rows) {
|
|
901
1051
|
const norm = normPath(row?.cwd)
|
|
@@ -977,7 +1127,18 @@ async function snapshot(ctx, state) {
|
|
|
977
1127
|
|
|
978
1128
|
return {
|
|
979
1129
|
ok: true,
|
|
1130
|
+
pluginVersion: PLUGIN_VERSION,
|
|
980
1131
|
storeVersion: STATE_VERSION,
|
|
1132
|
+
sessionProbe: {
|
|
1133
|
+
available: probe.available,
|
|
1134
|
+
count: probe.count,
|
|
1135
|
+
withoutCwd: probe.withoutCwd ?? 0,
|
|
1136
|
+
error: probe.error,
|
|
1137
|
+
samples: probe.samples ?? [],
|
|
1138
|
+
shape: probe.shape,
|
|
1139
|
+
source: probe.source,
|
|
1140
|
+
},
|
|
1141
|
+
workspacePathSample: workspaces.slice(0, 5).map((ws) => ws.path),
|
|
981
1142
|
defaultRef: {
|
|
982
1143
|
ref: defaultRefName,
|
|
983
1144
|
configured: defaultInfo.configured === true,
|
|
@@ -990,7 +1151,8 @@ async function snapshot(ctx, state) {
|
|
|
990
1151
|
interceptableRefs: [...interceptableRefs(ctx, state)],
|
|
991
1152
|
workspaceCount: workspaceIndex(ctx).size,
|
|
992
1153
|
credentialsAvailable: credentials !== undefined,
|
|
993
|
-
sessionListAvailable:
|
|
1154
|
+
sessionListAvailable: probe.available,
|
|
1155
|
+
sessionControllerAvailable: sessionListAvailable(ctx),
|
|
994
1156
|
statePath: stateFilePath(),
|
|
995
1157
|
workspaces,
|
|
996
1158
|
ungrouped,
|
|
@@ -1051,6 +1213,13 @@ async function checkSession(ctx, state, params) {
|
|
|
1051
1213
|
title = titleOf(row)
|
|
1052
1214
|
}
|
|
1053
1215
|
}
|
|
1216
|
+
// 会话没出现在宿主列表里时(sessionController 空列表、冷会话未装载等),
|
|
1217
|
+
// 用 live 会话服务补 cwd 与模型选择 —— 否则进入会话时的提醒会静默失效。
|
|
1218
|
+
if (sid !== undefined && (cwd === undefined || selection === undefined)) {
|
|
1219
|
+
const live = liveSessionInfo(ctx, sid)
|
|
1220
|
+
if (cwd === undefined && live?.cwd !== undefined) cwd = live.cwd
|
|
1221
|
+
if (selection === undefined && live?.selection !== undefined) selection = live.selection
|
|
1222
|
+
}
|
|
1054
1223
|
if (cwd === undefined) cwd = activeCwd(ctx)
|
|
1055
1224
|
const eff = effectiveOverride(ctx, state, { sessionId: sid, cwd })
|
|
1056
1225
|
const hit = eff.ref !== undefined ? await resolveKey(ctx, eff.ref) : await resolveKey(ctx, 'DEEPSEEK_API_KEY')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-workspace-api-key",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "A DeepSeek Harness plugin that binds a separate DeepSeek API key to each workspace, or to a single session, so the spend of one key stays with the sessions you choose.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"homepage": "https://github.com/COH2357/dsh-workspace-api-key#readme",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"dsh",
|
|
18
|
+
"dsh-plugin",
|
|
18
19
|
"deepseek-harness",
|
|
19
20
|
"plugin",
|
|
20
21
|
"api-key",
|