dsh-workspace-api-key 0.2.2 → 0.2.4

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
@@ -36,7 +36,7 @@ Host half (`lib/index.js`):
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
 
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
+ 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`, host rows are never dropped when that store only knows a subset, and sessions stay visible even when the host cannot list them. That store is provided by `dsh-api-session-controller` *after* the plugin's `apply()`, so both the panel and the overlay retry for it (every 500 ms, at most 40 times) instead of giving up on the first render. 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.
40
40
 
41
41
  ### HTTP API (loopback only)
42
42
 
@@ -72,7 +72,7 @@ Then add `"dsh-workspace-api-key"` to `dsh.profile.bundles` in that profile's `p
72
72
 
73
73
  ```sh
74
74
  node test/host.test.mjs # 125 assertions
75
- node test/client.test.mjs # 121 assertions
75
+ node test/client.test.mjs # 130 assertions
76
76
  ```
77
77
 
78
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.
package/README.zh.md CHANGED
@@ -36,7 +36,7 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
36
36
  - 自己的账本(哪一层对应哪个 ref、失效标记、key 是给哪个服务商记的)落在 `$DSH_HOME/storages/dsh-workspace-api-key.json`;
37
37
  - 通过 `agent/request-error` waterfall 观察失败,但**从不**返回 `{kind: 'retry'}`——只记录判定结果并 `next()` 放行。
