dsh-agent-plugins-market 0.6.2 → 0.7.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.
Files changed (155) hide show
  1. package/README.md +117 -111
  2. package/README.zh.md +112 -98
  3. package/client/client.js +28533 -1511
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog-context.js +188 -0
  6. package/lib/application/catalog.js +229 -1109
  7. package/lib/application/details.js +19 -8
  8. package/lib/application/install-store.js +95 -0
  9. package/lib/application/lsp-service.js +97 -0
  10. package/lib/application/mcp-service.js +263 -0
  11. package/lib/application/panel-resources.js +43 -14
  12. package/lib/application/ports.js +21 -0
  13. package/lib/application/project-agent-roles.js +8 -2
  14. package/lib/application/snapshot-cache.js +160 -0
  15. package/lib/application/source-store.js +521 -0
  16. package/lib/catalog/archive.js +35 -30
  17. package/lib/catalog/component-files.js +14 -6
  18. package/lib/catalog/fs-probes.js +43 -0
  19. package/lib/catalog/manifests.js +53 -92
  20. package/lib/catalog/native-project.js +2 -2
  21. package/lib/catalog/paths.js +74 -55
  22. package/lib/catalog/plugin-variables.js +38 -0
  23. package/lib/catalog/project-config.js +2 -1
  24. package/lib/catalog/project-hooks.js +23 -2
  25. package/lib/catalog/scan-resolvers.js +29 -45
  26. package/lib/catalog/skills-parse.js +61 -10
  27. package/lib/catalog/source-catalog.js +10 -4
  28. package/lib/catalog/suite-components.js +7 -7
  29. package/lib/catalog/suite-scanner.js +3 -1
  30. package/lib/catalog/surfaces.js +5 -26
  31. package/lib/catalog/validate.js +7 -11
  32. package/lib/contracts/market.js +2 -0
  33. package/lib/contracts/settings.js +63 -0
  34. package/lib/index.js +115 -217
  35. package/lib/model/layouts.js +10 -1
  36. package/lib/model/types.js +26 -3
  37. package/lib/routes.js +57 -15
  38. package/lib/runtime/agent-role-names.js +70 -0
  39. package/lib/runtime/agent-role-router.js +142 -106
  40. package/lib/runtime/commands-mounts.js +55 -11
  41. package/lib/runtime/deadline.js +54 -0
  42. package/lib/runtime/dynamic-context.js +140 -0
  43. package/lib/runtime/feedback-tool.js +101 -46
  44. package/lib/runtime/hooks-mounts.js +9 -11
  45. package/lib/runtime/host-locale.js +49 -24
  46. package/lib/runtime/lsp-direct-config.js +19 -19
  47. package/lib/runtime/lsp-mounts.js +182 -70
  48. package/lib/runtime/lsp-server-state.js +4 -4
  49. package/lib/runtime/lsp-status.js +1 -1
  50. package/lib/runtime/mcp-auth-record.js +42 -0
  51. package/lib/runtime/mcp-backend.js +14 -14
  52. package/lib/runtime/mcp-client/config.js +8 -1
  53. package/lib/runtime/mcp-client/connection.js +38 -43
  54. package/lib/runtime/mcp-client/host-contract.js +10 -0
  55. package/lib/runtime/mcp-client/host-seams.js +8 -88
  56. package/lib/runtime/mcp-client/oauth.js +5 -7
  57. package/lib/runtime/mcp-client/plugin-identity.js +28 -0
  58. package/lib/runtime/mcp-client/projection.js +202 -0
  59. package/lib/runtime/mcp-client/tools.js +4 -189
  60. package/lib/runtime/mcp-config.js +36 -14
  61. package/lib/runtime/mcp-direct-config.js +33 -16
  62. package/lib/runtime/mcp-mounts.js +35 -75
  63. package/lib/runtime/mcp-overrides.js +5 -5
  64. package/lib/runtime/mcp-redaction.js +3 -2
  65. package/lib/runtime/mcp-status.js +7 -8
  66. package/lib/runtime/mount-lifecycle.js +73 -0
  67. package/lib/runtime/profile-seam.js +321 -0
  68. package/lib/runtime/project-runtime.js +34 -17
  69. package/lib/runtime/reconcile-scheduler.js +97 -0
  70. package/lib/runtime/reconciler.js +6 -1
  71. package/lib/runtime/regions.js +0 -19
  72. package/lib/runtime/server-config.js +6 -4
  73. package/lib/runtime/settings-namespace.js +144 -0
  74. package/lib/runtime/skills-provider.js +38 -14
  75. package/lib/runtime/source-auto-update.js +66 -0
  76. package/lib/{model/state.js → runtime/state-store.js} +9 -11
  77. package/lib/runtime/storage-migration.js +35 -18
  78. package/lib/runtime/subagent-catalog.js +5 -4
  79. package/lib/runtime/timer-seat.js +80 -0
  80. package/lib/runtime/tool-registry-observer.js +20 -20
  81. package/lib/runtime/user-commands.js +7 -3
  82. package/lib/runtime/user-panels.js +125 -46
  83. package/lib/runtime/user-store.js +124 -69
  84. package/lib/types/application/catalog-context.d.ts +124 -0
  85. package/lib/types/application/catalog.d.ts +108 -304
  86. package/lib/types/application/install-store.d.ts +21 -0
  87. package/lib/types/application/lsp-service.d.ts +36 -0
  88. package/lib/types/application/mcp-service.d.ts +77 -0
  89. package/lib/types/application/panel-resources.d.ts +8 -0
  90. package/lib/types/application/ports.d.ts +72 -0
  91. package/lib/types/application/queries.d.ts +15 -26
  92. package/lib/types/application/snapshot-cache.d.ts +51 -0
  93. package/lib/types/application/source-store.d.ts +100 -0
  94. package/lib/types/catalog/archive.d.ts +3 -11
  95. package/lib/types/catalog/component-files.d.ts +4 -0
  96. package/lib/types/catalog/fs-probes.d.ts +10 -0
  97. package/lib/types/catalog/manifests.d.ts +17 -16
  98. package/lib/types/catalog/native-project.d.ts +2 -2
  99. package/lib/types/catalog/paths.d.ts +53 -15
  100. package/lib/types/catalog/plugin-variables.d.ts +16 -0
  101. package/lib/types/catalog/scan-pipeline.d.ts +3 -3
  102. package/lib/types/catalog/scan-resolvers.d.ts +3 -9
  103. package/lib/types/catalog/skills-parse.d.ts +13 -7
  104. package/lib/types/catalog/source-catalog.d.ts +10 -5
  105. package/lib/types/catalog/suite-scanner.d.ts +8 -6
  106. package/lib/types/catalog/validate.d.ts +0 -2
  107. package/lib/types/contracts/lsp-status.d.ts +35 -0
  108. package/lib/types/contracts/market.d.ts +13 -2
  109. package/lib/types/contracts/mcp-status.d.ts +4 -0
  110. package/lib/types/contracts/settings.d.ts +59 -0
  111. package/lib/types/model/layouts.d.ts +10 -0
  112. package/lib/types/model/types.d.ts +29 -5
  113. package/lib/types/runtime/agent-role-names.d.ts +6 -0
  114. package/lib/types/runtime/agent-role-router.d.ts +59 -52
  115. package/lib/types/runtime/commands-mounts.d.ts +15 -7
  116. package/lib/types/runtime/deadline.d.ts +17 -0
  117. package/lib/types/runtime/dynamic-context.d.ts +81 -0
  118. package/lib/types/runtime/feedback-tool.d.ts +33 -11
  119. package/lib/types/runtime/hooks-mounts.d.ts +1 -1
  120. package/lib/types/runtime/host-locale.d.ts +20 -2
  121. package/lib/types/runtime/lsp-direct-config.d.ts +3 -3
  122. package/lib/types/runtime/lsp-mounts.d.ts +69 -19
  123. package/lib/types/runtime/lsp-status.d.ts +2 -1
  124. package/lib/types/runtime/mcp-auth-record.d.ts +29 -0
  125. package/lib/types/runtime/mcp-backend.d.ts +10 -14
  126. package/lib/types/runtime/mcp-client/config.d.ts +8 -2
  127. package/lib/types/runtime/mcp-client/host-contract.d.ts +78 -0
  128. package/lib/types/runtime/mcp-client/host-seams.d.ts +20 -64
  129. package/lib/types/runtime/mcp-client/oauth.d.ts +1 -1
  130. package/lib/types/runtime/mcp-client/plugin-identity.d.ts +16 -0
  131. package/lib/types/runtime/mcp-client/projection.d.ts +45 -0
  132. package/lib/types/runtime/mcp-client/tools.d.ts +5 -72
  133. package/lib/types/runtime/mcp-config.d.ts +25 -4
  134. package/lib/types/runtime/mcp-direct-config.d.ts +18 -4
  135. package/lib/types/runtime/mcp-mounts.d.ts +10 -15
  136. package/lib/types/runtime/mcp-overrides.d.ts +1 -1
  137. package/lib/types/runtime/mcp-status.d.ts +1 -1
  138. package/lib/types/runtime/mount-lifecycle.d.ts +53 -0
  139. package/lib/types/runtime/profile-seam.d.ts +64 -0
  140. package/lib/types/runtime/project-runtime.d.ts +10 -3
  141. package/lib/types/runtime/reconcile-scheduler.d.ts +45 -0
  142. package/lib/types/runtime/reconciler.d.ts +2 -0
  143. package/lib/types/runtime/regions.d.ts +1 -4
  144. package/lib/types/runtime/settings-namespace.d.ts +76 -0
  145. package/lib/types/runtime/skills-provider.d.ts +11 -2
  146. package/lib/types/runtime/source-auto-update.d.ts +32 -0
  147. package/lib/types/{model/state.d.ts → runtime/state-store.d.ts} +2 -2
  148. package/lib/types/runtime/storage-migration.d.ts +5 -3
  149. package/lib/types/runtime/subagent-catalog.d.ts +2 -0
  150. package/lib/types/runtime/timer-seat.d.ts +51 -0
  151. package/lib/types/runtime/tool-registry-observer.d.ts +12 -6
  152. package/lib/types/runtime/user-panels.d.ts +69 -19
  153. package/lib/types/runtime/user-store.d.ts +67 -20
  154. package/package.json +68 -24
  155. package/schemas/README.md +1 -1
