ds-bot 0.1.3 → 0.1.5

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
@@ -1,7 +1,6 @@
1
1
  // DS Bot: a team of long-lived Bots on top of ordinary dsh root Sessions.
2
2
  // Every Bot is one root Session; the roster, rooms, and the bot-to-bot exchange log live
3
3
  // in one JSON file under DSH_HOME so they survive restarts without new Session event types.
4
- import { homedir } from 'node:os'
5
4
  import { join } from 'node:path'
6
5
  import * as diagnostics from './src/host/diagnostics.js'
7
6
  import * as store from './src/host/store.js'
@@ -10,6 +9,7 @@ import * as permissions from './src/host/permissions.js'
10
9
  import * as turns from './src/host/turns.js'
11
10
  import * as delivery from './src/host/delivery.js'
12
11
  import * as roster from './src/host/roster.js'
12
+ import * as folders from './src/host/folders.js'
13
13
  import * as agents from './src/host/agents.js'
14
14
  import * as prompt from './src/host/prompt.js'
15
15
  import * as timeContext from './src/host/time-context.js'
@@ -17,12 +17,14 @@ import * as tools from './src/host/tools.js'
17
17
  import * as chatIndex from './src/host/chat-index.js'
18
18
  import * as groups from './src/host/groups.js'
19
19
  import * as approvals from './src/host/approvals.js'
20
+ import * as resume from './src/host/resume.js'
20
21
  import * as memory from './src/host/memory.js'
21
22
  import * as parts from './src/host/parts.js'
22
23
  import * as images from './src/host/images.js'
23
24
  import * as browser from './src/host/browser.js'
24
25
  import * as secrets from './src/host/secrets.js'
25
26
  import * as connectors from './src/host/connectors.js'
27
+ import * as capabilities from './src/host/capabilities.js'
26
28
  import * as schedules from './src/host/schedules.js'
27
29
  import * as usage from './src/host/usage.js'
28
30
  import * as update from './src/host/update.js'
@@ -38,13 +40,19 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses
38
40
  // the functions other modules call; they call them through `rt` at run time, never during
39
41
  // install. Listeners run and tools are listed in registration order, so this order holds:
40
42
  // usage meters model requests after every listener that rewrites them.
41
- const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, agents, prompt, timeContext, tools, chatIndex, groups, approvals, memory, parts, images, secrets, connectors, schedules, browser, usage, update, dshUpdate, channel]
43
+ const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, channel]
42
44
 
