dsh-workspace-api-key 0.2.0 → 0.2.1

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
@@ -32,6 +32,7 @@ Host half (`lib/index.js`):
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
34
  - builds the session list from the `sessionController` service (`list()`), so the panel shows the same titles and model projections the sidebar does, and degrades to per-workspace only when that service is absent;
35
+ - reports its own version and a session probe (`available`, `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()`, 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
 
@@ -41,7 +42,7 @@ The browser half (`lib/client.js`) registers into the `sidebar.panellist` slot (
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,8 +71,8 @@ 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 # 94 assertions
74
- node test/client.test.mjs # 94 assertions
74
+ node test/host.test.mjs # 111 assertions
75
+ node test/client.test.mjs # 106 assertions
75
76
  ```
76
77
 
77
78
  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 state persistence. 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, 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.
@@ -85,6 +86,7 @@ Neither suite needs a running DSH process. The host suite drives `apply()` again
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, when none of them match, sample session `cwd` values next to sample workspace paths.
88
90
 
89
91
  ## License
90
92
 
package/README.zh.md CHANGED
@@ -32,6 +32,7 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
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
34
  - 会话列表来自 `sessionController` 服务(`list()`),所以面板显示的标题与模型投影和侧栏一致;该服务缺失时自动降级为只能按工作区配置;
35
+ - `/wsk-api/state` 会带上插件自身版本与会话探针(`available`、`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
 
@@ -70,8 +71,8 @@ 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 # 94 条断言
74
- node test/client.test.mjs # 94 条断言
74
+ node test/host.test.mjs # 111 条断言
75
+ node test/client.test.mjs # 106 条断言
75
76
  ```
76
77
 
77
78
  两个套件都不需要启动 DSH。宿主套件用一个假 ctx(假的 `credentials`、`workspaceRegistry`、`agents`、`sessionController`、`webServer`,以及打桩的 `fetch`)驱动 `apply()`,覆盖 ref 派生、会话→工作区→默认的级联、工作区匹配、把第二个适配器的 `apiKeyEnv` 一起重定向、清回默认、401/402/429/5xx 的判定、回环与来源校验、状态落盘。浏览器套件通过一个极小的 `window.__ModuleLoader__` 与自制 React 运行时加载 `lib/client.js`,渲染出面板与覆盖层,断言槽注册、首屏渲染、会话折叠/展开、会话级保存与测试、未分组目录卡片、失效与不匹配提醒、「去配置」的交接、没有会话列表时的降级,以及 `ctx.get('layout')` 在插件 apply 之后才可用时返回键仍然有效。
@@ -85,6 +86,7 @@ node test/client.test.mjs # 94 条断言
85
86
  - key 通过凭据服务存放在 `$DSH_HOME/.credentials.yaml` 的 `refs:` 里,明文,和默认 key 完全一样。它不会被写进会话日志、插件状态文件或插件目录。
86
87
  - 匹配是规范化之后的文本比较。如果某个会话的 `cwd` 是指向已注册工作区路径的符号链接或 junction,则匹配不上,会走上层层级。
87
88
  - 卸载插件不会删除已生成的 `*_WS_*` / `*_SS_*` ref,可在「设置 → 模型」里清掉。
89
+ - 如果面板列出了工作区却没有可展开的会话,请看面板底部的诊断行。出现红色「宿主端插件还是旧版本」横幅,说明 DSH 进程没有重启过:宿主半边只在启动时加载,关窗口和刷新页面都不算——请完全退出 DSH Desktop 再打开。其余情况诊断行会写明宿主看到多少个会话;一个都对不上时,还会并排给出会话 `cwd` 样本与工作区路径样本,便于比对。
88
90
 
89
91
  ## 许可
90
92
 
package/lib/client.js CHANGED
@@ -68,6 +68,7 @@ window.__ModuleLoader__.load({
68
68
  background: transparent; color: inherit; font: inherit; font-size: 12px; }
69
69
  .wsk-input:focus { outline: 2px solid color-mix(in srgb, #2563eb 45%, transparent); outline-offset: -1px; }
70
70
  .wsk-hint { color: var(--wsk-dim); font-size: 11px; line-height: 1.6; }
71
+ .wsk-diag { color: var(--wsk-dim); font-size: 10px; line-height: 1.6; margin-top: 4px; word-break: break-all; }
71
72
  .wsk-msg { padding: 7px 10px; border-radius: 6px; font-size: 12px; line-height: 1.5; }
72
73
  .wsk-msg.ok { background: color-mix(in srgb, var(--wsk-ok) 12%, transparent); color: var(--wsk-ok); }
73
74
  .wsk-msg.bad { background: color-mix(in srgb, var(--wsk-bad) 12%, transparent); color: var(--wsk-bad); }
@@ -424,6 +425,42 @@ window.__ModuleLoader__.load({
424
425
  const defaultRef = state?.defaultRef
425
426
  const providers = state?.providers ?? []
426
427
 
428
+ // ── 诊断 ──────────────────────────────────────────────────────────────
429
+ // 面板里「看不到可展开的会话」有两个完全不同的原因,修法也不同:
430
+ // 1) 宿主半边还是旧版本(只刷新了页面 / 没重启应用)→ 响应里没有 storeVersion;
431
+ // 2) 宿主是新版本,但会话列表服务不可用或返回 0 行 → 看 sessionProbe。
432
+ // 这里把两者都显式画出来,用户截一张图就能定位。
433
+ const probe = state?.sessionProbe
434
+ const hostIsStale = state !== null && state.storeVersion === undefined
435
+ const sessionTotal = [...grouped, ...ungrouped].reduce(
436
+ (sum, ws) => sum + (Array.isArray(ws.sessions) ? ws.sessions.length : 0), 0)
437
+
438
+ const diagText = () => {
439
+ if (state === null) return undefined
440
+ const hostVersion = typeof state.pluginVersion === 'string'
441
+ ? state.pluginVersion
442
+ : '旧版(未重启应用)'
443
+ const sessions = probe === undefined
444
+ ? '未知'
445
+ : probe.available !== true
446
+ ? '不可用'
447
+ : `${probe.count} 个`
448
+ const listError = probe?.error !== undefined ? ` · list 报错:${probe.error}` : ''
449
+ const noCwd = probe?.withoutCwd > 0 ? ` · ${probe.withoutCwd} 个没有 cwd` : ''
450
+ return `插件 v${hostVersion} · 状态存储 v${state.storeVersion ?? '—'} · 会话列表:${sessions}${listError}${noCwd}`
451
+ + ` · 工作区 ${grouped.length} + 未分组 ${ungrouped.length} · 挂在目录下的会话 ${sessionTotal}`
452
+ }
453
+
454
+ const diagSamples = () => {
455
+ if (state === null || probe === undefined) return null
456
+ const cwds = Array.isArray(probe.samples) ? probe.samples : []
457
+ const paths = Array.isArray(state.workspacePathSample) ? state.workspacePathSample : []
458
+ if (cwds.length === 0 && paths.length === 0) return null
459
+ return h('div', { className: 'wsk-hint', style: { marginTop: 4 } },
460
+ cwds.length > 0 ? h('div', null, `会话 cwd 样本:${cwds.join(' | ')}`) : null,
461
+ paths.length > 0 ? h('div', null, `工作区路径样本:${paths.join(' | ')}`) : null)
462
+ }
463
+
427
464
  const providerOptions = (current, fallback) => {
428
465
  const list = [...providers]
429
466
  for (const candidate of [current, fallback]) {
@@ -704,9 +741,31 @@ window.__ModuleLoader__.load({
704
741
  `读取失败:${error}`,
705
742
  h('div', { className: 'wsk-hint' }, '请确认 DSH Web 服务正在运行、且本插件的宿主半边已加载。'))
706
743
  : null,
744
+ hostIsStale
745
+ ? h('div', { className: 'wsk-msg bad', style: { marginBottom: 12 } },
746
+ '宿主端插件还是旧版本:所以看不到会话列表,也没有按会话配置。',
747
+ h('div', { className: 'wsk-hint' },
748
+ '请「完全退出」DSH Desktop 再重新打开(关闭窗口 / 刷新页面都不够:宿主代码只在启动时加载)。'),
749
+ h('div', { className: 'wsk-hint' }, diagText()))
750
+ : null,
707
751
  state !== null && state.sessionListAvailable === false
708
752
  ? h('div', { className: 'wsk-msg info', style: { marginBottom: 12 } },
709
- '当前 DSH 版本没有提供会话列表服务,只能按工作区配置 key。')
753
+ '当前 DSH 版本没有提供会话列表服务,只能按工作区配置 key。',
754
+ h('div', { className: 'wsk-hint' }, diagText()))
755
+ : null,
756
+ !hostIsStale && probe?.available === true && probe.count === 0
757
+ ? h('div', { className: 'wsk-msg info', style: { marginBottom: 12 } },
758
+ '宿主报告 0 个会话,所以没有可展开的列表。',
759
+ h('div', { className: 'wsk-hint' },
760
+ '只有「会话记录里带 cwd」的会话才能挂到工作区/未分组目录下。'),
761
+ h('div', { className: 'wsk-hint' }, diagText()),
762
+ diagSamples())
763
+ : null,
764
+ !hostIsStale && (probe?.count ?? 0) > 0 && sessionTotal === 0
765
+ ? h('div', { className: 'wsk-msg info', style: { marginBottom: 12 } },
766
+ '拿到会话了,但没有一个能对上工作区/目录(路径不匹配)。',
767
+ h('div', { className: 'wsk-hint' }, diagText()),
768
+ diagSamples())
710
769
  : null,
711
770
 
712
771
  h('div', { className: 'wsk-card' },
@@ -758,7 +817,8 @@ window.__ModuleLoader__.load({
758
817
 
759
818
  h('div', { className: 'wsk-hint' },
760
819
  '专属 key 存进 DSH 凭据库中的独立引用,因此换 key 不写进会话日志、也不影响历史回放。',
761
- state?.statePath ? h('div', null, `状态文件:${state.statePath}`) : null)),
820
+ state?.statePath ? h('div', null, `状态文件:${state.statePath}`) : null,
821
+ diagText() !== undefined ? h('div', { className: 'wsk-diag' }, diagText()) : null)),
762
822
 
763
823
  renderPrompt())
764
824
  }
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 折大小写。 */
@@ -200,23 +214,48 @@ function sessionListAvailable(ctx) {
200
214
  }
201
215
  }
202
216
 
203
- /** 全部会话摘要(含冷会话)。服务缺失时返回空数组,界面退化成只列工作区。 */
204
- async function sessionSummaries(ctx) { let controller
217
+ /**
218
+ * 会话摘要 + 诊断信息。界面靠 available / count / error / 样本路径来解释
219
+ * 「为什么面板里看不到会话」:服务没挂载、list() 抛错、行里没有 cwd,还是
220
+ * 会话的 cwd 与工作区路径对不上。
221
+ */
222
+ async function sessionProbe(ctx) {
223
+ let controller
205
224
  try {
206
225
  controller = ctx.get('sessionController')
207
- } catch {
208
- controller = undefined
226
+ } catch (err) {
227
+ return { available: false, rows: [], count: 0, error: String(err?.message ?? err) }
228
+ }
229
+ if (controller === undefined) return { available: false, rows: [], count: 0 }
230
+ if (typeof controller.list !== 'function') {
231
+ return { available: false, rows: [], count: 0, error: 'sessionController.list is not a function' }
209
232
  }
210
- if (controller === undefined || typeof controller.list !== 'function') return []
211
233
  try {
212
234
  const rows = await controller.list()
213
- return Array.isArray(rows) ? rows : []
235
+ const list = Array.isArray(rows) ? rows : []
236
+ const samples = []
237
+ let withoutCwd = 0
238
+ for (const row of list) {
239
+ const cwd = typeof row?.cwd === 'string' && row.cwd !== '' ? row.cwd : undefined
240
+ if (cwd === undefined) {
241
+ withoutCwd += 1
242
+ continue
243
+ }
244
+ if (samples.length < 5 && !samples.includes(cwd)) samples.push(cwd)
245
+ }
246
+ log('sessionController.list ok:', list.length, 'rows,', withoutCwd, 'without cwd')
247
+ return { available: true, rows: list, count: list.length, withoutCwd, samples }
214
248
  } catch (err) {
215
249
  log('sessionController.list failed:', String(err?.message ?? err))
216
- return []
250
+ return { available: true, rows: [], count: 0, error: String(err?.message ?? err) }
217
251
  }
218
252
  }
219
253
 
254
+ /** 只要会话摘要(含冷会话)。服务缺失 / 出错时为空数组,界面退化成只列工作区。 */
255
+ async function sessionSummaries(ctx) {
256
+ return (await sessionProbe(ctx)).rows
257
+ }
258
+
220
259
  /** 从摘要里取「当前会话在用的模型选择」,形状 {provider, model}。 */
221
260
  function modelSelectionOf(row) {
222
261
  const values = row?.projections?.values
@@ -895,7 +934,8 @@ async function snapshot(ctx, state) {
895
934
  const defaultRefName = 'DEEPSEEK_API_KEY'
896
935
  const defaultInfo = await describeKey(ctx, defaultRefName)
897
936
  const defaultHit = await resolveKey(ctx, defaultRefName)
898
- const rows = await sessionSummaries(ctx)
937
+ const probe = await sessionProbe(ctx)
938
+ const rows = probe.rows
899
939
  const byCwd = new Map()
900
940
  for (const row of rows) {
901
941
  const norm = normPath(row?.cwd)
@@ -977,7 +1017,16 @@ async function snapshot(ctx, state) {
977
1017
 
978
1018
  return {
979
1019
  ok: true,
1020
+ pluginVersion: PLUGIN_VERSION,
980
1021
  storeVersion: STATE_VERSION,
1022
+ sessionProbe: {
1023
+ available: probe.available,
1024
+ count: probe.count,
1025
+ withoutCwd: probe.withoutCwd ?? 0,
1026
+ error: probe.error,
1027
+ samples: probe.samples ?? [],
1028
+ },
1029
+ workspacePathSample: workspaces.slice(0, 5).map((ws) => ws.path),
981
1030
  defaultRef: {
982
1031
  ref: defaultRefName,
983
1032
  configured: defaultInfo.configured === true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-workspace-api-key",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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",