dsh-loadout 0.12.0 → 0.12.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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  「装备栏」是一个 DeepSeek Harness 插件:给每个对话选一套工具(套装),只把用得上的工具投给模型。
4
4
 
5
+ ## 0.12.2
6
+
7
+ - **修「绑定到已不存在的套装」**:如果某个对话绑的套装在应用关着的时候被删掉(旧版本、手改 loadout.json、或别的机器同步过来的文件),启动后它会被当成「空套装」套用——等于把这对话的工具连同内置工具一起裁光,只剩 kit_*。现在这种绑定在第一次被读到时回退到「全部」,并把 ssignments 里那个失效名字改成
8
+ ull,日志里留一行说明。回归断言: binding to a deleted kit falls back to 全部(用第二个模块实例模拟重启)。
9
+ ## 0.12.1
10
+
11
+ - **修卡死**:编辑(覆盖保存)、重命名或删除一个正在被对话使用的套装时,宿主会遍历「正在用它的对话」并逐个重新套用工具;这个遍历边改边往 `Map` 里重写条目,迭代器被塞回一个已经走过的键,于是永远走不完——表现是界面点下去没有任何反应、进程占满一个核。现在遍历快照(`[...state]`),编辑与删除都会立刻返回。
12
+ - 测试补齐验收表:`selftest` 新增「同名不同大小写/空格被拒绝」「预置名不能被用户套装占用」「连续改默认不影响已有对话」「删除套装报告并释放全部绑定」「删除默认套装同时清空默认」「改名不动绑定」「写盘不留临时文件」「预置也能当默认」八项断言;`client-smoke` 的预置夹具改为随包发布的五套(仅聊天 / 编程用 / 查找资料 / 项目梳理 / 写作)。
13
+
5
14
  ## 未发布
6
15
 
7
16
  - 修 `kit_list`:返回值里带 `note`,但输出 schema 没有声明它,宿主按 schema 校验,于是这个工具**每次调用都直接失败**(`kit_list returned invalid output: "note" is not a declared property`)。现在 `note` 是合法字段,渲染时也会在套装名后显示备注;`selftest` 新增断言 `kit_list declares every field it returns`,防止返回值与 schema 再次漂移。