43
45
  export function apply(ctx, config = {}) {
44
- const home = config.home ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'bot')
46
+ createRuntime(ctx, config)
47
+ }
48
+
49
+ export function createRuntime(ctx, config = {}) {
50
+ const dshHome = folders.dshHomeOf(process.env.DSH_HOME)
51
+ const home = config.home ?? join(dshHome, 'bot')
45
52
  const rt = {
46
53
  ctx,
47
54
  config,
55
+ dshHome,
48
56
  home,
49
57
  statePath: join(home, 'state.json'),
50
58
  defaults: {
@@ -53,4 +61,5 @@ export function apply(ctx, config = {}) {
53
61
  },
54
62
  }
55
63
  for (const module of MODULES) module.install(rt)
64
+ return rt
56
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ds-bot",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "DS Bot: a team of long-lived Bots for DeepSeek Harness, with Main Bots, Bot-to-Bot messages and group chats",
5
5
  "keywords": [
6
6
  "dsh-plugin",
@@ -29,11 +29,17 @@ export function install(rt) {
29
29
  at: Date.now(),
30
30
  }
31
31
  pending.set(id, entry)
32
- if (owner === undefined) return Promise.resolve(next()).finally(() => pending.delete(id))
32
+ rt.noteApproval?.(sessionId, { tool: entry.tool, reason: entry.reason.slice(0, 200) })
33
+ const settled = () => {
34
+ pending.delete(id)
35
+ rt.noteApproval?.(sessionId, undefined)
36
+ }
37
+ if (owner === undefined) return Promise.resolve(next()).finally(settled)
33
38
  rt.yieldRoomTurn?.(sessionId)
34
39
  return new Promise((resolve) => {
35
40
  entry.answer = (outcome) => {
36
- if (!pending.delete(id)) return
41
+ if (!pending.has(id)) return
42
+ settled()
37
43
  resolve(outcome)
38
44
  }
39
45
  if (request.signal?.aborted) entry.answer('cancelled')
@@ -0,0 +1,521 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ const MCP_CLIENT = '@deepseek-ai/dsh-mcp-client'
4
+ const NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
5
+ const SECRET_REFERENCE = /\$DSH_SECRET_([A-Z][A-Z0-9_]*)/g
6
+ const ENSURE_TIMEOUT_MS = 15_000
7
+
8
+ export function validateCapabilityName(value) {
9
+ const name = String(value ?? '').trim()
10
+ if (name.length > 32 || !NAME.test(name) || name === 'github') {
11
+ throw new Error('Use a capability name with lowercase letters, digits, and single hyphens; "github" is reserved.')
12
+ }
13
+ return name
14
+ }
15
+
16
+ export function expandCapabilityNames(capabilities, library) {
17
+ const expanded = new Set()
18
+ for (const name of capabilities ?? []) {
19
+ const item = library[name]
20
+ if (!item) throw new Error(`No capability named ${name}.`)
21
+ if (item.kind === 'pack') {
22
+ for (const childName of item.items ?? []) {
23
+ const child = library[childName]
24
+ if (!child || !['mcp', 'skill'].includes(child.kind)) throw new Error(`Pack ${name} refers to an unavailable capability ${childName}.`)
25
+ expanded.add(childName)
26
+ }
27
+ } else if (item.kind === 'mcp' || item.kind === 'skill') {
28
+ expanded.add(name)
29
+ } else {
30
+ throw new Error(`Capability ${name} has an unknown kind.`)
31
+ }
32
+ }
33
+ return expanded
34
+ }
35
+
36
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
37
+ const strings = value => {
38
+ if (value === undefined) return []
39
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error('Arguments must be a list of text values.')
40
+ return [...value]
41
+ }
42
+ const stringMap = (value, label) => {
43
+ if (value === undefined) return {}
44
+ if (value === null || typeof value !== 'object' || Array.isArray(value)
45
+ || Object.values(value).some(item => typeof item !== 'string')) throw new Error(`${label} must contain text values.`)
46
+ return Object.fromEntries(Object.entries(value))
47
+ }
48
+
49
+ export function install(rt) {
50
+ const { ctx, state } = rt
51
+ const agents = new Map()
52
+ // Mounted fibers live in each Agent's own context, so they outlive this plugin unless
53
+ // its cleanup disposes them; a strong map keeps them reachable for that.
54
+ const mounts = new Map()
55
+ // One reconcile chain per Agent: a slow MCP server holds up only its own Agent.
56
+ const chains = new WeakMap()
57
+ const queued = new WeakSet()
58
+ const sessionStatuses = new Map()
59
+ let pluginPromise
60
+ let scheduled = null
61
+ let loadingSecrets = 0
62
+ let stopped = false
63
+
64
+ const library = () => state.capabilities.items
65
+ const ensureLibrary = () => {
66
+ state.capabilities ??= { items: {} }
67
+ state.capabilities.items ??= {}
68
+ return library()
69
+ }
70
+ const writeCheck = () => {
71
+ const readOnly = rt.readOnly()
72
+ if (readOnly !== undefined) throw new Error(readOnly)
73
+ }
74
+ const allowedToEdit = (item, botId) => rt.isMain(botId) || item.createdBy === botId
75
+ const usedByOthers = (name, botId) => Object.values(state.bots).some(bot => bot.id !== botId
76
+ && (bot.capabilities ?? []).some(attached => attached === name
77
+ || (library()[attached]?.kind === 'pack' && (library()[attached].items ?? []).includes(name))))
78
+ // A creator that is not a Main Bot could otherwise rewrite what a Main Bot runs.
79
+ const editCheck = (item, botId, verb) => {
80
+ if (!allowedToEdit(item, botId)) throw new Error(`Only ${item.createdBy ? 'its creator or a Main Bot' : 'a Main Bot'} can ${verb} ${item.name}.`)
81
+ if (!rt.isMain(botId) && usedByOthers(item.name, botId)) throw new Error(`Another Bot uses ${item.name}, so only a Main Bot can ${verb} it.`)
82
+ }
83
+ const itemInput = (body, { botId, ui }) => {
84
+ const name = validateCapabilityName(body.name)
85
+ const kind = body.kind
86
+ if (!['mcp', 'skill', 'pack'].includes(kind)) throw new Error('Choose an MCP server, skill, or pack.')
87
+ const items = ensureLibrary()
88
+ const previous = items[name]
89
+ if (previous && !ui) editCheck(previous, botId, 'change')
90
+ if (previous && previous.kind !== kind) throw new Error('A capability cannot change its kind. Delete it and save a new one.')
91
+ const now = Date.now()
92
+ const item = {
93
+ name,
94
+ kind,
95
+ description: String(body.description ?? '').trim().slice(0, 2000),
96
+ createdBy: previous?.createdBy ?? (ui ? null : botId),
97
+ createdAt: previous?.createdAt ?? now,
98
+ updatedAt: now,
99
+ }
100
+ if (kind === 'mcp') {
101
+ const transport = body.transport === 'streamable-http' ? 'streamable-http' : 'stdio'
102
+ if (transport === 'stdio') {
103
+ const command = String(body.command ?? '').trim()
104
+ if (command === '') throw new Error('Enter a command for the MCP server.')
105
+ const args = strings(body.args)
106
+ const env = stringMap(body.env, 'Environment variables')
107
+ const changed = !previous || previous.kind !== 'mcp' || previous.transport !== 'stdio'
108
+ || previous.command !== command || !same(previous.args ?? [], args) || !same(previous.env ?? {}, env)
109
+ Object.assign(item, { transport, command, args, env, approved: ui ? true : changed ? false : previous.approved === true })
110
+ } else {
111
+ const url = String(body.url ?? '').trim()
112
+ try {
113
+ const parsed = new URL(url)
114
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') throw new Error()
115
+ } catch { throw new Error('Enter a valid HTTP or HTTPS URL.') }
116
+ Object.assign(item, { transport, url, headers: stringMap(body.headers, 'Headers'), approved: true })
117
+ }
118
+ } else if (kind === 'skill') {
119
+ item.content = String(body.content ?? '').trim()
120
+ } else {
121
+ const names = [...new Set(strings(body.items))]
122
+ for (const childName of names) {
123
+ const child = items[childName]
124
+ if (!child || !['mcp', 'skill'].includes(child.kind)) throw new Error(`Packs can include only saved MCP servers and skills. ${childName} is not one.`)
125
+ }
126
+ item.items = names
127
+ }
128
+ items[name] = item
129
+ return item
130
+ }
131
+ const capabilitySave = async (body, options = {}) => {
132
+ await rt.load()
133
+ writeCheck()
134
+ const item = itemInput(body ?? {}, options)
135
+ await rt.save()
136
+ return item
137
+ }
138
+ const capabilityDelete = async (name, { botId, ui } = {}) => {
139
+ await rt.load()
140
+ writeCheck()
141
+ const items = ensureLibrary()
142
+ const item = items[String(name ?? '')]
143
+ if (!item) throw new Error(`No capability named ${name}.`)
144
+ if (!ui) editCheck(item, botId, 'delete')
145
+ delete items[item.name]
146
+ for (const pack of Object.values(items)) if (pack.kind === 'pack') pack.items = (pack.items ?? []).filter(child => child !== item.name)
147
+ for (const bot of Object.values(state.bots)) bot.capabilities = (bot.capabilities ?? []).filter(attached => attached !== item.name)
148
+ await rt.save()
149
+ return true
150
+ }
151
+ const changeAttachment = async (endpoint, { name, bot: targetRef }, callerId) => {
152
+ await rt.load()
153
+ writeCheck()
154
+ const caller = rt.botOf(callerId)
155
+ if (!caller) throw new Error('Only Bots on the team can change capabilities.')
156
+ const target = targetRef ? rt.findBot(targetRef) : caller
157
+ if (!target) throw new Error(`No Bot named ${targetRef}.`)
158
+ if (target.id !== caller.id && !rt.isMain(callerId)) throw new Error('Only a Main Bot can change another Bot’s capabilities.')
159
+ const item = ensureLibrary()[String(name ?? '')]
160
+ if (!item) throw new Error(`No capability named ${name}.`)
161
+ await setAttached(target, item.name, endpoint === 'attach')
162
+ return { bot: target.name, attached: endpoint === 'attach' }
163
+ }
164
+ // Changes one name against the saved list, so two quick changes both land.
165
+ const setAttached = async (bot, name, attached) => {
166
+ bot.capabilities ??= []
167
+ const wasEmpty = bot.capabilities.length === 0
168
+ if (attached && !bot.capabilities.includes(name)) bot.capabilities.push(name)
169
+ if (!attached) bot.capabilities = bot.capabilities.filter(entry => entry !== name)
170
+ await rt.save()
171
+ if (wasEmpty && bot.capabilities.length > 0) {
172
+ await trackBotAgents(bot)
173
+ void scheduleReconcile()
174
+ }
175
+ return bot.capabilities
176
+ }
177
+
178
+ const statusSet = (botId, itemName, sessionId, status) => {
179
+ let byItem = sessionStatuses.get(botId)
180
+ if (!byItem) sessionStatuses.set(botId, byItem = new Map())
181
+ let bySession = byItem.get(itemName)
182
+ if (!bySession) byItem.set(itemName, bySession = new Map())
183
+ bySession.set(sessionId, status)
184
+ }
185
+ const clearStatus = (botId, itemName, sessionId) => {
186
+ const byItem = sessionStatuses.get(botId)
187
+ const bySession = byItem?.get(itemName)
188
+ bySession?.delete(sessionId)
189
+ if (bySession?.size === 0) byItem.delete(itemName)
190
+ if (byItem?.size === 0) sessionStatuses.delete(botId)
191
+ }
192
+ const clearSession = sessionId => {
193
+ for (const [botId, byItem] of sessionStatuses) {
194
+ for (const [name, bySession] of byItem) {
195
+ bySession.delete(sessionId)
196
+ if (bySession.size === 0) byItem.delete(name)
197
+ }
198
+ if (byItem.size === 0) sessionStatuses.delete(botId)
199
+ }
200
+ }
201
+ const statusFor = (botId, name) => {
202
+ const values = [...(sessionStatuses.get(botId)?.get(name)?.values() ?? [])]
203
+ if (values.some(status => status.state === 'error')) return values.find(status => status.state === 'error')
204
+ if (values.some(status => status.state === 'needs-approval')) return { state: 'needs-approval' }
205
+ if (values.some(status => status.state === 'connecting')) return { state: 'connecting' }
206
+ return values.length > 0 ? { state: 'ready' } : undefined
207
+ }
208
+ const capabilityStatusesView = () => {
209
+ const result = {}
210
+ for (const bot of Object.values(state.bots)) {
211
+ const statuses = {}
212
+ for (const name of bot.capabilities ?? []) {
213
+ const item = library()[name]
214
+ if (!item) continue
215
+ if (item.kind !== 'pack') {
216
+ const status = statusFor(bot.id, name)
217
+ if (status) statuses[name] = status
218
+ continue
219
+ }
220
+ const children = (item.items ?? []).map(child => statusFor(bot.id, child)).filter(Boolean)
221
+ const status = children.find(status => status.state === 'error')
222
+ ?? children.find(status => status.state === 'needs-approval')
223
+ ?? children.find(status => status.state === 'connecting')
224
+ ?? children[0]
225
+ if (status) statuses[name] = status
226
+ }
227
+ result[bot.id] = statuses
228
+ }
229
+ return result
230
+ }
231
+ const libraryView = () => Object.values(library()).map(item => ({
232
+ ...item,
233
+ ...(item.env ? { env: { ...item.env } } : {}),
234
+ ...(item.headers ? { headers: { ...item.headers } } : {}),
235
+ ...(item.items ? { items: [...item.items] } : {}),
236
+ }))
237
+
238
+ async function loadMcpPlugin() {
239
+ if (!pluginPromise) {
240
+ pluginPromise = (async () => {
241
+ const loader = ctx.get?.('loader')
242
+ if (typeof loader?.import !== 'function') throw new Error('This DSH cannot load MCP servers.')
243
+ const exports = await loader.import(MCP_CLIENT)
244
+ return loader.unwrapExports?.(exports) ?? exports?.default ?? exports
245
+ })()
246
+ }
247
+ return pluginPromise
248
+ }
249
+ function resolveValues(values, botId) {
250
+ const resolved = {}
251
+ for (const [key, raw] of Object.entries(values ?? {})) {
252
+ resolved[key] = String(raw).replace(SECRET_REFERENCE, (_match, name) => {
253
+ if (!rt.canUseSecret(name, botId)) throw new Error(`Secret ${name} is missing or unavailable for this Bot.`)
254
+ const value = rt.secretValue(name)
255
+ if (value === undefined) throw new Error(`Secret ${name} is missing or unavailable for this Bot.`)
256
+ return value
257
+ })
258
+ }
259
+ return resolved
260
+ }
261
+ const configFor = (item, botId) => {
262
+ if (item.kind === 'skill') return { name: item.name, description: item.description, content: item.content }
263
+ if (item.transport === 'stdio') return { transport: 'stdio', serverName: item.name, command: item.command, args: item.args ?? [], env: resolveValues(item.env, botId), failOnStartupError: true }
264
+ return { transport: 'streamable-http', serverName: item.name, url: item.url, headers: resolveValues(item.headers, botId), failOnStartupError: true }
265
+ }
266
+ const disposeMount = async mount => {
267
+ if (mount?.fiber) await Promise.resolve().then(() => mount.fiber.dispose()).catch(() => {})
268
+ }
269
+ const mountOne = async (agent, bot, agentMounts, name) => {
270
+ const sessionId = agent.session.id
271
+ const item = library()[name]
272
+ const drop = async () => {
273
+ await disposeMount(agentMounts.get(name))
274
+ agentMounts.delete(name)
275
+ }
276
+ if (item.kind === 'mcp' && item.transport === 'stdio' && item.approved !== true) {
277
+ await drop()
278
+ statusSet(bot.id, name, sessionId, { state: 'needs-approval' })
279
+ return
280
+ }
281
+ let config
282
+ try {
283
+ config = configFor(item, bot.id)
284
+ } catch (error) {
285
+ await drop()
286
+ statusSet(bot.id, name, sessionId, { state: 'error', error: rt.redactSecrets(error.message).slice(0, 300) })
287
+ return
288
+ }
289
+ const key = `${name}:${createHash('sha256').update(JSON.stringify(config)).digest('hex')}`
290
+ if (agentMounts.get(name)?.key === key) return
291
+ await drop()
292
+ statusSet(bot.id, name, sessionId, { state: 'connecting' })
293
+ try {
294
+ const plugin = item.kind === 'skill'
295
+ ? {
296
+ name: `ds-bot-capability-skill-${name}`,
297
+ inject: ['skills'],
298
+ apply(c) {
299
+ c.effect(() => c.skills.register({ name: config.name, description: config.description, source: 'runtime', content: config.content }))
300
+ },
301
+ }
302
+ : await loadMcpPlugin()
303
+ if (stopped) return
304
+ const fiber = item.kind === 'skill' ? agent.ctx.plugin(plugin) : agent.ctx.plugin(plugin, config)
305
+ agentMounts.set(name, { key, fiber })
306
+ await fiber.await()
307
+ if (stopped || agents.get(sessionId)?.deref() !== agent) return
308
+ statusSet(bot.id, name, sessionId, { state: 'ready' })
309
+ } catch (error) {
310
+ await drop()
311
+ const message = rt.redactSecrets([error, error?.cause].filter(Boolean).map(part => String(part?.message ?? part)).join(': ') || 'Connection failed').slice(0, 300)
312
+ statusSet(bot.id, name, sessionId, { state: 'error', error: message })
313
+ }
314
+ }
315
+ const reconcileAgent = async (agent, bot) => {
316
+ const sessionId = agent.session.id
317
+ const desired = bot ? expandCapabilityNames(bot.capabilities, library()) : new Set()
318
+ const agentMounts = mounts.get(agent) ?? new Map()
319
+ if (desired.size > 0) mounts.set(agent, agentMounts)
320
+ if (bot) {
321
+ for (const name of sessionStatuses.get(bot.id)?.keys() ?? []) {
322
+ if (!desired.has(name)) clearStatus(bot.id, name, sessionId)
323
+ }
324
+ } else {
325
+ clearSession(sessionId)
326
+ }
327
+ for (const [name, mount] of agentMounts) {
328
+ if (desired.has(name)) continue
329
+ await disposeMount(mount)
330
+ agentMounts.delete(name)
331
+ }
332
+ await Promise.all([...desired].map(name => mountOne(agent, bot, agentMounts, name)))
333
+ if (agentMounts.size === 0 && mounts.get(agent) === agentMounts) mounts.delete(agent)
334
+ }
335
+ const botOfSession = sessionId => (rt.isBotSession(sessionId) ? rt.botOf(rt.selfOf(sessionId)) : undefined)
336
+ // At most one run waits behind the running one; it reads the latest state when it starts.
337
+ const reconcileOne = agent => {
338
+ if (queued.has(agent)) return chains.get(agent)
339
+ queued.add(agent)
340
+ const run = (chains.get(agent) ?? Promise.resolve()).then(async () => {
341
+ queued.delete(agent)
342
+ const sessionId = agent.session.id
343
+ if (stopped || agents.get(sessionId)?.deref() !== agent) return
344
+ await reconcileAgent(agent, botOfSession(sessionId))
345
+ }).catch(error => rt.warn('ds-bot: capability reconcile for %s failed: %s', agent.session?.id, error))
346
+ chains.set(agent, run)
347
+ return run
348
+ }
349
+ const loadInputs = async () => {
350
+ await rt.load()
351
+ loadingSecrets += 1
352
+ try {
353
+ await rt.loadSecrets()
354
+ } finally {
355
+ loadingSecrets -= 1
356
+ }
357
+ }
358
+ async function reconcile() {
359
+ if (stopped) return
360
+ await loadInputs()
361
+ const runs = []
362
+ for (const [sessionId, reference] of agents) {
363
+ const agent = reference.deref()
364
+ if (agent) {
365
+ runs.push(reconcileOne(agent))
366
+ continue
367
+ }
368
+ agents.delete(sessionId)
369
+ clearSession(sessionId)
370
+ }
371
+ await Promise.all(runs)
372
+ }
373
+ const scheduleReconcile = () => {
374
+ if (!scheduled) {
375
+ scheduled = new Promise(resolve => queueMicrotask(() => {
376
+ scheduled = null
377
+ resolve(reconcile().catch(error => rt.warn('ds-bot: capability reconcile failed: %s', error)))
378
+ }))
379
+ }
380
+ return scheduled
381
+ }
382
+ // A run that outlasts the limit goes on in the background, and later requests for the
383
+ // same Agent do not wait for it again.
384
+ const stalled = new WeakSet()
385
+ const prepare = async (agent, sessionId) => {
386
+ const run = loadInputs().then(() => reconcileOne(agent)).then(() => true, error => {
387
+ rt.warn('ds-bot: capability mount for %s failed: %s', sessionId, error)
388
+ return true
389
+ })
390
+ if (stalled.has(agent)) return
391
+ const timeoutMs = rt.capabilityEnsureTimeoutMs ?? ENSURE_TIMEOUT_MS
392
+ let timer
393
+ const completed = await Promise.race([run, new Promise(resolve => { timer = setTimeout(resolve, timeoutMs, false) })])
394
+ clearTimeout(timer)
395
+ if (completed) return
396
+ stalled.add(agent)
397
+ void run.then(() => stalled.delete(agent))
398
+ rt.warn('ds-bot: capability mount for %s did not finish within %dms; continuing', sessionId, timeoutMs)
399
+ }
400
+ async function ensureCapabilities(sessionId) {
401
+ await rt.load()
402
+ const bot = botOfSession(sessionId)
403
+ if (!bot) return
404
+ if ((bot.capabilities ?? []).length === 0 && !mounts.get(agents.get(sessionId)?.deref())?.size) return
405
+ let agent
406
+ try {
407
+ agent = await rt.resolveAgent(sessionId)
408
+ } catch (error) {
409
+ rt.warn('ds-bot: capability Agent for %s could not be resolved: %s', sessionId, rt.redactSecrets(String(error)))
410
+ return
411
+ }
412
+ trackAgent(agent)
413
+ await prepare(agent, sessionId)
414
+ }
415
+ const trackAgent = agent => {
416
+ const sessionId = agent?.session?.id
417
+ if (typeof sessionId !== 'string' || !agent.ctx) return
418
+ if (agents.get(sessionId)?.deref() === agent) return
419
+ agents.set(sessionId, new WeakRef(agent))
420
+ agent.ctx.effect(() => () => {
421
+ if (agents.get(sessionId)?.deref() === agent) agents.delete(sessionId)
422
+ clearSession(sessionId)
423
+ for (const mount of mounts.get(agent)?.values() ?? []) void disposeMount(mount)
424
+ mounts.delete(agent)
425
+ }, `ds-bot: capabilities ${sessionId}`)
426
+ }
427
+ const trackBotAgents = async bot => {
428
+ const sessionIds = new Set()
429
+ try {
430
+ const sessionId = rt.chatOf(bot.id)
431
+ if (typeof sessionId === 'string') sessionIds.add(sessionId)
432
+ } catch (error) {
433
+ rt.warn('ds-bot: capability chat session for %s could not be read: %s', bot.id, error)
434
+ }
435
+ try {
436
+ for (const sessionId of rt.groupSessionsOf(bot.id) ?? []) {
437
+ if (typeof sessionId === 'string') sessionIds.add(sessionId)
438
+ }
439
+ } catch (error) {
440
+ rt.warn('ds-bot: capability group sessions for %s could not be read: %s', bot.id, error)
441
+ }
442
+ for (const sessionId of sessionIds) {
443
+ try {
444
+ trackAgent(await rt.resolveAgent(sessionId))
445
+ } catch (error) {
446
+ rt.warn('ds-bot: capability Agent for %s could not be resolved: %s', sessionId, error)
447
+ }
448
+ }
449
+ }
450
+
451
+ async function capabilityEndpoint(endpoint, body = {}) {
452
+ if (endpoint === 'capability-save') return capabilitySave(body, { ui: true })
453
+ if (endpoint === 'capability-delete') return capabilityDelete(body.name, { ui: true })
454
+ if (endpoint === 'capability-approve') {
455
+ await rt.load()
456
+ writeCheck()
457
+ const item = library()[String(body.name ?? '')]
458
+ if (!item || item.kind !== 'mcp' || item.transport !== 'stdio') throw new Error(`No unapproved stdio MCP named ${body.name}.`)
459
+ item.approved = true
460
+ item.updatedAt = Date.now()
461
+ await rt.save()
462
+ return item
463
+ }
464
+ if (endpoint === 'bot-capability') {
465
+ await rt.load()
466
+ writeCheck()
467
+ const bot = rt.botOf(String(body.botId ?? ''))
468
+ if (!bot) throw new Error('Unknown Bot')
469
+ const name = String(body.name ?? '')
470
+ if (!library()[name]) throw new Error(`No capability named ${name}.`)
471
+ return setAttached(bot, name, body.attached === true)
472
+ }
473
+ throw new Error('Unknown capability operation.')
474
+ }
475
+ const secretsChanged = rt.secretsChanged
476
+ rt.secretsChanged = () => {
477
+ secretsChanged?.()
478
+ if (loadingSecrets === 0) void scheduleReconcile()
479
+ }
480
+ rt.capabilitiesStateSaved = () => { void scheduleReconcile() }
481
+ // Creation waits for these listeners, so a new Agent's first request already has its
482
+ // tools and skill catalog. agent/pre-step is too late: a step assembles its tools first.
483
+ ctx.on('agent/created', async function prepareCapabilities({ agent }) {
484
+ trackAgent(agent)
485
+ try {
486
+ await rt.load()
487
+ const bot = botOfSession(agent.session.id)
488
+ if ((bot?.capabilities ?? []).length > 0) await prepare(agent, agent.session.id)
489
+ } catch (error) {
490
+ rt.warn('ds-bot: capability load failed: %s', error)
491
+ }
492
+ })
493
+ ctx.effect(() => {
494
+ void rt.load().then(async () => {
495
+ for (const bot of Object.values(state.bots)) {
496
+ if ((bot.capabilities ?? []).length > 0) await trackBotAgents(bot)
497
+ }
498
+ await scheduleReconcile()
499
+ }).catch(error => rt.warn('ds-bot: capability startup failed: %s', error))
500
+ return async () => {
501
+ stopped = true
502
+ const live = [...mounts.values()]
503
+ mounts.clear()
504
+ agents.clear()
505
+ sessionStatuses.clear()
506
+ await Promise.all(live.flatMap(agentMounts => [...agentMounts.values()].map(disposeMount)))
507
+ }
508
+ }, 'ds-bot: capabilities')
509
+ Object.assign(rt, {
510
+ capabilitySave,
511
+ capabilityDelete,
512
+ capabilityEndpoint,
513
+ capabilityLibraryView: libraryView,
514
+ capabilityStatusesView,
515
+ reconcileCapabilities: reconcile,
516
+ scheduleCapabilitiesReconcile: scheduleReconcile,
517
+ ensureCapabilities,
518
+ expandCapabilities: names => expandCapabilityNames(names, library()),
519
+ changeCapabilityAttachment: changeAttachment,
520
+ })
521
+ }