dsh-loadout 0.11.2 → 0.12.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-loadout",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
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",
@@ -244,9 +244,9 @@ if (menu !== undefined) {
244
244
  check(`menu shows «${label}»`, allText.includes(label), allText.slice(0, 400))
245
245
  }
246
246
  check('我的套装 counts user kits only', allText.includes('我的套装(2)'), allText.slice(0, 400))
247
- // Presets ship in the bundle but are held back by SHOW_PRESETS: a shipped
248
- // preset that hides tools reads like a bug, so neither surface lists them.
249
- check('menu hides presets for now', !allText.includes('预设套装') && !allText.includes('写作'), allText.slice(0, 400))
247
+ // Presets are back from 0.12: they are the quickest way to start a known kind
248
+ // 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))
250
250
  check('加挂 counts extras', allText.includes('加挂(1)'), allText.slice(0, 400))
251
251
  check('footer text is the approved sentence', allText.includes(FOOTER))
252
252
  check('the menu shows the note with the kit name', allText.includes('我的套(用于视频制作)'), allText.slice(0, 400))
@@ -320,7 +320,7 @@ try {
320
320
  try {
321
321
  const panel = expand(registration.component({ preview: { data: fixture, openSections: { presets: true, kits: true } } }))
322
322
  const panelText = textOf(panel)
323
- check('settings hides presets for now', !panelText.includes('预设套装') && !panelText.includes('写作'), panelText.slice(0, 400))
323
+ check('settings lists the presets again', panelText.includes('预设套装 · 只读(3)') && panelText.includes('写作'), panelText.slice(0, 400))
324
324
  check('settings lists my kits', panelText.includes('我的套装'), panelText.slice(0, 400))
325
325
  check('settings picks a default kit', panelText.includes('新对话默认套装') && panelText.includes('仅内置'), panelText.slice(0, 400))
326
326
  check('a note rides next to the kit name', panelText.includes('我的套(用于视频制作)'), panelText.slice(0, 400))
@@ -330,10 +330,12 @@ try {
330
330
  check('settings says which conversation the ledger describes', panelText.includes('最近活跃对话保留') && panelText.includes('设置页不绑定具体会话'), panelText.slice(0, 400))
331
331
  // 全部 / 仅内置 sit in the same list as the kits, so they are rendered as cards
332
332
  // with the same two actions; presets are read-only and carry no save-as button.
333
- check('settings no longer offers per-conversation switching', !panelText.includes('只影响之后新建的对话'), panelText.slice(0, 400))
333
+ // The default section says out loud that it cannot reach an open conversation.
334
+ check('settings says the default only affects new conversations', panelText.includes('只影响之后新建的对话'), panelText.slice(0, 400))
334
335
  const copyButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '复制切换命令')
335
336
  const defaultButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '设为默认')
336
- check('全部 and 仅内置 are cards with the two actions', copyButtons.length === 4 && defaultButtons.length === 4, `${copyButtons.length}/${defaultButtons.length}`)
337
+ // 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}`)
337
339
  check('preset cards no longer offer save-as', !panelText.includes('另存为我的套装'), panelText.slice(0, 400))
338
340
  const editButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '编辑')
339
341
  check('settings offers an edit action for saved kits', editButtons.length === 2, `${editButtons.length} · 编辑`)
@@ -9,7 +9,7 @@
9
9
  * & "…\DeepSeek Harness.exe" dsh-loadout\scripts\selftest.mjs
10
10
  */
11
11
 
12
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
13
13
  import { tmpdir } from 'node:os'
14
14
  import { join } from 'node:path'
15
15
 
@@ -32,7 +32,7 @@ const check = (label, ok, detail) => {
32
32
  }
33
33
 
34
34
  const SESSION_ID = 'session-1'