38
38
 
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
+ 浏览器半边(`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`,浏览器端只认识一部分会话时也不会把宿主行挤掉,宿主列不出会话时面板也照样有会话可列。这个存储由 `dsh-api-session-controller` 在插件 `apply()` **之后**才提供,所以面板与覆盖层都会为它重试(每 500ms 一次,最多 40 次),而不是首次渲染拿不到就放弃。覆盖层向 `/wsk-api/check?sessionId=…&path=…` 查询当前占据主视图的会话,每个会话最多问一次,它的按钮只负责把你带到面板——改 key 只能由面板里的操作完成。
40
40
 
41
41
  ### HTTP 接口(仅限回环)
42
42
 
@@ -72,10 +72,10 @@ pnpm add "dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key"
72
72
 
73
73
  ```sh
74
74
  node test/host.test.mjs # 125 条断言
75
- node test/client.test.mjs # 121 条断言
75
+ node test/client.test.mjs # 130 条断言
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
@@ -212,6 +212,11 @@ window.__ModuleLoader__.load({
212
212
  return parts[parts.length - 1] ?? p
213
213
  }
214
214
 
215
+ /** 会话按最近使用排序。 */
216
+ function sortByUpdated(rows) {
217
+ return rows.slice().sort((a, b) => (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0))
218
+ }
219
+
215
220
  /** 浏览器端 sessions 服务快照 → 顶层会话条目(子代理会话不算)。 */
216
221
  function clientSessionEntries(sessions) {
217
222
  const snap = sessions?.list?.getSnapshot?.()
@@ -237,9 +242,10 @@ window.__ModuleLoader__.load({
237
242
  }
238
243
 
239
244
  /**
240
- * 用浏览器端的会话列表重建「工作区/目录 → 会话」的树。
241
- * 宿主行(含 key 层级)按 sessionId 合并进来;宿主没给出的会话用
242
- * /state 里的覆盖记录补出层级,保证按钮不会因为宿主列表为空而消失。
245
+ * 用浏览器端的会话列表补全「工作区/目录 → 会话」的树。
246
+ * 宿主行始终保留(不会被浏览器端列表挤掉),浏览器端额外只做两件事:
247
+ * 给同名会话补标题/cwd,以及把宿主漏掉的会话补进来(层级从 /state 的
248
+ * 覆盖记录推出)。这样宿主列表为空时面板照样有会话可列。
243
249
  */
244
250
  function mergeSessions(state, sessions) {
245
251
  const entries = clientSessionEntries(sessions)
@@ -261,18 +267,27 @@ window.__ModuleLoader__.load({
261
267
  if (key !== undefined) workspaceByPath.set(key, ws)
262
268
  }
263
269
 
264
- const buckets = new Map()
265
- const extras = new Map()
270
+ // 第一遍:宿主行能补什么就补什么,宿主没有的会话合成一行。
271
+ const enriched = new Map()
272
+ const additions = []
266
273
  for (const entry of entries) {
267
274
  const host = hostRows.get(entry.sessionId)
268
- const cwd = typeof host?.cwd === 'string' && host.cwd !== '' ? host.cwd : entry.cwd
275
+ if (host !== undefined) {
276
+ const patch = {}
277
+ if ((host.title === undefined || host.title === '') && entry.title !== undefined) patch.title = entry.title
278
+ if ((host.cwd === undefined || host.cwd === '') && entry.cwd !== undefined) patch.cwd = entry.cwd
279
+ if (Object.keys(patch).length > 0) enriched.set(entry.sessionId, { ...host, ...patch })
280
+ continue
281
+ }
282
+ const cwd = entry.cwd
269
283
  const pathKey = normKey(cwd)
270
284
  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 = {
285
+ const own = overrideBySession.get(entry.sessionId)
286
+ const level = own !== undefined ? 'session' : ws?.configured === true ? 'workspace' : 'default'
287
+ additions.push({
288
+ pathKey,
289
+ workspace: ws,
290
+ row: {
276
291
  sessionId: entry.sessionId,
277
292
  title: entry.title,
278
293
  cwd,
@@ -297,45 +312,50 @@ window.__ModuleLoader__.load({
297
312
  effectiveTitle: undefined,
298
313
  invalid: (level === 'session' ? own?.invalid : level === 'workspace' ? ws?.invalid : null) ?? null,
299
314
  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)
315
+ },
316
+ })
312
317
  }
313
318
 
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
- ))
319
+ const fill = (rows) => (Array.isArray(rows) ? rows : [])
320
+ .map((row) => enriched.get(row?.sessionId) ?? row)
321
+
322
+ // 第二遍:宿主结构照旧,浏览器端多出来的会话按 cwd 归档。
323
+ const workspaces = (state.workspaces ?? []).map((ws) => {
324
+ const rows = fill(ws.sessions)
325
+ for (const item of additions) {
326
+ if (item.workspace?.path === ws.path) rows.push(item.row)
327
+ }
328
+ return { ...ws, sessions: sortByUpdated(rows) }
329
+ })
330
+
318
331
  const ungrouped = []
332
+ const extras = new Map()
333
+ for (const item of additions) {
334
+ if (item.workspace !== undefined) continue
335
+ const key = item.pathKey ?? ''
336
+ const list = extras.get(key) ?? []
337
+ list.push(item.row)
338
+ extras.set(key, list)
339
+ }
340
+ for (const group of (state.ungrouped ?? [])) {
341
+ const key = normKey(group?.path) ?? String(group?.path ?? '')
342
+ const rows = fill(group.sessions)
343
+ for (const row of (extras.get(key) ?? [])) rows.push(row)
344
+ extras.delete(key)
345
+ ungrouped.push({ ...group, sessions: sortByUpdated(rows) })
346
+ }
319
347
  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
348
+ const path = key !== '' ? key : undefined
322
349
  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),
350
+ id: undefined,
351
+ path: rows[0]?.cwd ?? path,
352
+ title: path !== undefined && path !== '' ? dirName(path) : '(没有目录)',
353
+ kind: 'directory',
354
+ configured: false,
355
+ exists: true,
356
+ sessions: sortByUpdated(rows),
332
357
  })
333
358
  }
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
359
  return { ...state, workspaces, ungrouped, sessionSource: 'client' }
340
360
  }
341
361
 
@@ -485,12 +505,31 @@ window.__ModuleLoader__.load({
485
505
  // 浏览器端的会话列表(含标题、cwd)会随时变化:变了就重渲染。
486
506
  const sessionStore = sessionsService(props?.sessions)
487
507
  const [sessionTick, setSessionTick] = useState(0)
508
+ // 这个服务由 dsh-api-session-controller 在 effect 里晚于插件 apply 提供:
509
+ // 首次渲染拿不到就轮询一段时间,否则面板永远等不到会话列表。
510
+ const [storeMissing, setStoreMissing] = useState(false)
488
511
  useEffect(() => {
489
512
  const store = sessionStore?.list
490
- if (store === undefined || store === null || typeof store.subscribe !== 'function') return undefined
513
+ if (store === undefined || store === null || typeof store.subscribe !== 'function') {
514
+ setStoreMissing(true)
515
+ return undefined
516
+ }
517
+ setStoreMissing(false)
491
518
  const off = store.subscribe(() => setSessionTick((tick) => tick + 1))
492
519
  return () => { if (typeof off === 'function') off() }
493
520
  }, [sessionStore])
521
+ useEffect(() => {
522
+ if (!storeMissing) return undefined
523
+ let tries = 0
524
+ const timer = setInterval(() => {
525
+ tries += 1
526
+ if (sessionsService(props?.sessions) !== undefined || tries >= 40) {
527
+ clearInterval(timer)
528
+ setSessionTick((tick) => tick + 1)
529
+ }
530
+ }, 500)
531
+ return () => clearInterval(timer)
532
+ }, [storeMissing])
494
533
 
495
534
  // 让覆盖层能立刻刷新面板(点「去配置」时面板可能已经挂载着)。
496
535
  useEffect(() => {
@@ -1011,13 +1050,15 @@ window.__ModuleLoader__.load({
1011
1050
 
1012
1051
  useEffect(() => {
1013
1052
  ensureStyles()
1014
- const sessions = ctx.get('sessions')
1015
- if (sessions === undefined || sessions === null) return undefined
1016
-
1017
1053
  let disposed = false
1054
+ let timer = null
1055
+ let unsubscribe = null
1056
+ let tries = 0
1057
+ let list = null
1058
+
1018
1059
  const currentSession = () => {
1019
1060
  try {
1020
- const snapshot = sessions.list?.getSnapshot?.()
1061
+ const snapshot = list?.getSnapshot?.()
1021
1062
  const byId = snapshot?.byId ?? {}
1022
1063
  for (const [id, item] of Object.entries(byId)) {
1023
1064
  if (Number(item?.retainedBy?.mainView) > 0) return { id, cwd: item?.cwd }
@@ -1047,12 +1088,26 @@ window.__ModuleLoader__.load({
1047
1088
  }
1048
1089
  }
1049
1090
 
1050
- evaluate()
1051
- const unsubscribe = typeof sessions.list?.subscribe === 'function'
1052
- ? sessions.list.subscribe(() => { evaluate() })
1053
- : undefined
1091
+ // 会话列表服务由 dsh-api-session-controller 在 effect 里晚于插件 apply 提供
1092
+ // (覆盖层更早挂载),所以这里要有上限地重试,否则永远看不到会话。
1093
+ const attach = () => {
1094
+ if (disposed) return
1095
+ const sessions = ctx.get('sessions')
1096
+ if (sessions === null || sessions === undefined) {
1097
+ tries += 1
1098
+ if (tries < 40) timer = setTimeout(attach, 500)
1099
+ return
1100
+ }
1101
+ list = sessions.list ?? null
1102
+ evaluate()
1103
+ unsubscribe = typeof sessions.list?.subscribe === 'function'
1104
+ ? sessions.list.subscribe(() => { evaluate() })
1105
+ : null
1106
+ }
1107
+ attach()
1054
1108
  return () => {
1055
1109
  disposed = true
1110
+ if (timer !== null) clearTimeout(timer)
1056
1111
  if (typeof unsubscribe === 'function') unsubscribe()
1057
1112
  }
1058
1113
  }, [])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-workspace-api-key",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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",