dsh-workspace-api-key 0.2.4 → 0.2.6

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
@@ -31,8 +31,8 @@ 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()`, 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;
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. It hides the two kinds of session the sidebar hides: subagent sessions (`origin: 'subagent'`, or a `parentSession`) and *blank* sessions (created but never prompted — the sidebar shows only the one currently open). Without those two filters one workspace with a single real session listed thirteen;
35
+ - reports its own version and a session probe (`available`, `shape`, `source`, `count`, `withoutCwd`, `subagents`, `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;
36
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`;
37
37
  - observes failures through the `agent/request-error` waterfall without ever returning `{kind: 'retry'}` — it only records the verdict and calls `next()`.
38
38
 
@@ -71,11 +71,11 @@ Then add `"dsh-workspace-api-key"` to `dsh.profile.bundles` in that profile's `p
71
71
  ## Tests
72
72
 
73
73
  ```sh
74
- node test/host.test.mjs # 125 assertions
75
- node test/client.test.mjs # 130 assertions
74
+ node test/host.test.mjs # 130 assertions
75
+ node test/client.test.mjs # 139 assertions
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`, `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
+ 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, the live-session fallback, and subagent and blank sessions being hidden. 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.
79
79
 
80
80
  ## Requirements and limits
81
81
 
package/README.zh.md CHANGED
@@ -31,8 +31,8 @@ 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()`,会拆开它 `{items: […]}` 的信封),控件缺失 / 抛错 / 列表为空时再退到宿主 live 会话服务(`ctx.get('sessions').list()`),所以那种情况下面板仍能列出会话(该兜底会过滤掉子代理会话);
35
- - `/wsk-api/state` 会带上插件自身版本与会话探针(`available`、`shape`、`source`、`count`、`withoutCwd`、`samples`、`error`,以及工作区路径样本),面板据此说明**为什么**没有列出会话:宿主半边是旧版本(只刷新了页面而没重启应用)、没有 `sessionController`、`list()` 抛错、信封形状不认识,还是会话的 `cwd` 与任何工作区路径都对不上;
34
+ - 会话列表来自 `sessionController` 服务(`list()`,会拆开它 `{items: […]}` 的信封),控件缺失 / 抛错 / 列表为空时再退到宿主 live 会话服务(`ctx.get('sessions').list()`),所以那种情况下面板仍能列出会话;侧栏藏起来的两种会话这里也藏:子代理会话(`origin: 'subagent'` 或带 `parentSession`)和**空会话**(建了但一句话都没发过,侧栏只显示当前开着的那个)。两种都不过滤的话,一个只有 1 个真实会话的工作区会列出 13 个;
35
+ - `/wsk-api/state` 会带上插件自身版本与会话探针(`available`、`shape`、`source`、`count`、`withoutCwd`、`subagents`、`samples`、`error`,以及工作区路径样本),面板据此说明**为什么**没有列出会话:宿主半边是旧版本(只刷新了页面而没重启应用)、没有 `sessionController`、`list()` 抛错、信封形状不认识,还是会话的 `cwd` 与任何工作区路径都对不上;
36
36
  - 自己的账本(哪一层对应哪个 ref、失效标记、key 是给哪个服务商记的)落在 `$DSH_HOME/storages/dsh-workspace-api-key.json`;
37
37
  - 通过 `agent/request-error` waterfall 观察失败,但**从不**返回 `{kind: 'retry'}`——只记录判定结果并 `next()` 放行。
38
38
 
@@ -71,11 +71,11 @@ pnpm add "dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key"
71
71
  ## 测试
72
72
 
73
73
  ```sh
74
- node test/host.test.mjs # 125 条断言
75
- node test/client.test.mjs # 130 条断言
74
+ node test/host.test.mjs # 130 条断言
75
+ node test/client.test.mjs # 139 条断言
76
76
  ```
77
77
 
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
+ 两个套件都不需要启动 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 之后才可用时返回键仍然有效。
79
79
 
80
80
  ## 要求与限制
81
81
 
package/lib/client.js CHANGED
@@ -236,6 +236,9 @@ window.__ModuleLoader__.load({
236
236
  : typeof item.title === 'string' && item.title !== '' ? item.title : undefined,
237
237
  updatedAt: typeof item.updatedAt === 'number' ? item.updatedAt : 0,
238
238
  running: item.running === true,
239
+ // 还没发过消息的「空会话」:侧栏只显示当前选中的那个,其余都藏起来。
240
+ blank: item.blank === true,
241
+ current: ((item.retainedBy?.mainView ?? 0) > 0),
239
242
  })