35
- const OWN_TOOLS = ['kit_list', 'kit_apply', 'kit_create', 'kit_set_tools', 'kit_set_default', 'kit_delete']
35
+ const OWN_TOOLS = ['kit_list', 'kit_apply', 'kit_create', 'kit_set_tools', 'kit_set_default', 'kit_delete', 'kit_assign', 'kit_rename']
36
36
  const SCOPED = ['read', 'write', 'edit', 'glob', 'grep', 'pwsh', 'web_search', 'web_fetch', 'todo_write']
37
37
  /** Registered by each agent into its own layer: visible, but never restrictable. */
38
38
  const SCOPE_OWNED = ['subagent']
@@ -61,6 +61,14 @@ const definitionOf = (toolName) => ({
61
61
  parameters: { type: 'object', properties: {}, additionalProperties: false },
62
62
  })
63
63
 
64
+ /**
65
+ * Session headers and agents are shared by every fake host in this file: the
66
+ * plugin keeps one registry per process, and a second `apply()` (the legacy and
67
+ * v2 blocks) must see the conversations the first one registered.
68
+ */
69
+ const fakeSessions = new Map()
70
+ const fakeAgents = new Map()
71
+
64
72
  /** One fake host. `layers` is what the real tool runtime exposes. */
65
73
  function makeCtx() {
66
74
  const tools = new Map()
@@ -92,10 +100,15 @@ function makeCtx() {
92
100
  optional(dependencies, install) { install(childFor(dependencies, built, commands, routes, sessionAgentOf)) },
93
101
  inject(dependencies, install) { install(childFor(dependencies, built, commands, routes, sessionAgentOf)) },
94
102
  }
95
- const sessions = new Map()
103
+ // `sessions` is the host's Session registry: `get(id)` hands back a Session
104
+ // whose `header` carries `createdAt` / `parentSession` / `origin`. The agents
105
+ // themselves live in their own map — the plugin only reads headers from this
106
+ // one, and `sessionController` is what resolves a Session id to an agent.
107
+ const sessions = fakeSessions
108
+ const agents = fakeAgents
96
109
  built.sessionController = {
97
110
  async resolveAgent(sessionId) {
98
- if (sessions.has(sessionId)) return { agent: sessions.get(sessionId) }
111
+ if (agents.has(sessionId)) return { agent: agents.get(sessionId) }
99
112
  if (sessionId === SESSION_ID) return { agent: sessionAgentOf.agent }
100
113
  return { error: { message: `找不到会话 "${sessionId}"` } }
101
114
  },
@@ -104,7 +117,9 @@ function makeCtx() {
104
117
  built.handlers = handlers
105
118
  built.commands = commands
106
119
  built.routes = routes
107
- built.sessions = sessions
120
+ built.sessions = { get: (sessionId) => sessions.get(sessionId) }
121
+ built.sessionTable = sessions
122
+ built.agentTable = agents
108
123
  return built
109
124
  }
110
125
 
@@ -130,6 +145,9 @@ function childFor(dependencies, built, commands, routes) {
130
145
  }
131
146
  }
132
147
  if (dependencies.includes('sessionController')) child.sessionController = built.sessionController
148
+ // The plugin injects `sessions` optionally; a fake host without it must still
149
+ // work (that is the 0.x path), so this mirrors the real `optional` contract.
150
+ if (dependencies.includes('sessions')) child.sessions = built.sessions
133
151
  return child
134
152
  }
135
153
 
@@ -178,8 +196,13 @@ check('registered state route', ctx.routes.has('/dsh-loadout/state'))
178
196
  * `layers.effect` is the append `restrict()` performs internally, which is how
179
197
  * the plugin masks those same tools without ever writing an allow list.
180
198
  */