package/index.js CHANGED
@@ -1132,8 +1132,19 @@ export function apply(ctx, config = {}) {
1132
1132
  */
1133
1133
  function ensureBindingApplied(agent) {
1134
1134
  if (agent === undefined || agent === null) return
1135
- const value = ensureBinding(agent)
1135
+ let value = ensureBinding(agent)
1136
1136
  if (value === undefined) return
1137
+ // A stored binding can name a kit that no longer exists — an older build, a
1138
+ // hand-edited store, or a kit deleted while the app was closed. Applying it
1139
+ // would compute an empty allow list and strip the conversation down to the
1140
+ // kit_* tools, so such a conversation falls back to 全部 and the dangling
1141
+ // name is rewritten to stop it resurfacing on every read.
1142
+ if (value !== null && value !== DEFAULT_DSH && resolveKit(lastAgent, value) === undefined) {
1143
+ book.assignments[sessionIdOf(agent)] = null
1144
+ save()
1145
+ ctx.logger?.warn(`dsh-loadout: 套装「${value}」已不存在,这个对话回到「全部」`)
1146
+ value = null
1147
+ }
1137
1148
  const selection = selectionForBinding(value)
1138
1149
  const current = state.get(agent)?.selection
1139
1150
  if (current !== undefined && sameSelection(current, selection)) return
@@ -1516,7 +1527,7 @@ export function apply(ctx, config = {}) {
1516
1527
  throw new Error('至少要选一个内置工具或一个插件,否则这个包等于"全部"')
1517
1528
  }
1518
1529
  if (oldName !== kit.name) {
1519
- for (const [agent, entry] of state) {
1530
+ for (const [agent, entry] of [...state]) {
1520
1531
  if (entry.selection.base === BASE_KIT && entry.selection.kit === oldName) {
1521
1532
  entry.selection = { ...entry.selection, kit: kit.name }
1522
1533
  entry.label = labelOf(agent, entry.selection)
@@ -1526,7 +1537,11 @@ export function apply(ctx, config = {}) {
1526
1537
  // Editing the tools of the kit a conversation is running has to change that
1527
1538
  // conversation now: a saved edit that only takes effect after the next
1528
1539
  // switch reads as "the edit did nothing".
1529
- for (const [agent, entry] of state) {
1540
+ //
1541
+ // Iterate a snapshot: `useSelection` deletes and re-adds the agent's entry,
1542
+ // and a Map iterator that is handed back an entry it already passed never
1543
+ // finishes (that is a hang, not a slow loop).
1544
+ for (const [agent, entry] of [...state]) {
1530
1545
  if (entry.selection.base === BASE_KIT && entry.selection.kit === kit.name) {
1531
1546
  useSelection(agent, { base: BASE_KIT, kit: kit.name, extras: [...entry.selection.extras] })
1532
1547
  }
@@ -1553,7 +1568,9 @@ export function apply(ctx, config = {}) {
1553
1568
  for (const [sessionId, value] of Object.entries(book.assignments)) {
1554
1569
  if (value === kit.name) book.assignments[sessionId] = null
1555
1570
  }
1556
- for (const [agent, entry] of state) {
1571
+ // Snapshot for the same reason as `update`: each `useSelection` rewrites the
1572
+ // map entry it belongs to.
1573
+ for (const [agent, entry] of [...state]) {
1557
1574
  if (entry.selection.base === BASE_KIT && entry.selection.kit === kit.name) {
1558
1575
  useSelection(agent, emptySelection())
1559
1576
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-loadout",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Loadout for DeepSeek Harness: group tools into kits, keep only the current kit's tools visible in each conversation, and manage everything from a settings panel.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -179,11 +179,13 @@ const fixture = {
179
179
  ok: true,
180
180
  source: 'C:/fake/.dsh/loadout.json',
181
181
  presets: [
182
+ { name: 'chat', title: '仅聊天', allow: ['ask_user_question', 'web_search', 'read'] },
183
+ { name: 'coding', title: '编程用', allow: ['pwsh', 'read'] },
184
+ { name: 'research', title: '查找资料', allow: ['web_search', 'web_fetch'] },
185
+ { name: 'plan', title: '项目梳理', allow: ['read', 'write', 'todo_write'] },
182
186
  { name: 'writing', title: '写作', allow: ['read', 'write', 'edit'] },
183
- { name: 'research', title: '调研', allow: ['web_search', 'web_fetch'] },
184
- { name: 'coding', title: '编程', allow: ['pwsh', 'read'] },
185
187
  ],
186
- presetNames: ['writing', 'research', 'coding'],
188
+ presetNames: ['chat', 'coding', 'research', 'plan', 'writing'],
187
189
  kits: [
188
190
  { name: 'mine', title: '我的套', note: '用于视频制作', builtins: ['read'], plugins: [], allow: ['read'], description: '内置 1 个', tokens: 860, isDefault: true },
189
191
  { name: 'other', title: '另一套', builtins: ['write'], plugins: [], allow: ['write'], description: '内置 1 个', tokens: 420, isDefault: false },
@@ -246,7 +248,7 @@ if (menu !== undefined) {
246
248
  check('我的套装 counts user kits only', allText.includes('我的套装(2)'), allText.slice(0, 400))
247
249
  // Presets are back from 0.12: they are the quickest way to start a known kind
248
250
  // of work, they stay read-only, and each one now says what it leaves out.
249
- check('menu lists the presets again', allText.includes('预设套装(3)') && allText.includes('写作'), allText.slice(0, 400))
251
+ check('menu lists the presets again', allText.includes('预设套装(5)') && allText.includes('写作') && allText.includes('仅聊天'), allText.slice(0, 400))
250
252
  check('加挂 counts extras', allText.includes('加挂(1)'), allText.slice(0, 400))
251
253
  check('footer text is the approved sentence', allText.includes(FOOTER))
252
254
  check('the menu shows the note with the kit name', allText.includes('我的套(用于视频制作)'), allText.slice(0, 400))
@@ -320,7 +322,7 @@ try {
320
322
  try {
321
323
  const panel = expand(registration.component({ preview: { data: fixture, openSections: { presets: true, kits: true } } }))
322
324
  const panelText = textOf(panel)
323
- check('settings lists the presets again', panelText.includes('预设套装 · 只读(3)') && panelText.includes('写作'), panelText.slice(0, 400))
325
+ check('settings lists the presets again', panelText.includes('预设套装 · 只读(5)') && panelText.includes('写作') && panelText.includes('项目梳理'), panelText.slice(0, 400))
324
326
  check('settings lists my kits', panelText.includes('我的套装'), panelText.slice(0, 400))
325
327
  check('settings picks a default kit', panelText.includes('新对话默认套装') && panelText.includes('仅内置'), panelText.slice(0, 400))
326
328
  check('a note rides next to the kit name', panelText.includes('我的套(用于视频制作)'), panelText.slice(0, 400))
@@ -335,7 +337,7 @@ try {
335
337
  const copyButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '复制切换命令')
336
338
  const defaultButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '设为默认')
337
339
  // 2 defaults + 3 presets + 2 user kits — every card carries both actions.
338
- check('全部 and 仅内置 are cards with the two actions', copyButtons.length === 7 && defaultButtons.length === 7, `${copyButtons.length}/${defaultButtons.length}`)
340
+ check('全部 and 仅内置 are cards with the two actions', copyButtons.length === 9 && defaultButtons.length === 9, `${copyButtons.length}/${defaultButtons.length}`)
339
341
  check('preset cards no longer offer save-as', !panelText.includes('另存为我的套装'), panelText.slice(0, 400))
340
342
  const editButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '编辑')
341
343
  check('settings offers an edit action for saved kits', editButtons.length === 2, `${editButtons.length} · 编辑`)
@@ -151,13 +151,50 @@ function childFor(dependencies, built, commands, routes) {
151
151
  return child
152
152
  }
153
153
 
154
+ const COLLAB = ['send_message', 'list_agents', 'interrupt_agent', 'subagent', 'subagent_fork']
155
+
156
+ // The coding preset restricted to what the fake runtime actually exposes: the
157
+ // collaboration five are listed by the real preset, but this fake agent only owns
158
+ // subagent from that group, and a tool it cannot see can never survive a mask.
159
+ const CODING_KEPT = ['pwsh', 'read', 'write', 'edit', 'glob', 'grep', 'todo_write', 'subagent', ...OWN_TOOLS]
160
+
161
+ // Mirrors the presets in `cordis.patch.yml` (the host hands this same shape to
162
+ // `apply`). The last check in this block keeps the two lists from drifting.
154
163
  const config = {
155
164
  defaultKit: 'writing',
156
165
  kits: [
157
- { name: 'writing', title: '写作', builtins: ['read', 'write', 'edit'] },
158
- { name: 'research', title: '调研', builtins: ['web_search', 'web_fetch'] },
159
- { name: 'coding', title: '编程', builtins: ['pwsh', 'read'] },
160
- ],
166
+ {
167
+ name: 'chat',
168
+ title: '仅聊天',
169
+ builtins: ['ask_user_question', 'weather', 'web_search', 'web_fetch', 'read'],
170
+ },
171
+ { name: 'coding', title: '编程用', builtins: ['pwsh', 'read', 'write', 'edit', 'glob', 'grep', 'todo_write'] },
172
+ {
173
+ name: 'research',
174
+ title: '查找资料',
175
+ builtins: ['web_search', 'web_fetch', 'advanced_search', 'research', 'platform_search', 'read', 'write', 'glob', 'grep'],
176
+ },
177
+ {
178
+ name: 'plan',
179
+ title: '项目梳理',
180
+ builtins: ['ask_user_question', 'web_search', 'web_fetch', 'read', 'write', 'edit', 'glob', 'grep', 'todo_write'],
181
+ },
182
+ { name: 'writing', title: '写作', builtins: ['read', 'write', 'edit', 'glob', 'grep'] },
183
+ ].map((preset) => ({
184
+ ...preset,
185
+ // Every preset carries the collaboration five, or a subagent running it
186
+ // cannot answer its parent at all.
187
+ builtins: [...preset.builtins, 'send_message', 'list_agents', 'interrupt_agent', 'subagent', 'subagent_fork'],
188
+ })),
189
+ }
190
+
191
+ // The five preset ids must exist in the shipped patch too — the test's copy above
192
+ // is hand-written, and a rename in one place without the other is silent drift.
193
+ {
194
+ const patch = readFileSync(new URL('../cordis.patch.yml', import.meta.url), 'utf8')
195
+ const missing = config.kits.filter((preset) => !new RegExp(`name:\\s*${preset.name}\\b`).test(patch))
196
+ check('the shipped patch carries every preset the tests use',
197
+ missing.length === 0, missing.map((preset) => preset.name).join(','))
161
198
  }
162
199
 
163
200
  const ctx = makeCtx()
@@ -345,7 +382,7 @@ let listed = await run('kit_list', {}, agent)
345
382
  check('kit_list active title', listed.active === '写作', listed.active)
346
383
  check('kit_list base is kit', listed.base === 'kit', listed.base)
347
384
  check('kit_list prefers the shipped config', listed.source === 'bundle 配置', listed.source)
348
- check('kit_list has three presets', listed.presets.length === 3, String(listed.presets.length))
385
+ check('kit_list lists the five shipped presets', listed.presets.length === 5, String(listed.presets.length))
349
386
  check('kit_list has no user kit yet', listed.kits.length === 0, String(listed.kits.length))
350
387
  // The host validates execute() output against the declared schema, so a field
351
388
  // that is returned but not declared fails the whole call at runtime
@@ -360,12 +397,12 @@ check('kit_list has no user kit yet', listed.kits.length === 0, String(listed.ki
360
397
  }
361
398
 
362
399
  // ---- kit_apply by title ------------------------------------------------------
363
- const appliedKit = await run('kit_apply', { kit: '编程' }, agent)
400
+ const appliedKit = await run('kit_apply', { kit: '编程用' }, agent)
364
401
  check('kit_apply by title', appliedKit.kit === 'coding', appliedKit.kit)
365
402
  check('previous restriction released', agent.filters[0].active === false)
366
403
  check(
367
404
  'preset keeps exactly its built-ins plus own tools',
368
- sameSet(keptOf(agent), ['pwsh', 'read', ...OWN_TOOLS]),
405
+ sameSet(keptOf(agent), CODING_KEPT),
369
406
  keptOf(agent).join(','),
370
407
  )
371
408
 
@@ -380,7 +417,7 @@ check('unknown kit throws', unknownError.includes('未知套装'), unknownError)
380
417
  // ---- GET /state exposes the whole model -------------------------------------
381
418
  let state = await getState()
382
419
  check('state route 200', state.status === 200, String(state.status))
383
- check('state lists presets with names', JSON.stringify(state.body.presetNames) === JSON.stringify(['writing', 'research', 'coding']), JSON.stringify(state.body.presetNames))
420
+ check('state lists presets with names', JSON.stringify(state.body.presetNames) === JSON.stringify(['chat', 'coding', 'research', 'plan', 'writing']), JSON.stringify(state.body.presetNames))
384
421
  check('state lists preset allow', Array.isArray(state.body.presets) && state.body.presets[0].allow.includes('read'), JSON.stringify(state.body.presets?.[0]))
385
422
  check('state splits the catalog', Array.isArray(state.body.catalog.builtins) && Array.isArray(state.body.catalog.plugins), JSON.stringify(state.body.catalog))
386
423
  check(
@@ -433,7 +470,7 @@ check('presets carry a token estimate', Number.isFinite(answer.body.presets[0].t
433
470
  answer = await post({ action: 'applyBase', sessionId: SESSION_ID, base: 'kit', kit: 'coding' })
434
471
  const kitKept = keptOf(agent)
435
472
  check('applyBase kit 200', answer.status === 200, JSON.stringify(answer.body))
436
- check('kit keeps only its built-ins', sameSet(kitKept, ['pwsh', 'read', ...OWN_TOOLS]), kitKept.join(','))
473
+ check('kit keeps only its built-ins', sameSet(kitKept, CODING_KEPT), kitKept.join(','))
437
474
  check('kit masks plugin tools', !kitKept.includes('demo_search'), kitKept.join(','))
438
475
 
439
476
  let badBase = await post({ action: 'applyBase', sessionId: SESSION_ID, base: 'kit', kit: 'ghost' })
@@ -678,7 +715,7 @@ check('GET answers for the Session it names', firstView.body.selection.kit === '
678
715
  check('GET answers for a different Session', secondView.body.selection.kit === 'research', JSON.stringify(secondView.body.selection))
679
716
  check(
680
717
  'two Sessions do not leak into each other',
681
- firstView.body.label === '编程' && secondView.body.label === '调研',
718
+ firstView.body.label === '编程用' && secondView.body.label === '查找资料',
682
719
  JSON.stringify([firstView.body.label, secondView.body.label]),
683
720
  )
684
721
 
@@ -785,6 +822,102 @@ try {
785
822
  }
786
823
  check('a reserved name is refused', /全部/u.test(reservedError ?? ''), reservedError)
787
824
 
825
+ // T18: the same name with different case or surrounding space is the same kit.
826
+ let caseDupError
827
+ await ctxV2.registered.get('kit_create').execute(
828
+ { name: 'qa', title: '问答', builtins: ['read'], plugins: [] }, exec(agentB))
829
+ try {
830
+ await ctxV2.registered.get('kit_create').execute(
831
+ { name: ' QA ', title: '重复', builtins: ['read'], plugins: [] }, exec(agentB))
832
+ } catch (error) {
833
+ caseDupError = String(error?.message ?? error)
834
+ }
835
+ check('a name that differs only by case or space is refused',
836
+ /已存在/u.test(caseDupError ?? ''), caseDupError)
837
+
838
+ // T24: a user kit may not shadow a shipped preset.
839
+ let presetClashError
840
+ try {
841
+ await ctxV2.registered.get('kit_create').execute(
842
+ { name: 'chat', title: '冒充预置', builtins: ['read'], plugins: [] }, exec(agentB))
843
+ } catch (error) {
844
+ presetClashError = String(error?.message ?? error)
845
+ }
846
+ check('a preset name cannot be taken by a user kit',
847
+ /预置/u.test(presetClashError ?? ''), presetClashError)
848
+
849
+ // T22: a binding that names a kit removed outside the app falls back to 全部.
850
+ // Simulated the way it really happens: the kit is gone from the store file when
851
+ // the app starts again, so a fresh module instance has to cope with it.
852
+ await ctxV2.registered.get('kit_create').execute(
853
+ { name: 'ghost-kit', title: '已删除的', builtins: ['read'], plugins: [] }, exec(agentB))
854
+ await ctxV2.registered.get('kit_assign').execute({ sessionId: 'session-ghost', kit: 'ghost-kit' }, exec(agentB))
855
+ const storeWithGhost = storeOf()
856
+ storeWithGhost.kits = storeWithGhost.kits.filter((kit) => kit.name !== 'ghost-kit')
857
+ writeFileSync(join(v2Home, '.dsh', 'loadout.json'), JSON.stringify(storeWithGhost), 'utf8')
858
+ const v2b = await import('../index.js?v2=2')
859
+ const ctxV2b = makeCtx()
860
+ v2b.apply(ctxV2b, { defaultKit: '', kits: config.kits })
861
+ const agentGhost = makeAgent(undefined, { id: 'session-ghost' })
862
+ for (const handler of ctxV2b.handlers.get('agent/created') ?? []) handler(agentGhost)
863
+ check('a binding to a deleted kit falls back to 全部',
864
+ storeOf().assignments['session-ghost'] === null && keptOf(agentGhost).includes('web_search'),
865
+ JSON.stringify({ assignments: storeOf().assignments, kept: keptOf(agentGhost) }))
866
+
867
+ // T03/T05: the default changes again; conversations that already answered keep theirs.
868
+ const agentC = makeAgent(undefined, { id: 'session-c' })
869
+ await ctxV2.registered.get('kit_set_default').execute({ name: 'chat' }, exec(agentB))
870
+ fireCreated(agentC)
871
+ check('changing the default twice keeps earlier conversations',
872
+ storeOf().assignments['session-a'] === null && storeOf().assignments['session-c'] === 'chat',
873
+ JSON.stringify(storeOf().assignments))
874
+
875
+ // T08: deleting a kit reports and releases every conversation that held it.
876
+ await ctxV2.registered.get('kit_create').execute(
877
+ { name: 'shared', title: '共享', builtins: ['read'], plugins: [] }, exec(agentB))
878
+ for (const id of ['session-a', 'session-b', 'session-c']) {
879
+ await ctxV2.registered.get('kit_assign').execute({ sessionId: id, kit: 'shared' }, exec(agentB))
880
+ }
881
+ const sharedDelete = await ctxV2.registered.get('kit_delete').execute({ name: 'shared' }, exec(agentB))
882
+ check('deleting a kit reports every conversation it held',
883
+ sharedDelete.affectedSessions === 3
884
+ && ['session-a', 'session-b', 'session-c'].every((id) => storeOf().assignments[id] === null),
885
+ JSON.stringify({ sharedDelete, assignments: storeOf().assignments }))
886
+
887
+ // T09: deleting the default kit also clears the default.
888
+ await ctxV2.registered.get('kit_create').execute(
889
+ { name: 'shared-two', title: '共享二', builtins: ['read'], plugins: [] }, exec(agentB))
890
+ await ctxV2.registered.get('kit_set_default').execute({ name: 'shared-two' }, exec(agentB))
891
+ await ctxV2.registered.get('kit_assign').execute({ sessionId: 'session-a', kit: 'shared-two' }, exec(agentB))
892
+ const defaultDelete = await ctxV2.registered.get('kit_delete').execute({ name: 'shared-two' }, exec(agentB))
893
+ check('deleting the default kit clears the default',
894
+ defaultDelete.affectedSessions === 1
895
+ && (storeOf().defaultKit === null || storeOf().defaultKit === ''),
896
+ JSON.stringify({ defaultDelete, defaultKit: storeOf().defaultKit }))
897
+
898
+ // T10: renaming changes the display name only; bindings point at the stable id.
899
+ await ctxV2.registered.get('kit_create').execute(
900
+ { name: 'stable', title: '稳定', builtins: ['read'], plugins: [] }, exec(agentB))
901
+ await ctxV2.registered.get('kit_assign').execute({ sessionId: 'session-b', kit: 'stable' }, exec(agentB))
902
+ await ctxV2.registered.get('kit_rename').execute({ name: 'stable', title: '稳定改名' }, exec(agentB))
903
+ check('renaming a bound kit keeps the binding',
904
+ storeOf().assignments['session-b'] === 'stable'
905
+ && storeOf().kits.some((kit) => kit.name === 'stable' && kit.title === '稳定改名'),
906
+ JSON.stringify(storeOf().kits.map((kit) => [kit.name, kit.title])))
907
+
908
+ // T15: every write is a complete file — the temporary one never survives.
909
+ check('the store write leaves no temporary file',
910
+ !existsSync(join(v2Home, '.dsh', 'loadout.json.tmp')), 'loadout.json.tmp is still there')
911
+
912
+ // T25: a shipped preset can be the new-conversation default too.
913
+ await ctxV2.registered.get('kit_set_default').execute({ name: 'plan' }, exec(agentB))
914
+ const agentD = makeAgent(undefined, { id: 'session-d' })
915
+ fireCreated(agentD)
916
+ check('a preset can be the default',
917
+ storeOf().assignments['session-d'] === 'plan'
918
+ && keptOf(agentD).includes('read') && !keptOf(agentD).includes('pwsh'),
919
+ JSON.stringify({ binding: storeOf().assignments['session-d'], kept: keptOf(agentD) }))
920
+
788
921
  process.env.USERPROFILE = savedHomeV2
789
922
  process.env.HOME = savedHomeV2b
790
923
  rmSync(v2Home, { recursive: true, force: true })