package/lib/routes.js CHANGED
@@ -130,7 +130,7 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
130
130
  }
131
131
  });
132
132
  post(MARKET_ROUTES.addSource, async (body) => {
133
- const url = String(body['url'] ?? '').trim();
133
+ const url = textField(body['url'] ?? '', 'source url').trim();
134
134
  if (url === '')
135
135
  throw new Error('missing source url');
136
136
  const local = body['local'] === true;
@@ -145,11 +145,11 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
145
145
  if (!url.startsWith('~/') && url !== '~' && !isAbsolute(expanded))
146
146
  throw new Error('local source url must be an absolute path or start with ~/');
147
147
  }
148
- const branch = body['branch'];
148
+ const branch = body['branch'] === undefined ? undefined : textField(body['branch'], 'branch');
149
149
  const sha256 = parseSha256(body['sha256']);
150
150
  const source = await manager.addSource({
151
151
  url: local || kind === 'local' ? expandHome(url) : url,
152
- ...(typeof branch === 'string' && branch.trim() !== '' ? { branch: branch.trim() } : {}),
152
+ ...(branch !== undefined && branch.trim() !== '' ? { branch: branch.trim() } : {}),
153
153
  ...(local ? { local: true } : {}),
154
154
  ...(kind === undefined ? {} : { kind }),
155
155
  ...(sha256 === undefined ? {} : { sha256 })
@@ -162,13 +162,13 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
162
162
  throw new Error('missing source id');
163
163
  const patch = {};
164
164
  if (body['url'] !== undefined) {
165
- const url = String(body['url']).trim();
165
+ const url = textField(body['url'], 'source url').trim();
166
166
  if (url === '')
167
167
  throw new Error('missing source url');
168
168
  patch.url = url;
169
169
  }
170
170
  if (body['branch'] !== undefined)
171
- patch.branch = String(body['branch']).trim();
171
+ patch.branch = textField(body['branch'], 'branch').trim();
172
172
  if (body['local'] !== undefined)
173
173
  patch.local = body['local'] === true;
174
174
  const kind = parseSourceKind(body['kind']);
@@ -197,8 +197,8 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
197
197
  return {};
198
198
  });
199
199
  post(MARKET_ROUTES.refreshSource, async (body) => {
200
- const id = body['id'];
201
- await manager.refreshSource(typeof id === 'string' && id !== '' ? id : undefined);
200
+ const id = body['id'] === undefined ? undefined : textField(body['id'], 'source id');
201
+ await manager.refreshSource(id === '' ? undefined : id);
202
202
  return {};
203
203
  });
204
204
  post(MARKET_ROUTES.install, async (body) => {
@@ -257,6 +257,18 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
257
257
  await manager.setMcpOverride(sourceId, suiteId, serverKey, sanitized ?? null);
258
258
  return {};
259
259
  });
260
+ // Enable or disable one declared MCP server. Addressed by the same
261
+ // source-qualified suite id the status rows carry, so the card can toggle a
262
+ // server without holding the source and suite ids apart.
263
+ post(MARKET_ROUTES.setMcpServerEnabled, async (body) => {
264
+ const suiteKey = textField(body['suiteId'] ?? '', 'MCP suite id');
265
+ const serverKey = textField(body['serverKey'] ?? '', 'MCP server key');
266
+ const enabled = body['enabled'];
267
+ if (typeof enabled !== 'boolean')
268
+ throw new Error('missing boolean enabled');
269
+ await manager.setMcpServerEnabled(suiteKey, serverKey, enabled);
270
+ return {};
271
+ });
260
272
  // Manual MCP reconcile: retries failed mounts and clears residual tools
261
273
  // without touching any catalog state.
262
274
  post(MARKET_ROUTES.mcpRetry, async () => {
@@ -299,12 +311,24 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
299
311
  return {};
300
312
  });
301
313
  post(`${MARKET_ROUTES.lspServers}/enabled`, async (body) => {
302
- const id = String(body['id'] ?? '');
314
+ const id = textField(body['id'] ?? '', 'LSP server id');
303
315
  if (id === '')
304
316
  throw new Error('missing LSP server id');
305
- await manager.setLspServerEnabled(id, body['enabled'] !== false);
317
+ const enabled = body['enabled'];
318
+ if (typeof enabled !== 'boolean')
319
+ throw new Error('missing boolean enabled');
320
+ await manager.setLspServerEnabled(id, enabled);
306
321
  return {};
307
322
  });
323
+ // Upgrade repair: drop the hand-written profile layer an older release told
324
+ // the user to add for LSP. It edits a file the user owns, so the profile
325
+ // name is matched against what is on disk and nothing else is touched.
326
+ post(MARKET_ROUTES.migrateLspSeam, async (body) => {
327
+ const profile = textField(body['profile'] ?? '', 'profile name').trim();
328
+ if (profile === '')
329
+ throw new Error('missing profile name');
330
+ return { migration: await manager.migrateLegacyLspSeam(profile) };
331
+ });
308
332
  // User panel CRUD (skills / commands / agent personas). The host web
309
333
  // server matches exact pathnames, so the entry name rides the `name` query
310
334
  // parameter (GET read, POST replace, POST .../delete remove). The routes
@@ -327,8 +351,8 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
327
351
  sendJson(response, 200, { entry });
328
352
  });