181
- function makeAgent(tools = [...SCOPED, ...SCOPE_OWNED, 'demo_search', ...OWN_TOOLS]) {
182
- const agent = { tools, filters: [], restrictable: new Set(GLOBAL) }
199
+ let agentSeq = 0
200
+
201
+ function makeAgent(tools = [...SCOPED, ...SCOPE_OWNED, 'demo_search', ...OWN_TOOLS], options = {}) {
202
+ // Every agent carries a Session id, because that id is the key a conversation's
203
+ // binding is stored under (`assignments[sessionId]`). An id-less fake made the
204
+ // whole binding layer look like a no-op.
205
+ const agent = { id: options.id ?? `session-agent-${++agentSeq}`, tools, filters: [], restrictable: new Set(GLOBAL) }
183
206
  const append = (filter) => {
184
207
  const entry = { filter, active: true }
185
208
  agent.filters.push(entry)
@@ -230,6 +253,15 @@ function makeAgent(tools = [...SCOPED, ...SCOPE_OWNED, 'demo_search', ...OWN_TOO
230
253
  },
231
254
  },
232
255
  }
256
+ // Publish the Session header and the agent itself, so `ensureBinding()` can
257
+ // read `createdAt` / `parentSession` / `origin` exactly like the host does.
258
+ const header = {
259
+ createdAt: options.createdAt ?? Date.now(),
260
+ ...(options.parentSession === undefined ? {} : { parentSession: options.parentSession }),
261
+ ...(options.origin === undefined ? {} : { origin: options.origin }),
262
+ }
263
+ fakeSessions.set(agent.id, { id: agent.id, header })
264
+ fakeAgents.set(agent.id, agent)
233
265
  return agent
234
266
  }
235
267
 
@@ -315,6 +347,17 @@ check('kit_list base is kit', listed.base === 'kit', listed.base)
315
347
  check('kit_list prefers the shipped config', listed.source === 'bundle 配置', listed.source)
316
348
  check('kit_list has three presets', listed.presets.length === 3, String(listed.presets.length))
317
349
  check('kit_list has no user kit yet', listed.kits.length === 0, String(listed.kits.length))
350
+ // The host validates execute() output against the declared schema, so a field
351
+ // that is returned but not declared fails the whole call at runtime
352
+ // (`kit_list returned invalid output: "note" is not a declared property`).
353
+ // Keep the two sides in sync.
354
+ {
355
+ const schema = registered.get('kit_list').output.schema
356
+ const kitItem = schema.properties.kits.items.properties
357
+ const presetItem = schema.properties.presets.items.properties
358
+ check('kit_list declares every field it returns', 'note' in kitItem && 'note' in presetItem,
359
+ `kits=${Object.keys(kitItem).join(',')} presets=${Object.keys(presetItem).join(',')}`)
360
+ }
318
361
 
319
362
  // ---- kit_apply by title ------------------------------------------------------
320
363
  const appliedKit = await run('kit_apply', { kit: '编程' }, agent)
@@ -540,7 +583,7 @@ check('a note survives a restart', afterRestart.kits.find((kit) => kit.name ===
540
583
  // ---- store schema ----------------------------------------------------------
541
584
  // 0.11.0 stamps the file so a later release can migrate instead of guessing.
542
585
  const stamped = JSON.parse(readFileSync(storePath, 'utf8')).schema
543
- check('the store file records its schema version', stamped === 1, String(stamped))
586
+ check('the store file records its schema version', stamped === 2, String(stamped))
544
587
 
545
588
  // A store written by 0.10.x has no `schema` field at all; it still has to load,
546
589
  // because that is the exact file everyone upgrading from 0.10.x has on disk.
@@ -625,8 +668,7 @@ check('non-local Host refused', foreignResponse.status === 403, String(foreignRe
625
668
  // ---- GET /state is per-Session: two agents never leak into each other -------
626
669
  // The header chip asks for one conversation; answering from whichever agent was
627
670
  // touched last is the bug this covers.
628
- const secondAgent = makeAgent()
629
- ctx.sessions.set('session-2', secondAgent)
671
+ const secondAgent = makeAgent([...SCOPED, ...SCOPE_OWNED, 'demo_search', ...OWN_TOOLS], { id: 'session-2' })
630
672
  await post({ action: 'applyBase', sessionId: SESSION_ID, base: 'kit', kit: 'coding' })
631
673
  await post({ action: 'applyBase', sessionId: 'session-2', base: 'kit', kit: 'research' })
632
674
 
@@ -654,6 +696,99 @@ check(
654
696
  JSON.stringify(ghostView.body.selection),
655
697
  )
656
698
 
699
+ // ---- v2: one persisted binding per conversation ----------------------------
700
+ // The plan's core promise: a conversation carries its own choice, so changing
701
+ // the default later never reaches back into it, and an explicit switch survives
702
+ // a restart. Everything here reads the store file, not the in-memory state.
703
+ const v2Home = join(tmpdir(), `loadout-v2-${process.pid}`)
704
+ rmSync(v2Home, { recursive: true, force: true })
705
+ mkdirSync(join(v2Home, '.dsh', 'sessions', 'ws', 'session-old'), { recursive: true })
706
+ const savedHomeV2 = process.env.USERPROFILE
707
+ const savedHomeV2b = process.env.HOME
708
+ process.env.USERPROFILE = v2Home
709
+ process.env.HOME = v2Home
710
+ writeFileSync(join(v2Home, '.dsh', 'loadout.json'), JSON.stringify({
711
+ schema: 1,
712
+ kits: [{ name: 'mine', title: '我的', builtins: ['read', 'write'], plugins: [] }],
713
+ defaultKit: '',
714
+ }), 'utf8')
715
+ const v2 = await import('../index.js?v2=1')
716
+ const ctxV2 = makeCtx()
717
+ v2.apply(ctxV2, { defaultKit: '', kits: config.kits })
718
+ const fireCreated = (agent) => {
719
+ for (const handler of ctxV2.handlers.get('agent/created') ?? []) handler(agent)
720
+ }
721
+ const storeOf = () => JSON.parse(readFileSync(join(v2Home, '.dsh', 'loadout.json'), 'utf8'))
722
+ const backups = () => readdirSync(join(v2Home, '.dsh')).filter((entry) => entry.startsWith('loadout.json.v1.bak-'))
723
+
724
+ check('migration stamps schema 2', storeOf().schema === 2, String(storeOf().schema))
725
+ check('migration freezes existing conversations on 全部', storeOf().assignments['session-old'] === null,
726
+ JSON.stringify(storeOf().assignments))
727
+ check('migration keeps the schema-1 file aside', backups().length === 1, backups().join(','))
728
+
729
+ const agentA = makeAgent(undefined, { id: 'session-a' })
730
+ fireCreated(agentA)
731
+ check('a new conversation records 全部', storeOf().assignments['session-a'] === null, JSON.stringify(storeOf().assignments))
732
+ check('a new conversation starts unmasked', activeFilters(agentA).length === 0, String(activeFilters(agentA).length))
733
+
734
+ await ctxV2.registered.get('kit_set_default').execute({ name: 'coding' }, exec(agentA))
735
+ check('changing the default leaves an existing conversation alone',
736
+ activeFilters(agentA).length === 0 && keptOf(agentA).includes('web_search'),
737
+ JSON.stringify({ filters: activeFilters(agentA).length, kept: keptOf(agentA) }))
738
+
739
+ const agentB = makeAgent(undefined, { id: 'session-b' })
740
+ fireCreated(agentB)
741
+ check('a later conversation starts on the saved default',
742
+ !keptOf(agentB).includes('web_search') && keptOf(agentB).includes('read'), keptOf(agentB).join(','))
743
+ check('a later conversation records the default', storeOf().assignments['session-b'] === 'coding',
744
+ JSON.stringify(storeOf().assignments))
745
+
746
+ const agentFork = makeAgent(undefined, { id: 'session-fork', parentSession: 'session-b' })
747
+ fireCreated(agentFork)
748
+ check('a fork inherits its parent binding', storeOf().assignments['session-fork'] === 'coding',
749
+ JSON.stringify(storeOf().assignments))
750
+
751
+ const agentChild = makeAgent(undefined, { id: 'sub-1', origin: 'subagent' })
752
+ fireCreated(agentChild)
753
+ check('a subagent gets no binding', storeOf().assignments['sub-1'] === undefined,
754
+ JSON.stringify(storeOf().assignments))
755
+
756
+ const assigned = await ctxV2.registered.get('kit_assign').execute({ sessionId: 'session-b', kit: 'mine' }, exec(agentB))
757
+ check('kit_assign pins the conversation', assigned.ok !== false && storeOf().assignments['session-b'] === 'mine',
758
+ JSON.stringify({ assigned, assignments: storeOf().assignments }))
759
+
760
+ const dryDelete = await ctxV2.registered.get('kit_delete').execute({ name: 'mine', dryRun: true }, exec(agentB))
761
+ check('kit_delete --dryRun counts without deleting',
762
+ dryDelete.affectedSessions === 1 && storeOf().kits.some((kit) => kit.name === 'mine'),
763
+ JSON.stringify(dryDelete))
764
+
765
+ const deleted = await ctxV2.registered.get('kit_delete').execute({ name: 'mine' }, exec(agentB))
766
+ check('kit_delete clears the bindings it owned',
767
+ deleted.deleted === true && storeOf().assignments['session-b'] === null,
768
+ JSON.stringify({ deleted, assignments: storeOf().assignments }))
769
+
770
+ const createdV2 = await ctxV2.registered.get('kit_create').execute(
771
+ { name: 'k-one', title: '一号', builtins: ['read'], plugins: [] }, exec(agentB))
772
+ const renamed = await ctxV2.registered.get('kit_rename').execute({ name: 'k-one', title: '改过的一号' }, exec(agentB))
773
+ const listedV2 = await ctxV2.registered.get('kit_list').execute({}, exec(agentB))
774
+ check('kit_rename changes the title and keeps the id',
775
+ renamed.ok !== false && listedV2.kits.some((kit) => kit.name === 'k-one' && kit.title === '改过的一号'),
776
+ JSON.stringify({ createdV2, renamed, kits: listedV2.kits.map((kit) => [kit.name, kit.title]) }))
777
+ check('kit_list reports the binding', listedV2.binding === null || listedV2.binding === '',
778
+ JSON.stringify(listedV2.binding))
779
+
780
+ let reservedError
781
+ try {
782
+ await ctxV2.registered.get('kit_create').execute({ name: '全部', title: '占位', builtins: ['read'], plugins: [] }, exec(agentB))
783
+ } catch (error) {
784
+ reservedError = String(error?.message ?? error)
785
+ }
786
+ check('a reserved name is refused', /全部/u.test(reservedError ?? ''), reservedError)
787
+
788
+ process.env.USERPROFILE = savedHomeV2
789
+ process.env.HOME = savedHomeV2b
790
+ rmSync(v2Home, { recursive: true, force: true })
791
+
657
792
  // ---- legacy store: plugin-kits.json is read once ---------------------------
658
793
  const legacyHome = join(tmpdir(), `loadout-legacy-${process.pid}`)
659
794
  rmSync(legacyHome, { recursive: true, force: true })
@@ -670,7 +805,10 @@ const legacyModule = await import('../index.js?legacy=1')
670
805
  const ctx3 = makeCtx()
671
806
  legacyModule.apply(ctx3, config)
672
807
  const legacyListed = await ctx3.registered.get('kit_list').execute({}, exec(makeAgent()))
673
- check('legacy store is read', legacyListed.source === join(legacyHome, '.dsh', 'plugin-kits.json'), legacyListed.source)
808
+ // Migration copies the legacy kits into `loadout.json` and saves immediately, so
809
+ // the file the user's kits live in is the new one from the first boot. What must
810
+ // survive is the data (kits + default), not the old path string.
811
+ check('legacy store is migrated', legacyListed.source === join(legacyHome, '.dsh', 'loadout.json'), legacyListed.source)
674
812
  check('legacy kit survives', legacyListed.kits.some((kit) => kit.name === 'old'), JSON.stringify(legacyListed.kits.map((kit) => kit.name)))
675
813
  check('legacy default survives', legacyListed.default === 'old', legacyListed.default)
676
814
  await ctx3.registered.get('kit_set_default').execute({ name: 'coding' }, exec(makeAgent()))