240
243
  }
241
244
  return entries
@@ -246,14 +249,49 @@ window.__ModuleLoader__.load({
246
249
  * 宿主行始终保留(不会被浏览器端列表挤掉),浏览器端额外只做两件事:
247
250
  * 给同名会话补标题/cwd,以及把宿主漏掉的会话补进来(层级从 /state 的
248
251
  * 覆盖记录推出)。这样宿主列表为空时面板照样有会话可列。
252
+ * 侧栏还藏两种会话,这里跟着藏:子代理会话(跟随父会话)和「空会话」
253
+ * ——后者是建了但一句话都没发过的会话,侧栏只显示当前选中的那一个。
249
254
  */
250
255
  function mergeSessions(state, sessions) {
251
- const entries = clientSessionEntries(sessions)
252
- if (state === null || state === undefined || entries.length === 0) return state
256
+ const allEntries = clientSessionEntries(sessions)
257
+ if (state === null || state === undefined) return state
258
+ const isTopLevel = (row) => row?.parentSessionId === undefined && row?.origin !== 'subagent'
259
+ /** 当前正开着的空会话:侧栏会把它当「新会话」显示,所以也保留。 */
260
+ const currentIds = new Set(allEntries.filter((entry) => entry.current).map((entry) => entry.sessionId))
261
+ const entries = allEntries.filter((entry) => !entry.blank || entry.current)
262
+ const isVisible = (row) => row?.sessionId !== undefined
263
+ ? isTopLevel(row) && (row.blank !== true || currentIds.has(row.sessionId))
264
+ : isTopLevel(row)
265
+ // 诊断用:被藏起来的空会话按 id 去重(同一个会话可能同时出现在两侧)。
266
+ const blankIds = new Set()
267
+ for (const entry of allEntries) {
268
+ if (entry.blank && !entry.current) blankIds.add(entry.sessionId)
269
+ }
270
+ for (const group of [...(state.workspaces ?? []), ...(state.ungrouped ?? [])]) {
271
+ let index = 0
272
+ for (const row of (Array.isArray(group?.sessions) ? group.sessions : [])) {
273
+ index += 1
274
+ if (!isTopLevel(row) || row?.blank !== true || currentIds.has(row?.sessionId)) continue
275
+ blankIds.add(row?.sessionId ?? `${group?.path ?? ''}#${index}`)
276
+ }
277
+ }
278
+ const blankSkipped = blankIds.size
279
+ if (entries.length === 0) {
280
+ // 没有浏览器端列表时也把子代理/空会话滤掉(宿主新版本已经过滤,这里兜旧宿主)。
281
+ const clean = (rows) => (Array.isArray(rows) ? rows : []).filter(isVisible)
282
+ return {
283
+ ...state,
284
+ workspaces: (state.workspaces ?? []).map((ws) => ({ ...ws, sessions: clean(ws.sessions) })),
285
+ ungrouped: (state.ungrouped ?? []).map((group) => ({ ...group, sessions: clean(group.sessions) })),
286
+ sessionBlanks: blankSkipped,
287
+ }
288
+ }
253
289
 
254
290
  const hostRows = new Map()
255
291
  for (const group of [...(state.workspaces ?? []), ...(state.ungrouped ?? [])]) {
256
292
  for (const row of (Array.isArray(group?.sessions) ? group.sessions : [])) {
293
+ // 子代理会话不是独立会话(宿主新版本已经过滤,这里对旧宿主再兜一层)。
294
+ if (!isTopLevel(row)) continue
257
295
  if (row?.sessionId !== undefined) hostRows.set(row.sessionId, row)
258
296
  }
259
297
  }
@@ -317,6 +355,7 @@ window.__ModuleLoader__.load({
317
355
  }
318
356
 
319
357
  const fill = (rows) => (Array.isArray(rows) ? rows : [])
358
+ .filter(isVisible)
320
359
  .map((row) => enriched.get(row?.sessionId) ?? row)
321
360
 
322
361
  // 第二遍:宿主结构照旧,浏览器端多出来的会话按 cwd 归档。
@@ -356,7 +395,7 @@ window.__ModuleLoader__.load({
356
395
  sessions: sortByUpdated(rows),
357
396
  })
358
397
  }
359
- return { ...state, workspaces, ungrouped, sessionSource: 'client' }
398
+ return { ...state, workspaces, ungrouped, sessionSource: 'client', sessionBlanks: blankSkipped }
360
399
  }
361
400
 
362
401
  function modelText(session) {
@@ -644,7 +683,8 @@ window.__ModuleLoader__.load({
644
683
  // 这里把两者都显式画出来,用户截一张图就能定位。
645
684
  const probe = state?.sessionProbe
646
685
  const hostIsStale = state !== null && state.storeVersion === undefined
647
- const clientCount = clientSessionEntries(sessionStore).length
686
+ const clientCount = clientSessionEntries(sessionStore)
687
+ .filter((entry) => !entry.blank || entry.current).length
648
688
  const sessionTotal = [...grouped, ...ungrouped].reduce(
649
689
  (sum, ws) => sum + (Array.isArray(ws.sessions) ? ws.sessions.length : 0), 0)
650
690
 
@@ -661,9 +701,11 @@ window.__ModuleLoader__.load({
661
701
  + (probe.source === 'sessions' ? '(live 兜底)' : '')
662
702
  const listError = probe?.error !== undefined ? ` · list 报错:${probe.error}` : ''
663
703
  const noCwd = probe?.withoutCwd > 0 ? ` · ${probe.withoutCwd} 个没有 cwd` : ''
704
+ const subs = probe?.subagents > 0 ? ` · 已跳过 ${probe.subagents} 个子代理会话` : ''
705
+ const blanks = (view?.sessionBlanks ?? 0) > 0 ? ` · 已跳过 ${view.sessionBlanks} 个空会话` : ''
664
706
  return `插件 v${hostVersion} · 状态存储 v${state.storeVersion ?? '—'}`
665
- + ` · 宿主会话列表:${sessions}${listError}${noCwd}`
666
- + ` · 浏览器会话列表:${clientCount} 个`
707
+ + ` · 宿主会话列表:${sessions}${listError}${noCwd}${subs}`
708
+ + ` · 浏览器会话列表:${clientCount} 个${blanks}`
667
709
  + ` · 工作区 ${grouped.length} + 未分组 ${ungrouped.length} · 挂在目录下的会话 ${sessionTotal}`
668
710
  }
669
711
 
package/lib/index.js CHANGED
@@ -263,9 +263,16 @@ async function sessionProbe(ctx) {
263
263
  shape = 'throw'
264
264
  }
265
265
  }
266
+ // 子代理会话(origin: 'subagent' / 带 parentSession)也在这份列表里,
267
+ // 但侧栏不把它们当独立会话显示(它们是某个会话派生出来的)。
268
+ // 一并过滤,否则一个工作区会冒出几十个「会话」(用户实测过 13 个里 12 个是子代理)。
269
+ const isSubagentRow = (row) => row?.parentSessionId !== undefined || row?.origin === 'subagent'
270
+ const allRows = list
271
+ const visibleRows = allRows.filter((row) => !isSubagentRow(row))
272
+ const skippedSubagents = allRows.length - visibleRows.length
266
273
  // sessionController 给不出任何会话时(服务缺失、list 抛错、列表为空……),
267
274
  // 用宿主 live 会话服务兜底,让界面至少能列出「当前装载中的会话」。
268
- let rows = list
275
+ let rows = visibleRows
269
276
  let source = rows.length > 0 ? 'sessionController' : undefined
270
277
  if (rows.length === 0) {
271
278
  const live = liveSessionRows(ctx)
@@ -285,12 +292,14 @@ async function sessionProbe(ctx) {
285
292
  }
286
293
  if (samples.length < 5 && !samples.includes(cwd)) samples.push(cwd)
287
294
  }
288
- log('sessionProbe:', rows.length, 'rows from', source ?? 'none', ',', withoutCwd, 'without cwd, shape', shape)
295
+ log('sessionProbe:', rows.length, 'rows from', source ?? 'none', ',', withoutCwd, 'without cwd, shape', shape,
296
+ ',', skippedSubagents, 'subagent rows skipped')
289
297
  return {
290
298
  available: source !== undefined,
291
299
  rows,
292
300
  count: rows.length,
293
301
  withoutCwd,
302
+ subagents: skippedSubagents,
294
303
  samples,
295
304
  shape,
296
305
  source,
@@ -1133,6 +1142,7 @@ async function snapshot(ctx, state) {
1133
1142
  available: probe.available,
1134
1143
  count: probe.count,
1135
1144
  withoutCwd: probe.withoutCwd ?? 0,
1145
+ subagents: probe.subagents ?? 0,
1136
1146
  error: probe.error,
1137
1147
  samples: probe.samples ?? [],
1138
1148
  shape: probe.shape,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-workspace-api-key",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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",