329
353
  post(`${userPanelRoute(kind)}/create`, async (body) => {
330
- const name = String(body['name'] ?? '').trim();
331
- const text = String(body['text'] ?? '');
354
+ const name = textField(body['name'] ?? '', 'entry name').trim();
355
+ const text = textField(body['text'] ?? '', 'entry text');
332
356
  if (name === '')
333
357
  throw new Error('missing entry name');
334
358
  const entry = await storeOf(kind).create(name, text);
@@ -337,7 +361,7 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
337
361
  });
338
362
  post(`${userPanelRoute(kind)}/update`, async (body, request) => {
339
363
  const name = queryOf(request).get('name') ?? '';
340
- const text = String(body['text'] ?? '');
364
+ const text = textField(body['text'] ?? '', 'entry text');
341
365
  if (name === '')
342
366
  throw new Error('missing entry name');
343
367
  await storeOf(kind).update(name, text);
@@ -345,7 +369,7 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
345
369
  return {};
346
370
  });
347
371
  post(`${userPanelRoute(kind)}/delete`, async (body) => {
348
- const name = String(body['name'] ?? '');
372
+ const name = textField(body['name'] ?? '', 'entry name');
349
373
  if (name === '')
350
374
  throw new Error('missing entry name');
351
375
  await storeOf(kind).remove(name);
@@ -359,6 +383,23 @@ export function mountSuiteRoutes(hostCtx, manager, panels) {
359
383
  dispose();
360
384
  };
361
385
  }
386
+ /**
387
+ * Read a request field this route documents as a string. Bodies are arbitrary
388
+ * JSON, so the field may arrive as any value; anything but a string is
389
+ * rejected rather than coerced, because `String({})` is `"[object Object]"` —
390
+ * a non-empty value that every check downstream would accept and store as a
391
+ * source url, a branch name, or an entry name. Empty stays empty so each
392
+ * caller keeps owning whether that means "missing".
393
+ */
394
+ function textField(value, label) {
395
+ if (typeof value !== 'string')
396
+ throw new Error(`${label} must be a string`);
397
+ return value;
398
+ }
399
+ /** Render an untrusted field for a diagnostic message. Never a value reader — see `textField`. */
400
+ function describe(value) {
401
+ return String(value);
402
+ }
362
403
  function parseTarget(body) {
363
404
  const sourceId = body['sourceId'];
364
405
  const suiteId = body['suiteId'];
@@ -374,13 +415,13 @@ function parseSourceKind(raw) {
374
415
  return undefined;
375
416
  if (raw === 'git' || raw === 'local' || raw === 'archive')
376
417
  return raw;
377
- throw new Error(`invalid source kind "${String(raw)}"`);
418
+ throw new Error(`invalid source kind "${describe(raw)}"`);
378
419
  }
379
420
  /** Parse an optional SHA-256 hex digest; rejects malformed values. */
380
421
  function parseSha256(raw) {
381
422
  if (raw === undefined || raw === null || raw === '')
382
423
  return undefined;
383
- const value = String(raw).trim().toLowerCase();
424
+ const value = textField(raw, 'sha256').trim().toLowerCase();
384
425
  if (!/^[0-9a-f]{64}$/.test(value))
385
426
  throw new Error('sha256 must be a 64-character hex digest');
386
427
  return value;
@@ -396,6 +437,7 @@ function sameOrigin(request) {
396
437
  return false;
397
438
  }
398
439
  }
440
+ /** Parses the request body; `undefined` for an oversized, unparsable, or failed read. */
399
441
  function readJsonBody(request) {
400
442
  return new Promise(resolve => {
401
443
  let size = 0;
@@ -0,0 +1,70 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * The `[source, suite, kind, name]` identity the host encodes into a discovery
4
+ * name, or `undefined` for a name that is not one — a user-authored role name
5
+ * is a plain string, and anything else carries no identity.
6
+ */
7
+ function roleIdentity(name) {
8
+ let parsed;
9
+ try {
10
+ parsed = JSON.parse(name);
11
+ }
12
+ catch {
13
+ return undefined;
14
+ }
15
+ if (!Array.isArray(parsed))
16
+ return undefined;
17
+ const parts = parsed;
18
+ if (parts.length !== 4)
19
+ return undefined;
20
+ const [source, suite, kind, id] = parts;
21
+ if (typeof source !== 'string' || typeof suite !== 'string' || typeof kind !== 'string' || typeof id !== 'string')
22
+ return undefined;
23
+ return [source, suite, kind, id];
24
+ }
25
+ /** Internal IDs stay opaque to callers; only colliding resource names need qualification. */
26
+ export function namedAgentRoles(entries) {
27
+ const roles = entries.map(entry => {
28
+ const identity = roleIdentity(entry.name);
29
+ const base = identity === undefined ? entry.name : identity[3].replace(/\.agent$/, '');
30
+ return { entry, base, suite: identity?.[1] ?? 'user', source: identity?.[0] ?? 'user', level: 0 };
31
+ });
32
+ const baseCounts = new Map();
33
+ for (const role of roles)
34
+ baseCounts.set(role.base, (baseCounts.get(role.base) ?? 0) + 1);
35
+ for (const role of roles)
36
+ if ((baseCounts.get(role.base) ?? 0) > 1)
37
+ role.level = 1;
38
+ const candidate = (role) => {
39
+ if (role.level === 0)
40
+ return role.base;
41
+ const qualified = `${role.suite}/${role.base}`;
42
+ const fullyQualified = `${role.source}/${role.suite}/${role.base}`;
43
+ if (role.level === 1)
44
+ return qualified;
45
+ if (role.level === 2)
46
+ return fullyQualified;
47
+ return `${fullyQualified}~${createHash('sha256').update(role.entry.name).digest('hex')}`;
48
+ };
49
+ // Recheck all names: a qualified collision may overlap another role's literal name.
50
+ for (;;) {
51
+ const groups = new Map();
52
+ for (const role of roles) {
53
+ const key = candidate(role);
54
+ groups.set(key, [...(groups.get(key) ?? []), role]);
55
+ }
56
+ let changed = false;
57
+ for (const group of groups.values()) {
58
+ if (group.length < 2)
59
+ continue;
60
+ for (const role of group) {
61
+ if (role.level < 3) {
62
+ role.level++;
63
+ changed = true;
64
+ }
65
+ }
66
+ }
67
+ if (!changed)
68
+ return roles.map(role => ({ ...role.entry, callName: candidate(role) }));
69
+ }
70
+ }
@@ -4,7 +4,14 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
4
4
  import { parse as parseYaml } from 'yaml';
5
5
  import { mountSubagentCatalog } from './subagent-catalog.js';
6
6
  import { bindHostLocale } from './host-locale.js';
7
- export const AGENT_ROLE_TOOL_NAME = 'subagents_run';
7
+ import { namedAgentRoles } from './agent-role-names.js';
8
+ import { expandPluginPaths } from '../catalog/plugin-variables.js';
9
+ export const AGENT_ROLE_TOOL_NAME = 'subagent_run';
10
+ /**
11
+ * Absolute delegation-depth cap applied to every role child. The host default is
12
+ * the same value; keeping it explicit preserves the shipped recursion budget.
13
+ */
14
+ const MAX_AGENT_ROLE_DEPTH = 3;
8
15
  function optionalText(value, key) {
9
16
  if (value === undefined)
10
17
  return undefined;
@@ -12,19 +19,7 @@ function optionalText(value, key) {
12
19
  throw new Error(`agent metadata ${key} must be a non-empty string`);
13
20
  return value.trim();
14
21
  }
15
- function toolList(value, key) {
16
- if (value === undefined)
17
- return undefined;
18
- const entries = typeof value === 'string' ? value.split(',') : Array.isArray(value) ? value : [value];
19
- return [
20
- ...new Set(entries.map(entry => {
21
- if (typeof entry !== 'string' || !/^[\w.:-]+$/.test(entry.trim()))
22
- throw new Error(`agent metadata ${key} must contain tool names (comma-separated or a YAML array)`);
23
- return entry.trim();
24
- }))
25
- ];
26
- }
27
- /** Strict YAML parsing prevents malformed routing/restriction metadata from silently inheriting wider privileges. */
22
+ /** Strict YAML parsing prevents malformed routing metadata from silently inheriting wider privileges. */
28
23
  export function parseAgentRole(text) {
29
24
  const match = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
30
25
  if (match === null) {
@@ -32,7 +27,11 @@ export function parseAgentRole(text) {
32
27
  throw new Error('agent frontmatter is not closed');
33
28
  return { content: text, disabled: false };
34
29
  }
35
- const parsed = parseYaml(match[1]);
30
+ // A matched block always carries its body; the check keeps the type honest.
31
+ const frontmatter = match[1];
32
+ if (frontmatter === undefined)
33
+ throw new Error('agent frontmatter is not closed');
34
+ const parsed = parseYaml(frontmatter);
36
35
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
37
36
  throw new Error('agent frontmatter must be a YAML object');
38
37
  const metadata = parsed;
@@ -56,51 +55,71 @@ export function parseAgentRole(text) {
56
55
  ...(provider === undefined ? {} : { provider }),
57
56
  ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
58
57
  ...(title === undefined ? {} : { title }),
59
- ...(description === undefined ? {} : { description }),
60
- ...(metadata.tools === undefined ? {} : { tools: toolList(metadata.tools, 'tools') }),
61
- ...(metadata.disallowedTools === undefined ? {} : { disallowedTools: toolList(metadata.disallowedTools, 'disallowedTools') })
58
+ ...(description === undefined ? {} : { description })
62
59
  };
63
60
  }
64
- /** Bare ids must resolve uniquely; Claude aliases are accepted only if actually advertised by a DSH provider. */
65
- export async function resolveAgentModel(policy, catalog) {
66
- if (policy.model === undefined)
67
- return policy.provider === undefined ? {} : { provider: policy.provider };
68
- const providers = catalog.listProviders();
69
- if (policy.provider !== undefined) {
70
- if (!providers.some(provider => provider.id === policy.provider))
71
- throw new Error(`agent model provider "${policy.provider}" is not registered`);
72
- return { provider: policy.provider, model: policy.model };
61
+ function parentRouteOf(parent) {
62
+ const agent = parent;
63
+ return agent?.session?.requestHeader?.()?.config ?? agent?.options ?? {};
64
+ }
65
+ /**
66
+ * Resolve the child LLM options one card may contribute.
67
+ *
68
+ * A card route is advisory. Only an exact `provider` plus `model` pair is
69
+ * applied, and only when the live LLM runtime accepts it; every other
70
+ * declaration — a bare id, a Claude alias, a lone `provider`, an unsupported
71
+ * effort — degrades to inheriting the parent route and reports a diagnostic.
72
+ * @param policy - the card's parsed routing metadata.
73
+ * @param parent - the calling Agent whose route an unusable declaration falls back to.
74
+ * @param llm - live LLM runtime owning provider, model and effort validation.
75
+ * @param signal - tool-call cancellation signal.
76
+ * @param subject - role name used in diagnostics.
77
+ * @param diagnose - diagnostic sink; degradation is reported, never silent.
78
+ * @returns the options to send, or undefined for pure inheritance.
79
+ */
80
+ export async function resolveAgentOptions(policy, parent, llm, signal, subject, diagnose) {
81
+ // One value, so a half-specified route can never be forwarded downstream.
82
+ const requested = policy.provider === undefined || policy.model === undefined ? undefined : { provider: policy.provider, model: policy.model };
83
+ if (requested === undefined && (policy.provider !== undefined || policy.model !== undefined)) {
84
+ const declared = [policy.provider, policy.model].filter(value => value !== undefined).join('/');
85
+ diagnose(`agent "${subject}": route "${declared}" needs an exact provider and model pair; it is ignored and the child inherits the parent route`);
73
86
  }
74
- const qualified = providers.filter(provider => policy.model.startsWith(`${provider.id}/`)).sort((a, b) => b.id.length - a.id.length)[0];
75
- if (qualified !== undefined) {
76
- const model = policy.model.slice(qualified.id.length + 1);
77
- if (model === '')
78
- throw new Error('agent model id must not be empty');
79
- return { provider: qualified.id, model };
87
+ const effort = policy.reasoningEffort;
88
+ if (effort === undefined && requested === undefined)
89
+ return undefined;
90
+ const inherited = parentRouteOf(parent);
91
+ const provider = requested?.provider ?? inherited.provider;
92
+ const model = requested?.model ?? inherited.model;
93
+ if (provider === undefined || model === undefined) {
94
+ /* v8 ignore next 3 -- an effective route is always known once a parent Agent ran a request. */
95
+ if (effort !== undefined)
96
+ diagnose(`agent "${subject}": reasoning effort "${effort}" has no effective route to validate against; it is ignored`);
97
+ return undefined;
80
98
  }
81
- const matches = (await Promise.all(providers.map(async (provider) => {
82
- try {
83
- return (await catalog.listModels(provider.id)).some(model => model.id === policy.model) ? provider.id : undefined;
84
- }
85
- catch {
86
- return undefined;
87
- }
88
- }))).filter((provider) => provider !== undefined);
89
- if (matches.length !== 1)
90
- throw new Error(`agent model "${policy.model}" ${matches.length === 0 ? 'is not advertised' : 'is ambiguous'}; configure provider and model explicitly`);
91
- return { provider: matches[0], model: policy.model };
92
- }
93
- /** Read the same declaration for catalog summaries and execution, including inline resources. */
94
- export async function readAgentRole(entry) {
95
- return parseAgentRole(entry.rawText ?? (await readFile(entry.path, 'utf8')));
99
+ const routeChanged = provider !== inherited.provider || model !== inherited.model;
100
+ const effectiveEffort = effort ?? (routeChanged ? undefined : inherited.reasoningEffort);
101
+ try {
102
+ await llm.resolveCallConfig({ provider, model, ...(effectiveEffort === undefined ? {} : { reasoningEffort: effectiveEffort }) }, signal);
103
+ }
104
+ catch (error) {
105
+ signal.throwIfAborted();
106
+ diagnose(`agent "${subject}": route ${provider}/${model} is unusable (${String(error)}); it is ignored and the child inherits the parent route`);
107
+ return undefined;
108
+ }
109
+ // Cancellation raised during the adapter lookup must not create a child.
110
+ signal.throwIfAborted();
111
+ return {
112
+ ...(requested ?? {}),
113
+ ...(effort === undefined ? {} : { reasoningEffort: effort })
114
+ };
96
115
  }
97
116
  /** Stable summaries; invalid declarations are excluded and transient read failures abort publication. */
98
- export async function agentRoleCatalog(entries, signal, diagnose = () => { }) {
117
+ export async function agentRoleCatalog(entries, signal, diagnose = () => { }, projectDir) {
99
118
  const summaries = [];
100
119
  const counts = new Map();
101
120
  for (const entry of entries)
102
121
  counts.set(entry.name, (counts.get(entry.name) ?? 0) + 1);
103
- for (const entry of entries) {
122
+ for (const entry of namedAgentRoles(entries)) {
104
123
  signal.throwIfAborted();
105
124
  if (entry.disabled)
106
125
  continue;
@@ -119,7 +138,7 @@ export async function agentRoleCatalog(entries, signal, diagnose = () => { }) {
119
138
  }
120
139
  let policy;
121
140
  try {
122
- policy = parseAgentRole(text);
141
+ policy = expandPolicyPaths(parseAgentRole(text), entry, projectDir);
123
142
  }
124
143
  catch (error) {
125
144
  diagnose(`${entry.path}: ${String(error)}`);
@@ -128,92 +147,109 @@ export async function agentRoleCatalog(entries, signal, diagnose = () => { }) {
128
147
  if (policy.disabled)
129
148
  continue;
130
149
  const description = (policy.description ?? entry.description).replaceAll(/\s+/g, ' ').trim();
150
+ // Advertise a route only when the executor would apply it.
151
+ const exact = policy.provider !== undefined && policy.model !== undefined;
131
152
  summaries.push({
132
- name: entry.name,
133
- title: policy.title ?? entry.title ?? entry.name,
153
+ name: entry.callName,
154
+ roleId: entry.name,
155
+ title: policy.title ?? entry.title ?? entry.callName,
134
156
  description: description.length <= 500 ? description : `${description.slice(0, 497)}...`,
135
- ...(policy.provider === undefined ? {} : { provider: policy.provider }),
136
- ...(policy.model === undefined ? {} : { model: policy.model }),
157
+ ...(exact ? { provider: policy.provider, model: policy.model } : {}),
137
158
  ...(policy.reasoningEffort === undefined ? {} : { reasoningEffort: policy.reasoningEffort })
138
159
  });
139
160
  }
140
161
  signal.throwIfAborted();
141
162
  return summaries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
142
163
  }
143
- /** Resolve fresh panel state and raw Markdown on every call, including after edits, disabling and uninstall. */
144
- export async function executeAgentRole(host, listRoles, name, prompt, parent, signal) {
164
+ /** A suite card carries the same path variables as every other surface its author wrote for. */
165
+ function expandPolicyPaths(policy, entry, projectDir) {
166
+ if (entry.suiteRoot === undefined && entry.suiteData === undefined && projectDir === undefined)
167
+ return policy;
168
+ const context = {
169
+ ...(entry.suiteRoot === undefined ? {} : { root: entry.suiteRoot }),
170
+ ...(entry.suiteData === undefined ? {} : { data: entry.suiteData }),
171
+ ...(projectDir === undefined ? {} : { projectDir })
172
+ };
173
+ return {
174
+ ...policy,
175
+ content: expandPluginPaths(policy.content, context),
176
+ ...(policy.title === undefined ? {} : { title: expandPluginPaths(policy.title, context) }),
177
+ ...(policy.description === undefined ? {} : { description: expandPluginPaths(policy.description, context) })
178
+ };
179
+ }
180
+ /** Read the same declaration for catalog summaries and execution, including inline resources. */
181
+ export async function readAgentRole(entry, projectDir) {
182
+ return expandPolicyPaths(parseAgentRole(entry.rawText ?? (await readFile(entry.path, 'utf8'))), entry, projectDir);
183
+ }
184
+ /** The calling session's directory, which is what a card's `${CLAUDE_PROJECT_DIR}` names. */
185
+ function sessionCwd(agent) {
186
+ const cwd = agent?.session?.header?.cwd;
187
+ return typeof cwd === 'string' && cwd !== '' ? cwd : undefined;
188
+ }
189
+ /**
190
+ * Start one role child and return its durable id without waiting for the result.
191
+ * Resume state, steering, and the settlement notice belong to the host's
192
+ * continuation manager; this executor owns only role resolution.
193
+ */
194
+ export async function executeAgentRole(host, listRoles, agentName, prompt, parent, signal, diagnose = () => { }) {
145
195
  if (parent === undefined)
146
- throw new Error('subagents_run requires a calling agent');
196
+ throw new Error('subagent_run requires a calling agent');
147
197
  if (prompt.trim() === '')
148
- throw new Error('subagents_run requires a non-empty prompt');
198
+ throw new Error('subagent_run requires a non-empty prompt');
149
199
  signal.throwIfAborted();
150
- const entries = (await listRoles(parent)).filter(entry => entry.name === name);
151
- if (entries.length !== 1)
152
- throw new Error(`agent role "${name}" is unavailable or ambiguous`);
153
- const entry = entries[0];
200
+ const [entry, ...duplicates] = namedAgentRoles(await listRoles(parent)).filter(candidate => candidate.callName === agentName);
201
+ if (entry === undefined || duplicates.length > 0)
202
+ throw new Error(`agent "${agentName}" is unavailable or ambiguous`);
154
203
  if (entry.disabled)
155
- throw new Error(`agent role "${name}" is disabled`);
156
- const policy = await readAgentRole(entry);
204
+ throw new Error(`agent "${agentName}" is disabled`);
205
+ const policy = await readAgentRole(entry, sessionCwd(parent));
157
206
  if (policy.disabled)
158
- throw new Error(`agent role "${name}" is disabled`);
159
- const agentOptions = { ...(await resolveAgentModel(policy, host.llm)), ...(policy.reasoningEffort === undefined ? {} : { reasoningEffort: policy.reasoningEffort }) };
160
- const parentAgent = parent;
161
- const parentOptions = parentAgent.session?.requestHeader?.()?.config ?? parentAgent.options ?? {};
162
- const provider = agentOptions.provider ?? parentOptions.provider;
163
- const model = agentOptions.model ?? parentOptions.model;
164
- if (provider === undefined || model === undefined)
165
- throw new Error('subagents_run requires an effective provider and model');
166
- const routeChanged = provider !== parentOptions.provider || model !== parentOptions.model;
167
- const reasoningEffort = agentOptions.reasoningEffort ?? (routeChanged ? undefined : parentOptions.reasoningEffort);
168
- await host.llm.resolveCallConfig({ provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }) }, signal);
207
+ throw new Error(`agent "${agentName}" is disabled`);
208
+ const agentOptions = await resolveAgentOptions(policy, parent, host.llm, signal, agentName, diagnose);
169
209
  signal.throwIfAborted();
170
- const run = await host.subagents.start('spawn', {
171
- label: name,
172
- prompt: [{ type: 'text', text: prompt }],
173
- parent,
174
- signal,
175
- maxDepth: 3,
176
- agentOptions,
177
- persona: policy.content,
178
- ...(policy.tools === undefined && policy.disallowedTools === undefined
179
- ? {}
180
- : {
181
- toolFilter: {
182
- ...(policy.tools === undefined ? {} : { allow: policy.tools }),
183
- ...(policy.disallowedTools === undefined ? {} : { deny: policy.disallowedTools })
184
- }
185
- })
210
+ const started = await host.subagents.startContinuable({
211
+ provider: 'spawn',
212
+ label: agentName,
213
+ request: {
214
+ prompt: [{ type: 'text', text: prompt }],
215
+ parent,
216
+ maxDepth: MAX_AGENT_ROLE_DEPTH,
217
+ persona: policy.content,
218
+ ...(agentOptions === undefined ? {} : { agentOptions })
219
+ },
220
+ signal
186
221
  });
187
- try {
188
- const result = await run.result;
189
- if (result.stopReason !== 'completed')
190
- throw new Error(`agent role "${name}" stopped: ${result.stopReason}; partial output: ${JSON.stringify(result.output)}`);
191
- return { runId: run.id, output: result.output };
192
- }
193
- finally {
194
- await run.dispose();
195
- }
222
+ return { subagentId: started.childId };
196
223
  }
197
224
  /** Mount once after tools, llm and subagents are available; the returned disposer belongs to the plugin lifecycle. */
198
225
  export function mountAgentRoleTool(ctx, listRoles, t = bindHostLocale(undefined)) {
199
226
  const host = ctx;
200
227
  const tool = defineTool({
201
228
  name: AGENT_ROLE_TOOL_NAME,
202
- description: 'Delegate a self-contained task to a role from the current subagent catalog and wait for its result. The child starts without the parent conversation. Its saved provider, model, reasoning effort, persona and tool restrictions are applied automatically.',
229
+ description: 'Delegate a self-contained task to a role from the current subagent catalog. The child starts without the parent conversation and runs ' +
230
+ 'in the background: this returns a durable subagent id immediately. When the run settles the runtime sends you a notice carrying its ' +
231
+ "outcome and closing message, and send_message steers the child while it runs. The role's saved persona and model settings are applied automatically.",
203
232
  parameters: {
204
- role: { type: 'string', required: true, description: 'Exact role ID from the current subagent catalog.' },
233
+ agent: { type: 'string', required: true, description: 'Exact callable role name from the current subagent catalog.' },
205
234
  prompt: { type: 'string', required: true, description: 'Complete task and context for the isolated child.' }
206
235
  },
207
- output: { schema: { type: 'json' }, render: (_args, result) => [{ type: 'text', text: JSON.stringify(result) }] },
236
+ output: {
237
+ schema: {
238
+ type: 'object',
239
+ additionalProperties: false,
240
+ properties: { subagentId: { type: 'string', required: true } }
241
+ },
242
+ render: (_args, value) => [{ type: 'text', text: `started subagent ${value.subagentId}` }]
243
+ },
208
244
  isConcurrencySafe: () => true,
209
245
  async execute(args, exec) {
210
- return executeAgentRole(host, listRoles, args.role, args.prompt, exec.agent, exec.signal);
246
+ return executeAgentRole(host, listRoles, args.agent, args.prompt, exec.agent, exec.signal, message => ctx.logger?.warn(message));
211
247
  }
212
248
  });
213
249
  const disposeTool = host.tools.register(tool);
214
250
  let disposeCatalog;
215
251
  try {
216
- disposeCatalog = mountSubagentCatalog(ctx, tool, async (agent, signal) => agentRoleCatalog(await listRoles(agent), signal, message => ctx.logger?.warn(message)), t);
252
+ disposeCatalog = mountSubagentCatalog(ctx, tool, async (agent, signal) => agentRoleCatalog(await listRoles(agent), signal, message => ctx.logger?.warn(message), sessionCwd(agent)), t);
217
253
  }
218
254
  catch (error) {
219
255
  disposeTool();