dsh-loadout 0.11.2 → 0.12.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/index.js CHANGED
@@ -14,13 +14,27 @@
14
14
  * dsh → catalog built-ins ∪ tools(extras) ∪ OWN_TOOLS
15
15
  * kit → tools(kit.builtins) ∪ tools(kit.plugins ∪ extras) ∪ OWN_TOOLS
16
16
  *
17
- * Presets (写作 / 调研 / 编程) ship in `cordis.patch.yml` and are read-only.
18
- * User kits live in `~/.dsh/loadout.json`:
17
+ * Presets (仅聊天 / 编程用 / 查找资料 / 项目梳理 / 写作) ship in `cordis.patch.yml`
18
+ * and are read-only. User kits live in `~/.dsh/loadout.json`:
19
19
  *
20
- * { "kits": [{ "name", "title", "builtins", "plugins" }], "defaultKit": "" }
20
+ * {
21
+ * "schema": 2,
22
+ * "kits": [{ "name", "title", "note", "builtins", "plugins" }],
23
+ * "defaultKit": "",
24
+ * "assignments": { "<sessionId>": "kit-name" | "@dsh" | null },
25
+ * "migratedAt": "2026-11-02T13:00:00.000Z"
26
+ * }
27
+ *
28
+ * `assignments` is what makes a conversation's choice last: every conversation
29
+ * gets ONE persisted binding when it is created (never re-derived afterwards),
30
+ * so changing the default only affects conversations created later, and an
31
+ * explicit switch survives a restart. `null` means 全部 — an explicit choice, not
32
+ * "no answer". `migratedAt` lets a conversation that predates the upgrade keep
33
+ * the state it effectively had instead of inheriting today's default.
21
34
  *
22
35
  * Three surfaces share the same store and the same operations:
23
- * - tools: kit_list / kit_apply / kit_create / kit_set_tools / kit_set_default / kit_delete
36
+ * - tools: kit_list / kit_apply / kit_create / kit_set_tools / kit_set_default
37
+ * / kit_delete / kit_assign / kit_rename
24
38
  * - chat: the `/kit [name]` command
25
39
  * - web UI: the header chip and 设置 → 装备栏, served under /dsh-loadout/
26
40
  *
@@ -35,7 +49,7 @@
35
49
  * @module dsh-loadout
36
50
  */
37
51
 
38
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs'
52
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs'
39
53
  import { homedir } from 'node:os'
40
54
  import { dirname, join } from 'node:path'
41
55
  import { fileURLToPath } from 'node:url'
@@ -45,7 +59,7 @@ export const name = 'dsh-loadout'
45
59
  /** Cordis waits for the tool registry before applying this plugin. */
46
60
  export const inject = ['tools']
47
61
 
48
- const OWN_TOOLS = ['kit_list', 'kit_apply', 'kit_create', 'kit_set_tools', 'kit_set_default', 'kit_delete']
62
+ const OWN_TOOLS = ['kit_list', 'kit_apply', 'kit_create', 'kit_set_tools', 'kit_set_default', 'kit_delete', 'kit_assign', 'kit_rename']
49
63
 
50
64
  /** Runtime store. Profile-independent on purpose: one place, easy to back up. */
51
65
  const STORE_PATH = join(homedir(), '.dsh', 'loadout.json')
@@ -54,11 +68,39 @@ const STORE_PATH = join(homedir(), '.dsh', 'loadout.json')
54
68
  const LEGACY_STORE_PATH = join(homedir(), '.dsh', 'plugin-kits.json')
55
69
 
56
70
  /**
57
- * Version of the on-disk shape. Files written before 0.11.0 have no `schema`
58
- * field and are read as 0; a shape change bumps this and is migrated in
59
- * `normalizeStore`, so a release never has to guess what it is looking at.
71
+ * Version of the on-disk shape. 2 introduced `assignments` (one persisted
72
+ * binding per conversation) and `migratedAt`; 1 and 0 are the same kit-only
73
+ * shape and are upgraded by `migrateBook`. A shape change bumps this, so a
74
+ * release never has to guess what it is looking at.
75
+ */
76
+ const STORE_SCHEMA = 2
77
+
78
+ /**
79
+ * Names a user kit may not take: the two built-in choices, the words the UI
80
+ * shows for "no kit", and the shipped preset names (checked separately because
81
+ * they come from the bundle). `title` is free text; only `name` is an id.
82
+ */
83
+ const RESERVED_NAMES = ['全部', '全部工具', '仅内置', '自定义', '默认', 'all', 'builtin', 'custom', 'default']
84
+
85
+ const NAME_MAX = 32
86
+
87
+ /** Ids the UI parses, so a kit may not look like a path or a glob. */
88
+ const NAME_FORBIDDEN = /[\\/:*?"<>|\u0000-\u001f\u007f]/u
89
+
90
+ /**
91
+ * Normalize and validate a kit id. NFKC folds full-width letters (`chat` →
92
+ * `chat`), which otherwise produce two ids that look identical in the menu.
60
93
  */
61
- const STORE_SCHEMA = 1
94
+ function assertKitName(raw, presets) {
95
+ const value = typeof raw === 'string' ? raw.trim().normalize('NFKC') : ''
96
+ if (value === '') throw new Error('名字不能为空')
97
+ if (value.length > NAME_MAX) throw new Error(`名字最多 ${NAME_MAX} 个字符`)
98
+ if (NAME_FORBIDDEN.test(value)) throw new Error('名字里不能有 / \\ : * ? " < > | 或控制字符')
99
+ const lower = value.toLowerCase()
100
+ if (RESERVED_NAMES.includes(lower)) throw new Error(`"${value}" 是保留名字,请换一个名字`)
101
+ if (presets !== undefined && findKit(presets, value) !== undefined) throw new Error(`"${value}" 与预置套装同名,请换一个名字`)
102
+ return value
103
+ }
62
104
 
63
105
  /**
64
106
  * One-line reminder on a kit ("套装1(用于视频制作)"). It rides in the menu next
@@ -130,22 +172,44 @@ function normalizeKits(rows) {
130
172
  * Normalize the on-disk store. Missing arrays, unknown fields and a bare array
131
173
  * (a hand-written store) all degrade to an empty, still-usable book.
132
174
  *
133
- * `schema` records what the file claimed. 0 means "written before 0.11.0", which
134
- * is the same shape as 1 apart from the missing field, so it is upgraded on the
135
- * next save. Unknown future versions are still parsed by their known fields
136
- * rather than rejected — losing a user's kits over a number would be worse than
137
- * reading what we do understand.
175
+ * `schema` records what the file claimed. 0/1 mean "written before 0.11.x" and
176
+ * carry no bindings; `migrateBook` stamps `migratedAt` and lets conversations
177
+ * that already existed keep the state they effectively had. Unknown future
178
+ * versions are still parsed by their known fields rather than rejected — losing
179
+ * a user's kits over a number would be worse than reading what we understand.
138
180
  */
139
181
  function normalizeStore(value) {
140
- if (Array.isArray(value)) return { schema: 0, kits: normalizeKits(value), defaultKit: '' }
182
+ if (Array.isArray(value)) {
183
+ return { schema: 0, kits: normalizeKits(value), defaultKit: '', assignments: {}, migratedAt: undefined }
184
+ }
141
185
  if (typeof value !== 'object' || value === null) return undefined
142
186
  return {
143
187
  schema: Number.isInteger(value.schema) ? value.schema : 0,
144
188
  kits: normalizeKits(value.kits),
145
189
  defaultKit: typeof value.defaultKit === 'string' ? value.defaultKit.trim() : '',
190
+ assignments: normalizeAssignments(value.assignments),
191
+ migratedAt: typeof value.migratedAt === 'string' && !Number.isNaN(Date.parse(value.migratedAt))
192
+ ? value.migratedAt
193
+ : undefined,
146
194
  }
147
195
  }
148
196
 
197
+ /**
198
+ * Session id → binding. `null` is a real value here (全部), so a missing key and
199
+ * a stored `null` must stay distinguishable: `hasOwnProperty` decides, never a
200
+ * truthiness test.
201
+ */
202
+ function normalizeAssignments(value) {
203
+ const out = {}
204
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return out
205
+ for (const [key, entry] of Object.entries(value)) {
206
+ if (typeof key !== 'string' || key === '') continue
207
+ if (entry === null) out[key] = null
208
+ else if (typeof entry === 'string' && entry.trim() !== '') out[key] = entry.trim()
209
+ }
210
+ return out
211
+ }
212
+
149
213
  function readStore() {
150
214
  // New file first, then the pre-0.5 copy: a rename must not silently drop the
151
215
  // kits someone already saved. The legacy file is never written again, so the
@@ -254,9 +318,18 @@ export function apply(ctx, config = {}) {
254
318
  const presets = normalizeKits(config?.kits)
255
319
  const loaded = readStore()
256
320
  /** My kits: the user-created ones only. Presets are never stored here. */
321
+ /**
322
+ * My kits: the user-created ones only. Presets are never stored here.
323
+ *
324
+ * `assignments` is the v2 binding table: one entry per conversation, written
325
+ * once and then only on an explicit switch. `migratedAt` marks the upgrade, so
326
+ * a conversation older than it keeps the state it effectively had.
327
+ */
257
328
  const book = {
258
329
  kits: migrateUserKits(loaded?.store.kits ?? [], presets),
259
330
  defaultKit: loaded?.store.defaultKit ?? (typeof config?.defaultKit === 'string' ? config.defaultKit.trim() : ''),
331
+ assignments: loaded?.store.assignments ?? {},
332
+ migratedAt: loaded?.store.migratedAt,
260
333
  // The file the kits actually came from: the legacy path on the first boot
261
334
  // after the rename, the new one afterwards.
262
335
  source: loaded?.path ?? 'bundle 配置',
@@ -266,7 +339,7 @@ export function apply(ctx, config = {}) {
266
339
  writeStore({
267
340
  // Stamped so a future release can migrate instead of guessing. Files from
268
341
  // 0.10.x and earlier have no `schema`; normalizeStore reads them as 0 and
269
- // the first save upgrades them.
342
+ // migrateBook upgrades them.
270
343
  schema: STORE_SCHEMA,
271
344
  kits: book.kits.map((kit) => ({
272
345
  name: kit.name,
@@ -279,10 +352,74 @@ export function apply(ctx, config = {}) {
279
352
  note: kit.note ?? '',
280
353
  })),
281
354
  defaultKit: book.defaultKit,
355
+ assignments: book.assignments,
356
+ ...(book.migratedAt === undefined ? {} : { migratedAt: book.migratedAt }),
282
357
  })
283
- book.source = STORE_PATH
358
+ // `source` answers "where do the user's kits live". Writing assignments or a
359
+ // default is bookkeeping, not a kit, so it does not turn a fresh install's
360
+ // 「bundle 配置」 into a file path; saving a kit does.
361
+ if (book.kits.length > 0) book.source = STORE_PATH
362
+ }
363
+
364
+ /** Session ids that already exist on disk (conversations only, not subagents). */
365
+ function knownConversations() {
366
+ const root = join(homedir(), '.dsh', 'sessions')
367
+ const out = []
368
+ try {
369
+ for (const workspace of readdirSync(root, { withFileTypes: true })) {
370
+ if (!workspace.isDirectory()) continue
371
+ const dir = join(root, workspace.name)
372
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
373
+ if (entry.isDirectory() && entry.name.startsWith('session-')) out.push(entry.name)
374
+ }
375
+ }
376
+ } catch {
377
+ // No sessions directory yet: a fresh install has nothing to migrate.
378
+ }
379
+ return out
380
+ }
381
+
382
+ /**
383
+ * Upgrade the store to schema 2 exactly once.
384
+ *
385
+ * Before v2 a conversation had no stored choice, so whatever it showed was
386
+ * inherited from the default at the moment it was created. Freezing that as
387
+ * `null` (全部) for the conversations that already exist is the honest reading:
388
+ * it changes nobody's tools today, and it stops a later default change from
389
+ * reaching back into conversations the user never touched. The pre-upgrade
390
+ * file is copied aside first, so a wrong guess is undoable by hand.
391
+ */
392
+ function migrateBook() {
393
+ if (book.migratedAt !== undefined) return
394
+ if (loaded !== undefined && loaded.store.schema >= STORE_SCHEMA) {
395
+ book.migratedAt = new Date().toISOString()
396
+ save()
397
+ return
398
+ }
399
+ try {
400
+ if (loaded?.path !== undefined && existsSync(loaded.path)) {
401
+ copyFileSync(loaded.path, `${loaded.path}.v1.bak-${Date.now()}`)
402
+ }
403
+ } catch {
404
+ // A backup is best-effort; the migration itself still has to run.
405
+ }
406
+ book.migratedAt = new Date().toISOString()
407
+ for (const sessionId of knownConversations()) {
408
+ if (!Object.prototype.hasOwnProperty.call(book.assignments, sessionId)) book.assignments[sessionId] = null
409
+ }
410
+ // Writing the upgraded file must not make an untouched install look as if the
411
+ // user had saved kits there: `source` is what the UI prints, and a fresh
412
+ // install with nothing of the user's own still reads 「bundle 配置」.
413
+ const before = book.source
414
+ save()
415
+ if (book.kits.length === 0 && book.defaultKit === '') book.source = before
416
+ if (knownConversations().length > 0) {
417
+ ctx.logger?.info('dsh-loadout: 升级到 schema 2,已有对话保持原状(全部)')
418
+ }
284
419
  }
285
420
 
421
+ migrateBook()
422
+
286
423
  /** agent -> { selection: {base,kit,extras}, label, dispose } */
287
424
  const state = new Map()
288
425
 
@@ -296,6 +433,11 @@ export function apply(ctx, config = {}) {
296
433
  * so this is captured through an optional injection instead of read directly.
297
434
  */
298
435
  let sessionController
436
+ /**
437
+ * The session service, used only to read a Session's header (createdAt /
438
+ * parentSession / origin). Optional for the same reason as sessionController.
439
+ */
440
+ let sessions
299
441
  let lastAgent
300
442
  /** Every tool name this profile can expose; filled by learnTools below. */
301
443
  const knownTools = new Set(OWN_TOOLS)
@@ -316,10 +458,91 @@ export function apply(ctx, config = {}) {
316
458
  return { base: BASE_ALL, kit: null, extras: [] }
317
459
  }
318
460
 
461
+ /** The Session id behind an agent; `Agent.id` is the Session id. */
462
+ function sessionIdOf(agent) {
463
+ const id = agent?.id ?? agent?.session?.id
464
+ return typeof id === 'string' && id !== '' ? id : undefined
465
+ }
466
+
467
+ /** The Session header (createdAt / parentSession / origin), when available. */
468
+ function sessionHeaderOf(sessionId) {
469
+ if (sessionId === undefined || sessions === undefined) return undefined
470
+ try {
471
+ return sessions.get?.(sessionId)?.header
472
+ } catch {
473
+ return undefined
474
+ }
475
+ }
476
+
477
+ /** `hasOwnProperty`, because a stored `null` (全部) is a real answer. */
478
+ function bindingOf(sessionId) {
479
+ if (typeof sessionId !== 'string') return undefined
480
+ return Object.prototype.hasOwnProperty.call(book.assignments, sessionId)
481
+ ? book.assignments[sessionId]
482
+ : undefined
483
+ }
484
+
485
+ function selectionForBinding(value) {
486
+ if (value === undefined || value === null) return emptySelection()
487
+ if (value === DEFAULT_DSH) return { base: BASE_DSH, kit: null, extras: [] }
488
+ return { base: BASE_KIT, kit: value, extras: [] }
489
+ }
490
+
491
+ function bindingForSelection(selection) {
492
+ if (selection.base === BASE_DSH) return DEFAULT_DSH
493
+ if (selection.base === BASE_KIT) return selection.kit ?? null
494
+ return null
495
+ }
496
+
497
+ /** What a conversation created right now would start from. */
498
+ function defaultBinding() {
499
+ const wanted = book.defaultKit
500
+ if (wanted === '') return null
501
+ if (wanted === DEFAULT_DSH) return DEFAULT_DSH
502
+ return resolveKit(lastAgent, wanted) === undefined ? null : wanted
503
+ }
504
+
505
+ /**
506
+ * Give this conversation its ONE binding, if it has none yet.
507
+ *
508
+ * Called lazily (first state read, first tool call) as well as on
509
+ * `agent/created`, because the tool layer may not be readable yet when
510
+ * `agent/created` fires. Three rules:
511
+ * - a fork inherits its parent's binding (the parent's context is what the
512
+ * child was cut from; re-deriving it from today's default would change
513
+ * the tools mid-fork);
514
+ * - a conversation that predates the upgrade keeps the state it effectively
515
+ * had (全部) instead of inheriting today's default;
516
+ * - a subagent gets no entry at all (R11) — its parent's filters already
517
+ * shape what it can see, and it must not pin a kit for itself.
518
+ */
519
+ function ensureBinding(agent) {
520
+ const sessionId = sessionIdOf(agent)
521
+ if (sessionId === undefined) return undefined
522
+ const existing = bindingOf(sessionId)
523
+ if (existing !== undefined) return existing
524
+ const header = sessionHeaderOf(sessionId)
525
+ if (header?.origin === 'subagent') return undefined
526
+ let value
527
+ const parentBinding = header?.parentSession === undefined ? undefined : bindingOf(header.parentSession)
528
+ if (parentBinding !== undefined) value = parentBinding
529
+ else if (book.migratedAt !== undefined && typeof header?.createdAt === 'number'
530
+ && header.createdAt < Date.parse(book.migratedAt)) value = null
531
+ else value = defaultBinding()
532
+ book.assignments[sessionId] = value
533
+ save()
534
+ return value
535
+ }
536
+
319
537
  function currentSelection(agent) {
320
538
  const entry = agent === undefined || agent === null ? undefined : state.get(agent)
321
539
  const raw = entry?.selection
322
- if (raw === undefined) return emptySelection()
540
+ if (raw === undefined) {
541
+ // No live restriction yet: fall back to the persisted binding, so a
542
+ // restarted app and a fresh HTTP read agree with what the chip shows.
543
+ const bound = bindingOf(sessionIdOf(agent))
544
+ return bound === undefined ? emptySelection() : selectionForBinding(bound)
545
+ }
323
546
  const base = raw.base === BASE_DSH || raw.base === BASE_KIT ? raw.base : BASE_ALL
324
547
  if (base === BASE_ALL) return emptySelection()
325
548
  if (base === BASE_DSH) return { base: BASE_DSH, kit: null, extras: dedupe(asNames(raw.extras)) }
@@ -756,7 +979,7 @@ export function apply(ctx, config = {}) {
756
979
  * Apply one choice to one agent. Every surface (menu, settings page, /kit)
757
980
  * funnels through here so the three never disagree.
758
981
  */
759
- function useSelection(agent, selection) {
982
+ function useSelection(agent, selection, options = {}) {
760
983
  if (agent?.ctx?.tools === undefined) return { ok: false, reason: '这个会话没有可用的工具作用域(agent.ctx.tools)' }
761
984
  const normalized = normalizeSelection(agent, selection)
762
985
  if (normalized === undefined) {
@@ -774,6 +997,17 @@ export function apply(ctx, config = {}) {
774
997
  const result = applyMask(agent, keep)
775
998
  if (!result.ok) return result
776
999
  state.set(agent, { selection: normalized, label, dispose: result.dispose })
1000
+ // An explicit switch is the only thing that rewrites this conversation's
1001
+ // binding (ensureBinding writes the first one). Everything else — a default
1002
+ // change, a restart — must leave it alone.
1003
+ const sessionId = sessionIdOf(agent)
1004
+ if (sessionId !== undefined && options.persist !== false) {
1005
+ const next = bindingForSelection(normalized)
1006
+ if (bindingOf(sessionId) !== next) {
1007
+ book.assignments[sessionId] = next
1008
+ save()
1009
+ }
1010
+ }
777
1011
  if (result.bypassed.length > 0) {
778
1012
  ctx.logger?.warn(`dsh-loadout: 隐藏 ${result.hidden.length} 个工具,其中 ${result.bypassed.length} 个走工具层遮罩(${result.bypassed.join(', ')})`)
779
1013
  }
@@ -874,39 +1108,47 @@ export function apply(ctx, config = {}) {
874
1108
  }
875
1109
 
876
1110
  /**
877
- * Apply the configured default kit to an agent that has no selection yet.
1111
+ * Does the live selection already come from this binding?
1112
+ *
1113
+ * Only the base and the kit are compared: 加挂 (extras) are session-local and
1114
+ * deliberately NOT persisted to `assignments`, so a state read that treated a
1115
+ * different extras list as "the binding changed" would wipe them — the exact
1116
+ * regression this comparison exists to prevent.
1117
+ */
1118
+ function sameSelection(left, right) {
1119
+ return left.base === right.base
1120
+ && (left.kit ?? null) === (right.kit ?? null)
1121
+ }
1122
+
1123
+ /**
1124
+ * Make this conversation's tool layer match its persisted binding.
878
1125
  *
879
- * `agent/created` is the natural place for this, but it fires while the agent
880
- * is still being built: its tool layer may not be readable yet, and a failed
881
- * mask there is indistinguishable from "no default configured". 0.9.x shipped
882
- * exactly that silent no-op — new conversations always started at 全部 even
883
- * with a default saved. Repeating the same idempotent step from the first
884
- * state read closes the gap without timers: the UI fetches state before the
885
- * first message, and `state.has(agent)` keeps an explicit choice from being
886
- * overwritten.
1126
+ * `ensureBinding` owns the "what does this conversation start from" decision;
1127
+ * this function only applies it. It is idempotent (once base+kit match, nothing
1128
+ * is re-applied), and it runs from `agent/created`, from one deferred retry
1129
+ * (the tool layer can attach a tick after creation), and from `snapshot()` —
1130
+ * the UI reads state before the first message, which closes the 0.9.x window
1131
+ * where a saved default silently did nothing.
887
1132
  */
888
- function ensureDefault(agent) {
1133
+ function ensureBindingApplied(agent) {
889
1134
  if (agent === undefined || agent === null) return
890
- if (state.has(agent)) return
891
- if (book.defaultKit === '') return
892
- if (book.defaultKit === DEFAULT_DSH) {
893
- const result = useSelection(agent, { base: BASE_DSH, extras: [] })
894
- if (!result.ok) ctx.logger?.warn(`dsh-loadout: 新对话的「仅内置」默认未应用:${result.reason}`)
895
- return
896
- }
897
- const kit = resolveKit(agent, book.defaultKit)
898
- if (kit === undefined) return
899
- const result = useSelection(agent, { base: BASE_KIT, kit: kit.name, extras: [] })
900
- if (!result.ok) ctx.logger?.warn(`dsh-loadout: 默认套装 "${kit.name}" 未应用:${result.reason}`)
1135
+ const value = ensureBinding(agent)
1136
+ if (value === undefined) return
1137
+ const selection = selectionForBinding(value)
1138
+ const current = state.get(agent)?.selection
1139
+ if (current !== undefined && sameSelection(current, selection)) return
1140
+ // The binding is already stored by ensureBinding; applying it must not write
1141
+ // it again (that would turn a default into an explicit choice).
1142
+ const result = useSelection(agent, selection, { persist: false })
1143
+ if (!result.ok) ctx.logger?.warn(`dsh-loadout: 会话绑定 ${labelOf(agent, selection)} 未应用:${result.reason}`)
901
1144
  }
902
1145
 
903
1146
  ctx.on('agent/created', (agent) => {
904
1147
  rememberAgent(agent)
905
- ensureDefault(agent)
1148
+ ensureBindingApplied(agent)
906
1149
  // The tool layer can attach a tick after creation; one deferred retry covers
907
- // that race without putting timers on any hot path (ensureDefault is
908
- // idempotent and does nothing once the agent has a selection).
909
- const retry = setTimeout(() => ensureDefault(agent), 100)
1150
+ // that race without putting timers on any hot path.
1151
+ const retry = setTimeout(() => ensureBindingApplied(agent), 100)
910
1152
  if (typeof retry?.unref === 'function') retry.unref()
911
1153
  })
912
1154
 
@@ -1114,6 +1356,13 @@ export function apply(ctx, config = {}) {
1114
1356
  }
1115
1357
 
1116
1358
  // ---- shared operations, used by tools, the command and the routes --------
1359
+ /** How many conversations are pinned to this kit (a delete warning quotes it). */
1360
+ function boundCountOf(name) {
1361
+ let count = 0
1362
+ for (const value of Object.values(book.assignments)) if (value === name) count += 1
1363
+ return count
1364
+ }
1365
+
1117
1366
  function kitRow(kit) {
1118
1367
  const parts = kitParts(kit)
1119
1368
  return {
@@ -1125,15 +1374,16 @@ export function apply(ctx, config = {}) {
1125
1374
  allow: rawKitAllow(kit),
1126
1375
  description: describeKit(kit),
1127
1376
  isDefault: kit.name === book.defaultKit,
1377
+ boundCount: boundCountOf(kit.name),
1128
1378
  }
1129
1379
  }
1130
1380
 
1131
1381
  function snapshot(agent = lastAgent) {
1132
- // A new conversation may reach its first state read before the creation
1133
- // listener could install the default (the tool layer is not readable yet at
1134
- // that moment). Retrying here means the chip shows, and the first message
1135
- // uses, the configured default instead of 全部.
1136
- ensureDefault(agent)
1382
+ // A conversation may reach its first state read before the creation listener
1383
+ // could install its binding (the tool layer is not readable yet at that
1384
+ // moment). Retrying here means the chip shows, and the first message uses,
1385
+ // the binding instead of 全部.
1386
+ ensureBindingApplied(agent)
1137
1387
  const selection = currentSelection(agent)
1138
1388
  const appliedPlugins = appliedPluginsFor(agent, selection)
1139
1389
  const availableExtras = availableExtrasFor(agent, selection)
@@ -1166,6 +1416,10 @@ export function apply(ctx, config = {}) {
1166
1416
  presetNames: presets.map((kit) => kit.name),
1167
1417
  kits: book.kits.map((kit) => priced(kitRow(kit))),
1168
1418
  defaultKit: book.defaultKit,
1419
+ // This conversation's own binding: `null` (全部), '@dsh', a kit name, or
1420
+ // '' when the conversation has none (a subagent). The chip reads this
1421
+ // instead of inferring "no kit" from the live label.
1422
+ binding: bindingOf(sessionIdOf(agent)) ?? '',
1169
1423
  ledger,
1170
1424
  catalog,
1171
1425
  tools: toolCatalog(),
@@ -1200,8 +1454,8 @@ export function apply(ctx, config = {}) {
1200
1454
  const kit = normalizeKit(input ?? {})
1201
1455
 
1202
1456
  if (kit === undefined) throw new Error('名字不能为空')
1457
+ kit.name = assertKitName(kit.name, presets)
1203
1458
  if (findKit(book.kits, kit.name) !== undefined) throw new Error(`套装 "${kit.name}" 已存在`)
1204
- if (findKit(presets, kit.name) !== undefined) throw new Error(`"${kit.name}" 与预置套装同名,请换一个名字`)
1205
1459
  if (Array.isArray(input?.builtins) || Array.isArray(input?.plugins)) {
1206
1460
  kit.builtins = dedupe(asNames(input.builtins))
1207
1461
  kit.plugins = dedupe(asNames(input.plugins))
@@ -1227,10 +1481,15 @@ export function apply(ctx, config = {}) {
1227
1481
  }
1228
1482
  const oldName = kit.name
1229
1483
  if (typeof input.rename === 'string' && input.rename.trim() !== '' && input.rename.trim() !== kit.name) {
1230
- if (findKit(book.kits, input.rename) !== undefined) throw new Error(`套装 "${input.rename}" 已存在`)
1231
- if (findKit(presets, input.rename) !== undefined) throw new Error(`"${input.rename}" 与预置套装同名,请换一个名字`)
1232
- if (book.defaultKit === kit.name) book.defaultKit = input.rename.trim()
1233
- kit.name = input.rename.trim()
1484
+ const nextName = assertKitName(input.rename, presets)
1485
+ if (findKit(book.kits, nextName) !== undefined) throw new Error(`套装 "${nextName}" 已存在`)
1486
+ if (book.defaultKit === kit.name) book.defaultKit = nextName
1487
+ // A rename moves the identity, so every conversation pinned to the old
1488
+ // name follows it — otherwise those conversations would read as 全部.
1489
+ for (const [sessionId, value] of Object.entries(book.assignments)) {
1490
+ if (value === oldName) book.assignments[sessionId] = nextName
1491
+ }
1492
+ kit.name = nextName
1234
1493
  }
1235
1494
  if (typeof input.title === 'string' && input.title.trim() !== '') kit.title = input.title.trim()
1236
1495
  if (typeof input.note === 'string') kit.note = clampNote(input.note)
@@ -1257,7 +1516,7 @@ export function apply(ctx, config = {}) {
1257
1516
  throw new Error('至少要选一个内置工具或一个插件,否则这个包等于"全部"')
1258
1517
  }
1259
1518
  if (oldName !== kit.name) {
1260
- for (const [agent, entry] of state) {
1519
+ for (const [agent, entry] of [...state]) {
1261
1520
  if (entry.selection.base === BASE_KIT && entry.selection.kit === oldName) {
1262
1521
  entry.selection = { ...entry.selection, kit: kit.name }
1263
1522
  entry.label = labelOf(agent, entry.selection)
@@ -1267,7 +1526,11 @@ export function apply(ctx, config = {}) {
1267
1526
  // Editing the tools of the kit a conversation is running has to change that
1268
1527
  // conversation now: a saved edit that only takes effect after the next
1269
1528
  // switch reads as "the edit did nothing".
1270
- for (const [agent, entry] of state) {
1529
+ //
1530
+ // Iterate a snapshot: `useSelection` deletes and re-adds the agent's entry,
1531
+ // and a Map iterator that is handed back an entry it already passed never
1532
+ // finishes (that is a hang, not a slow loop).
1533
+ for (const [agent, entry] of [...state]) {
1271
1534
  if (entry.selection.base === BASE_KIT && entry.selection.kit === kit.name) {
1272
1535
  useSelection(agent, { base: BASE_KIT, kit: kit.name, extras: [...entry.selection.extras] })
1273
1536
  }
@@ -1276,21 +1539,33 @@ export function apply(ctx, config = {}) {
1276
1539
  return kit
1277
1540
  },
1278
1541
  remove(input) {
1542
+ const dryRun = input?.dryRun === true
1279
1543
  const wanted = String(input?.name ?? '')
1280
1544
  const kit = findKit(book.kits, wanted)
1281
1545
  if (kit === undefined) {
1282
1546
  if (findKit(presets, wanted) !== undefined) throw new Error('预置套装只读,请另存为我的套装')
1283
1547
  throw new Error(`未知套装 "${wanted}"`)
1284
1548
  }
1549
+ // Quoted by the delete confirmation ("N 个对话正在用它") and by the tool's
1550
+ // dry run, so a destructive click is never a surprise.
1551
+ const affected = boundCountOf(kit.name)
1552
+ if (dryRun) return { kit, affected }
1285
1553
  book.kits = book.kits.filter((row) => row !== kit)
1286
1554
  if (book.defaultKit === kit.name) book.defaultKit = ''
1287
- for (const [agent, entry] of state) {
1555
+ // A binding is an explicit answer; the kit is gone, so the honest answer is
1556
+ // 全部 (null) — never "inherit whatever the default is today".
1557
+ for (const [sessionId, value] of Object.entries(book.assignments)) {
1558
+ if (value === kit.name) book.assignments[sessionId] = null
1559
+ }
1560
+ // Snapshot for the same reason as `update`: each `useSelection` rewrites the
1561
+ // map entry it belongs to.
1562
+ for (const [agent, entry] of [...state]) {
1288
1563
  if (entry.selection.base === BASE_KIT && entry.selection.kit === kit.name) {
1289
1564
  useSelection(agent, emptySelection())
1290
1565
  }
1291
1566
  }
1292
1567
  save()
1293
- return kit
1568
+ return { kit, affected }
1294
1569
  },
1295
1570
  setDefault(input) {
1296
1571
  const wanted = typeof input?.name === 'string' ? input.name.trim() : ''
@@ -1322,32 +1597,36 @@ export function apply(ctx, config = {}) {
1322
1597
  base: { type: 'string' },
1323
1598
  extras: { type: 'array', items: { type: 'string' } },
1324
1599
  default: { type: 'string' },
1600
+ binding: { type: 'string' },
1325
1601
  source: { type: 'string' },
1326
1602
  kits: {
1327
1603
  type: 'array',
1328
1604
  items: outputSchema({
1329
1605
  name: { type: 'string' },
1330
1606
  title: { type: 'string' },
1607
+ note: { type: 'string' },
1331
1608
  tools: { type: 'array', items: { type: 'string' } },
1332
- }, ['name', 'title', 'tools']),
1609
+ boundCount: { type: 'number' },
1610
+ }, ['name', 'title', 'tools', 'boundCount']),
1333
1611
  },
1334
1612
  presets: {
1335
1613
  type: 'array',
1336
1614
  items: outputSchema({
1337
1615
  name: { type: 'string' },
1338
1616
  title: { type: 'string' },
1617
+ note: { type: 'string' },
1339
1618
  tools: { type: 'array', items: { type: 'string' } },
1340
1619
  }, ['name', 'title', 'tools']),
1341
1620
  },
1342
- }, ['active', 'base', 'extras', 'default', 'source', 'kits', 'presets']),
1621
+ }, ['active', 'base', 'extras', 'default', 'binding', 'source', 'kits', 'presets']),
1343
1622
  render: (_args, value) => text([
1344
1623
  `当前:${value.active === '' ? '(未设置)' : value.active}(base=${value.base}${value.extras.length > 0 ? `,加挂 ${value.extras.length} 个` : ''})`,
1345
1624
  `默认套装:${value.default === '' ? '(未设置)' : value.default}`,
1346
1625
  `存储:${value.source}`,
1347
1626
  '我的套装:',
1348
- ...(value.kits.length === 0 ? ['- (还没有)'] : value.kits.map((kit) => `- ${kit.name} (${kit.title}): ${kit.tools.length > 0 ? kit.tools.join(', ') : '全部工具'}`)),
1627
+ ...(value.kits.length === 0 ? ['- (还没有)'] : value.kits.map((kit) => `- ${kit.name} (${kit.title})${kit.note ? `(${kit.note})` : ''}: ${kit.tools.length > 0 ? kit.tools.join(', ') : '全部工具'}`)),
1349
1628
  '预设套装(只读):',
1350
- ...(value.presets.length === 0 ? ['- (无)'] : value.presets.map((kit) => `- ${kit.name} (${kit.title}): ${kit.tools.length > 0 ? kit.tools.join(', ') : '全部工具'}`)),
1629
+ ...(value.presets.length === 0 ? ['- (无)'] : value.presets.map((kit) => `- ${kit.name} (${kit.title})${kit.note ? `(${kit.note})` : ''}: ${kit.tools.length > 0 ? kit.tools.join(', ') : '全部工具'}`)),
1351
1630
  ].join('\n')),
1352
1631
  },
1353
1632
  async execute(_args, exec) {
@@ -1359,8 +1638,15 @@ export function apply(ctx, config = {}) {
1359
1638
  base: selection.base,
1360
1639
  extras: selection.extras,
1361
1640
  default: book.defaultKit,
1641
+ binding: bindingOf(sessionIdOf(agent)) ?? '',
1362
1642
  source: book.source,
1363
- kits: book.kits.map((kit) => ({ name: kit.name, title: kit.title, note: kit.note ?? '', tools: rawKitAllow(kit) })),
1643
+ kits: book.kits.map((kit) => ({
1644
+ name: kit.name,
1645
+ title: kit.title,
1646
+ note: kit.note ?? '',
1647
+ tools: rawKitAllow(kit),
1648
+ boundCount: boundCountOf(kit.name),
1649
+ })),
1364
1650
  presets: presets.map((kit) => ({ name: kit.name, title: kit.title, note: kit.note ?? '', tools: rawKitAllow(kit) })),
1365
1651
  }
1366
1652
  },
@@ -1487,20 +1773,132 @@ export function apply(ctx, config = {}) {
1487
1773
  ctx.tools.register({
1488
1774
  name: 'kit_delete',
1489
1775
  description: 'Remove one of my kits from the store. Presets cannot be deleted. '
1490
- + 'Conversations that already applied the kit fall back to 全部.',
1776
+ + 'Conversations pinned to the kit fall back to 全部; pass dryRun to see how many first.',
1491
1777
  parameters: {
1492
1778
  type: 'object',
1493
- properties: { name: { type: 'string' } },
1779
+ properties: {
1780
+ name: { type: 'string' },
1781
+ dryRun: { type: 'boolean', description: 'Only report affectedSessions; delete nothing.' },
1782
+ },
1494
1783
  required: ['name'],
1495
1784
  additionalProperties: false,
1496
1785
  },
1497
1786
  output: {
1498
- schema: outputSchema({ name: { type: 'string' }, message: { type: 'string' } }, ['name', 'message']),
1787
+ schema: outputSchema({
1788
+ name: { type: 'string' },
1789
+ affectedSessions: { type: 'number' },
1790
+ deleted: { type: 'boolean' },
1791
+ message: { type: 'string' },
1792
+ }, ['name', 'affectedSessions', 'deleted', 'message']),
1793
+ render: (_args, value) => text(value.message),
1794
+ },
1795
+ async execute(args) {
1796
+ const dryRun = args?.dryRun === true
1797
+ const { kit, affected } = operations.remove(args)
1798
+ if (dryRun) {
1799
+ return {
1800
+ name: kit.name,
1801
+ affectedSessions: affected,
1802
+ deleted: false,
1803
+ message: affected > 0
1804
+ ? `套装 ${kit.name} 正被 ${affected} 个对话使用;删除后它们会回到「全部」。`
1805
+ : `套装 ${kit.name} 没有对话在用,可以直接删除。`,
1806
+ }
1807
+ }
1808
+ return {
1809
+ name: kit.name,
1810
+ affectedSessions: affected,
1811
+ deleted: true,
1812
+ message: affected > 0
1813
+ ? `已删除套装 ${kit.name};${affected} 个对话回到「全部」。`
1814
+ : `已删除套装 ${kit.name}`,
1815
+ }
1816
+ },
1817
+ })
1818
+
1819
+ ctx.tools.register({
1820
+ name: 'kit_assign',
1821
+ description: 'Pin one conversation (by Session id) to a kit, 仅内置, or 全部. '
1822
+ + 'Normally you switch the conversation you are in with kit_apply; this is for correcting '
1823
+ + 'another conversation whose tools were set up wrong.',
1824
+ parameters: {
1825
+ type: 'object',
1826
+ properties: {
1827
+ sessionId: { type: 'string', description: 'Target Session id.' },
1828
+ kit: { type: 'string', description: 'Kit name or title; "@dsh" for 仅内置; "" or "全部" for 全部.' },
1829
+ },
1830
+ required: ['sessionId', 'kit'],
1831
+ additionalProperties: false,
1832
+ },
1833
+ output: {
1834
+ schema: outputSchema({
1835
+ sessionId: { type: 'string' },
1836
+ binding: { type: 'string' },
1837
+ applied: { type: 'boolean' },
1838
+ message: { type: 'string' },
1839
+ }, ['sessionId', 'binding', 'applied', 'message']),
1499
1840
  render: (_args, value) => text(value.message),
1500
1841
  },
1501
1842
  async execute(args) {
1502
- const kit = operations.remove(args)
1503
- return { name: kit.name, message: `已删除套装 ${kit.name}` }
1843
+ const sessionId = String(args?.sessionId ?? '')
1844
+ if (sessionId === '') throw new Error('kit_assign: 缺少 sessionId')
1845
+ const wanted = String(args?.kit ?? '').trim()
1846
+ let value
1847
+ if (wanted === '' || wanted === '全部' || wanted.toLowerCase() === 'all') value = null
1848
+ else if (wanted === DEFAULT_DSH || wanted === '仅内置') value = DEFAULT_DSH
1849
+ else {
1850
+ const kit = resolveKit(lastAgent, wanted)
1851
+ if (kit === undefined) throw new Error(`kit_assign: 未知套装 "${wanted}"`)
1852
+ value = kit.name
1853
+ }
1854
+ book.assignments[sessionId] = value
1855
+ save()
1856
+ // Apply it live when that conversation is still open; otherwise the pin is
1857
+ // picked up the next time the conversation is read.
1858
+ let applied = false
1859
+ if (sessionController !== undefined) {
1860
+ try {
1861
+ const found = await sessionController.resolveAgent(sessionId)
1862
+ if (found?.agent !== undefined) {
1863
+ const result = useSelection(found.agent, selectionForBinding(value))
1864
+ applied = result.ok
1865
+ }
1866
+ } catch {
1867
+ // The pin is stored; a closed conversation will read it on resume.
1868
+ }
1869
+ }
1870
+ const label = value === null ? '全部' : value === DEFAULT_DSH ? '仅内置' : value
1871
+ return {
1872
+ sessionId,
1873
+ binding: value ?? '',
1874
+ applied,
1875
+ message: `${sessionId} 现在固定使用「${label}」(${applied ? '已即时应用' : '下次打开该对话时生效'})。`,
1876
+ }
1877
+ },
1878
+ })
1879
+
1880
+ ctx.tools.register({
1881
+ name: 'kit_rename',
1882
+ description: 'Change the display title of one of my kits. The kit id stays the same, '
1883
+ + 'so its default and every conversation pinned to it keep working.',
1884
+ parameters: {
1885
+ type: 'object',
1886
+ properties: {
1887
+ name: { type: 'string', description: 'Kit id (not the title).' },
1888
+ title: { type: 'string', description: 'New display title.' },
1889
+ },
1890
+ required: ['name', 'title'],
1891
+ additionalProperties: false,
1892
+ },
1893
+ output: {
1894
+ schema: outputSchema({ name: { type: 'string' }, title: { type: 'string' }, message: { type: 'string' } }, ['name', 'title', 'message']),
1895
+ render: (_args, value) => text(value.message),
1896
+ },
1897
+ async execute(args) {
1898
+ const title = typeof args?.title === 'string' ? args.title.trim() : ''
1899
+ if (title === '') throw new Error('kit_rename: 缺少新的标题')
1900
+ const kit = operations.update({ name: args?.name, title })
1901
+ return { name: kit.name, title: kit.title, message: `套装 ${kit.name} 改名为「${kit.title}」(id 不变)。` }
1504
1902
  },
1505
1903
  })
1506
1904
 
@@ -1637,7 +2035,18 @@ export function apply(ctx, config = {}) {
1637
2035
  case 'create': operations.create(body); break
1638
2036
  case 'update': operations.update(body); break
1639
2037
  case 'delete':
1640
- case 'remove': operations.remove(body); break
2038
+ case 'remove': {
2039
+ // The settings page asks with dryRun first so it can quote how many
2040
+ // conversations a delete would release, then confirms.
2041
+ const { kit, affected } = operations.remove(body)
2042
+ return sendJson(res, 200, {
2043
+ ok: true,
2044
+ name: kit.name,
2045
+ affectedSessions: affected,
2046
+ deleted: body?.dryRun !== true,
2047
+ ...snapshot(),
2048
+ })
2049
+ }
1641
2050
  case 'default': operations.setDefault(body); break
1642
2051
  case 'applySet': {
1643
2052
  // Backward compatibility for panels written against 0.5: an explicit
@@ -1748,6 +2157,9 @@ export function apply(ctx, config = {}) {
1748
2157
  optional(['sessionController'], (child) => {
1749
2158
  sessionController = child.sessionController
1750
2159
  })
2160
+ optional(['sessions'], (child) => {
2161
+ sessions = child.sessions
2162
+ })
1751
2163
 
1752
2164
  ctx.logger?.info(`dsh-loadout: ${book.kits.length} 个我的套装 + ${presets.length} 个预设,默认 ${book.defaultKit || '(未设置)'},来源 ${book.source}`)
1753
2165
  }