dsh-agent-plugins-market 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +117 -111
  2. package/README.zh.md +112 -98
  3. package/client/client.js +547 -540
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog-context.js +181 -0
  6. package/lib/application/catalog.js +217 -1110
  7. package/lib/application/details.js +6 -6
  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 +246 -0
  11. package/lib/application/panel-resources.js +29 -12
  12. package/lib/application/ports.js +21 -0
  13. package/lib/application/project-agent-roles.js +1 -1
  14. package/lib/application/snapshot-cache.js +160 -0
  15. package/lib/application/source-store.js +521 -0
  16. package/lib/catalog/archive.js +34 -29
  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 +66 -55
  22. package/lib/catalog/project-config.js +2 -1
  23. package/lib/catalog/project-hooks.js +9 -1
  24. package/lib/catalog/scan-resolvers.js +29 -45
  25. package/lib/catalog/skills-parse.js +14 -10
  26. package/lib/catalog/source-catalog.js +3 -2
  27. package/lib/catalog/suite-components.js +5 -5
  28. package/lib/catalog/suite-scanner.js +3 -1
  29. package/lib/catalog/surfaces.js +5 -26
  30. package/lib/catalog/validate.js +7 -11
  31. package/lib/contracts/market.js +1 -0
  32. package/lib/index.js +91 -208
  33. package/lib/model/types.js +26 -3
  34. package/lib/routes.js +45 -15
  35. package/lib/runtime/agent-role-names.js +70 -0
  36. package/lib/runtime/agent-role-router.js +116 -102
  37. package/lib/runtime/commands-mounts.js +9 -6
  38. package/lib/runtime/deadline.js +28 -0
  39. package/lib/runtime/feedback-tool.js +94 -43
  40. package/lib/runtime/hooks-mounts.js +9 -11
  41. package/lib/runtime/host-locale.js +10 -21
  42. package/lib/runtime/lsp-direct-config.js +19 -19
  43. package/lib/runtime/lsp-mounts.js +159 -68
  44. package/lib/runtime/lsp-server-state.js +4 -4
  45. package/lib/runtime/lsp-status.js +1 -1
  46. package/lib/runtime/mcp-auth-record.js +42 -0
  47. package/lib/runtime/mcp-backend.js +6 -5
  48. package/lib/runtime/mcp-client/config.js +0 -1
  49. package/lib/runtime/mcp-client/connection.js +32 -37
  50. package/lib/runtime/mcp-client/host-contract.js +10 -0
  51. package/lib/runtime/mcp-client/oauth.js +5 -7
  52. package/lib/runtime/mcp-client/plugin-identity.js +28 -0
  53. package/lib/runtime/mcp-client/projection.js +202 -0
  54. package/lib/runtime/mcp-client/tools.js +4 -189
  55. package/lib/runtime/mcp-config.js +29 -10
  56. package/lib/runtime/mcp-direct-config.js +33 -16
  57. package/lib/runtime/mcp-mounts.js +35 -75
  58. package/lib/runtime/mcp-overrides.js +5 -5
  59. package/lib/runtime/mcp-redaction.js +3 -2
  60. package/lib/runtime/mcp-status.js +7 -8
  61. package/lib/runtime/mount-lifecycle.js +73 -0
  62. package/lib/runtime/profile-seam.js +321 -0
  63. package/lib/runtime/project-runtime.js +11 -8
  64. package/lib/runtime/reconcile-scheduler.js +99 -0
  65. package/lib/runtime/reconciler.js +4 -0
  66. package/lib/runtime/server-config.js +6 -4
  67. package/lib/runtime/settings-namespace.js +136 -0
  68. package/lib/runtime/skills-provider.js +5 -3
  69. package/lib/runtime/source-auto-update.js +70 -0
  70. package/lib/{model/state.js → runtime/state-store.js} +9 -11
  71. package/lib/runtime/storage-migration.js +35 -18
  72. package/lib/runtime/subagent-catalog.js +5 -4
  73. package/lib/runtime/user-commands.js +2 -2
  74. package/lib/runtime/user-panels.js +31 -21
  75. package/lib/runtime/user-store.js +12 -13
  76. package/lib/types/application/catalog-context.d.ts +118 -0
  77. package/lib/types/application/catalog.d.ts +99 -304
  78. package/lib/types/application/install-store.d.ts +21 -0
  79. package/lib/types/application/lsp-service.d.ts +36 -0
  80. package/lib/types/application/mcp-service.d.ts +71 -0
  81. package/lib/types/application/panel-resources.d.ts +8 -0
  82. package/lib/types/application/ports.d.ts +72 -0
  83. package/lib/types/application/queries.d.ts +13 -26
  84. package/lib/types/application/snapshot-cache.d.ts +51 -0
  85. package/lib/types/application/source-store.d.ts +100 -0
  86. package/lib/types/catalog/archive.d.ts +3 -11
  87. package/lib/types/catalog/component-files.d.ts +4 -0
  88. package/lib/types/catalog/fs-probes.d.ts +10 -0
  89. package/lib/types/catalog/manifests.d.ts +17 -16
  90. package/lib/types/catalog/native-project.d.ts +2 -2
  91. package/lib/types/catalog/paths.d.ts +47 -15
  92. package/lib/types/catalog/scan-pipeline.d.ts +3 -3
  93. package/lib/types/catalog/scan-resolvers.d.ts +3 -9
  94. package/lib/types/catalog/skills-parse.d.ts +0 -7
  95. package/lib/types/catalog/source-catalog.d.ts +3 -3
  96. package/lib/types/catalog/suite-scanner.d.ts +8 -6
  97. package/lib/types/catalog/validate.d.ts +0 -2
  98. package/lib/types/contracts/lsp-status.d.ts +35 -0
  99. package/lib/types/contracts/market.d.ts +3 -2
  100. package/lib/types/contracts/mcp-status.d.ts +4 -0
  101. package/lib/types/model/types.d.ts +29 -5
  102. package/lib/types/runtime/agent-role-names.d.ts +6 -0
  103. package/lib/types/runtime/agent-role-router.d.ts +54 -51
  104. package/lib/types/runtime/deadline.d.ts +18 -0
  105. package/lib/types/runtime/feedback-tool.d.ts +33 -11
  106. package/lib/types/runtime/hooks-mounts.d.ts +1 -1
  107. package/lib/types/runtime/host-locale.d.ts +1 -1
  108. package/lib/types/runtime/lsp-direct-config.d.ts +3 -3
  109. package/lib/types/runtime/lsp-mounts.d.ts +62 -19
  110. package/lib/types/runtime/lsp-status.d.ts +2 -1
  111. package/lib/types/runtime/mcp-auth-record.d.ts +29 -0
  112. package/lib/types/runtime/mcp-backend.d.ts +7 -4
  113. package/lib/types/runtime/mcp-client/config.d.ts +0 -2
  114. package/lib/types/runtime/mcp-client/host-contract.d.ts +78 -0
  115. package/lib/types/runtime/mcp-client/oauth.d.ts +1 -1
  116. package/lib/types/runtime/mcp-client/plugin-identity.d.ts +16 -0
  117. package/lib/types/runtime/mcp-client/projection.d.ts +45 -0
  118. package/lib/types/runtime/mcp-client/tools.d.ts +5 -72
  119. package/lib/types/runtime/mcp-config.d.ts +25 -4
  120. package/lib/types/runtime/mcp-direct-config.d.ts +18 -4
  121. package/lib/types/runtime/mcp-mounts.d.ts +10 -15
  122. package/lib/types/runtime/mcp-overrides.d.ts +1 -1
  123. package/lib/types/runtime/mcp-status.d.ts +1 -1
  124. package/lib/types/runtime/mount-lifecycle.d.ts +53 -0
  125. package/lib/types/runtime/profile-seam.d.ts +64 -0
  126. package/lib/types/runtime/reconcile-scheduler.d.ts +45 -0
  127. package/lib/types/runtime/reconciler.d.ts +2 -0
  128. package/lib/types/runtime/settings-namespace.d.ts +76 -0
  129. package/lib/types/runtime/source-auto-update.d.ts +33 -0
  130. package/lib/types/{model/state.d.ts → runtime/state-store.d.ts} +2 -2
  131. package/lib/types/runtime/storage-migration.d.ts +5 -3
  132. package/lib/types/runtime/subagent-catalog.d.ts +2 -0
  133. package/lib/types/runtime/user-panels.d.ts +8 -11
  134. package/lib/types/runtime/user-store.d.ts +2 -4
  135. package/package.json +48 -21
  136. package/schemas/README.md +1 -1
@@ -4,7 +4,13 @@ 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
+ export const AGENT_ROLE_TOOL_NAME = 'subagent_run';
9
+ /**
10
+ * Absolute delegation-depth cap applied to every role child. The host default is
11
+ * the same value; keeping it explicit preserves the shipped recursion budget.
12
+ */
13
+ const MAX_AGENT_ROLE_DEPTH = 3;
8
14
  function optionalText(value, key) {
9
15
  if (value === undefined)
10
16
  return undefined;
@@ -12,19 +18,7 @@ function optionalText(value, key) {
12
18
  throw new Error(`agent metadata ${key} must be a non-empty string`);
13
19
  return value.trim();
14
20
  }
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. */
21
+ /** Strict YAML parsing prevents malformed routing metadata from silently inheriting wider privileges. */
28
22
  export function parseAgentRole(text) {
29
23
  const match = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
30
24
  if (match === null) {
@@ -32,7 +26,11 @@ export function parseAgentRole(text) {
32
26
  throw new Error('agent frontmatter is not closed');
33
27
  return { content: text, disabled: false };
34
28
  }
35
- const parsed = parseYaml(match[1]);
29
+ // A matched block always carries its body; the check keeps the type honest.
30
+ const frontmatter = match[1];
31
+ if (frontmatter === undefined)
32
+ throw new Error('agent frontmatter is not closed');
33
+ const parsed = parseYaml(frontmatter);
36
34
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
37
35
  throw new Error('agent frontmatter must be a YAML object');
38
36
  const metadata = parsed;
@@ -56,43 +54,63 @@ export function parseAgentRole(text) {
56
54
  ...(provider === undefined ? {} : { provider }),
57
55
  ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
58
56
  ...(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') })
57
+ ...(description === undefined ? {} : { description })
62
58
  };
63
59
  }
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 };
60
+ function parentRouteOf(parent) {
61
+ const agent = parent;
62
+ return agent?.session?.requestHeader?.()?.config ?? agent?.options ?? {};
63
+ }
64
+ /**
65
+ * Resolve the child LLM options one card may contribute.
66
+ *
67
+ * A card route is advisory. Only an exact `provider` plus `model` pair is
68
+ * applied, and only when the live LLM runtime accepts it; every other
69
+ * declaration — a bare id, a Claude alias, a lone `provider`, an unsupported
70
+ * effort — degrades to inheriting the parent route and reports a diagnostic.
71
+ * @param policy - the card's parsed routing metadata.
72
+ * @param parent - the calling Agent whose route an unusable declaration falls back to.
73
+ * @param llm - live LLM runtime owning provider, model and effort validation.
74
+ * @param signal - tool-call cancellation signal.
75
+ * @param subject - role name used in diagnostics.
76
+ * @param diagnose - diagnostic sink; degradation is reported, never silent.
77
+ * @returns the options to send, or undefined for pure inheritance.
78
+ */
79
+ export async function resolveAgentOptions(policy, parent, llm, signal, subject, diagnose) {
80
+ // One value, so a half-specified route can never be forwarded downstream.
81
+ const requested = policy.provider === undefined || policy.model === undefined ? undefined : { provider: policy.provider, model: policy.model };
82
+ if (requested === undefined && (policy.provider !== undefined || policy.model !== undefined)) {
83
+ const declared = [policy.provider, policy.model].filter(value => value !== undefined).join('/');
84
+ diagnose(`agent "${subject}": route "${declared}" needs an exact provider and model pair; it is ignored and the child inherits the parent route`);
73
85
  }
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 };
86
+ const effort = policy.reasoningEffort;
87
+ if (effort === undefined && requested === undefined)
88
+ return undefined;
89
+ const inherited = parentRouteOf(parent);
90
+ const provider = requested?.provider ?? inherited.provider;
91
+ const model = requested?.model ?? inherited.model;
92
+ if (provider === undefined || model === undefined) {
93
+ /* v8 ignore next 3 -- an effective route is always known once a parent Agent ran a request. */
94
+ if (effort !== undefined)
95
+ diagnose(`agent "${subject}": reasoning effort "${effort}" has no effective route to validate against; it is ignored`);
96
+ return undefined;
80
97
  }
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')));
98
+ const routeChanged = provider !== inherited.provider || model !== inherited.model;
99
+ const effectiveEffort = effort ?? (routeChanged ? undefined : inherited.reasoningEffort);
100
+ try {
101
+ await llm.resolveCallConfig({ provider, model, ...(effectiveEffort === undefined ? {} : { reasoningEffort: effectiveEffort }) }, signal);
102
+ }
103
+ catch (error) {
104
+ signal.throwIfAborted();
105
+ diagnose(`agent "${subject}": route ${provider}/${model} is unusable (${String(error)}); it is ignored and the child inherits the parent route`);
106
+ return undefined;
107
+ }
108
+ // Cancellation raised during the adapter lookup must not create a child.
109
+ signal.throwIfAborted();
110
+ return {
111
+ ...(requested ?? {}),
112
+ ...(effort === undefined ? {} : { reasoningEffort: effort })
113
+ };
96
114
  }
97
115
  /** Stable summaries; invalid declarations are excluded and transient read failures abort publication. */
98
116
  export async function agentRoleCatalog(entries, signal, diagnose = () => { }) {
@@ -100,7 +118,7 @@ export async function agentRoleCatalog(entries, signal, diagnose = () => { }) {
100
118
  const counts = new Map();
101
119
  for (const entry of entries)
102
120
  counts.set(entry.name, (counts.get(entry.name) ?? 0) + 1);
103
- for (const entry of entries) {
121
+ for (const entry of namedAgentRoles(entries)) {
104
122
  signal.throwIfAborted();
105
123
  if (entry.disabled)
106
124
  continue;
@@ -128,86 +146,82 @@ export async function agentRoleCatalog(entries, signal, diagnose = () => { }) {
128
146
  if (policy.disabled)
129
147
  continue;
130
148
  const description = (policy.description ?? entry.description).replaceAll(/\s+/g, ' ').trim();
149
+ // Advertise a route only when the executor would apply it.
150
+ const exact = policy.provider !== undefined && policy.model !== undefined;
131
151
  summaries.push({
132
- name: entry.name,
133
- title: policy.title ?? entry.title ?? entry.name,
152
+ name: entry.callName,
153
+ roleId: entry.name,
154
+ title: policy.title ?? entry.title ?? entry.callName,
134
155
  description: description.length <= 500 ? description : `${description.slice(0, 497)}...`,
135
- ...(policy.provider === undefined ? {} : { provider: policy.provider }),
136
- ...(policy.model === undefined ? {} : { model: policy.model }),
156
+ ...(exact ? { provider: policy.provider, model: policy.model } : {}),
137
157
  ...(policy.reasoningEffort === undefined ? {} : { reasoningEffort: policy.reasoningEffort })
138
158
  });
139
159
  }
140
160
  signal.throwIfAborted();
141
161
  return summaries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
142
162
  }
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) {
163
+ /** Read the same declaration for catalog summaries and execution, including inline resources. */
164
+ export async function readAgentRole(entry) {
165
+ return parseAgentRole(entry.rawText ?? (await readFile(entry.path, 'utf8')));
166
+ }
167
+ /**
168
+ * Start one role child and return its durable id without waiting for the result.
169
+ * Resume state, steering, and the settlement notice belong to the host's
170
+ * continuation manager; this executor owns only role resolution.
171
+ */
172
+ export async function executeAgentRole(host, listRoles, agentName, prompt, parent, signal, diagnose = () => { }) {
145
173
  if (parent === undefined)
146
- throw new Error('subagents_run requires a calling agent');
174
+ throw new Error('subagent_run requires a calling agent');
147
175
  if (prompt.trim() === '')
148
- throw new Error('subagents_run requires a non-empty prompt');
176
+ throw new Error('subagent_run requires a non-empty prompt');
149
177
  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];
178
+ const [entry, ...duplicates] = namedAgentRoles(await listRoles(parent)).filter(candidate => candidate.callName === agentName);
179
+ if (entry === undefined || duplicates.length > 0)
180
+ throw new Error(`agent "${agentName}" is unavailable or ambiguous`);
154
181
  if (entry.disabled)
155
- throw new Error(`agent role "${name}" is disabled`);
182
+ throw new Error(`agent "${agentName}" is disabled`);
156
183
  const policy = await readAgentRole(entry);
157
184
  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);
185
+ throw new Error(`agent "${agentName}" is disabled`);
186
+ const agentOptions = await resolveAgentOptions(policy, parent, host.llm, signal, agentName, diagnose);
169
187
  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
- })
188
+ const started = await host.subagents.startContinuable({
189
+ provider: 'spawn',
190
+ label: agentName,
191
+ request: {
192
+ prompt: [{ type: 'text', text: prompt }],
193
+ parent,
194
+ maxDepth: MAX_AGENT_ROLE_DEPTH,
195
+ persona: policy.content,
196
+ ...(agentOptions === undefined ? {} : { agentOptions })
197
+ },
198
+ signal
186
199
  });
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
- }
200
+ return { subagentId: started.childId };
196
201
  }
197
202
  /** Mount once after tools, llm and subagents are available; the returned disposer belongs to the plugin lifecycle. */
198
203
  export function mountAgentRoleTool(ctx, listRoles, t = bindHostLocale(undefined)) {
199
204
  const host = ctx;
200
205
  const tool = defineTool({
201
206
  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.',
207
+ description: 'Delegate a self-contained task to a role from the current subagent catalog. The child starts without the parent conversation and runs ' +
208
+ 'in the background: this returns a durable subagent id immediately. When the run settles the runtime sends you a notice carrying its ' +
209
+ "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
210
  parameters: {
204
- role: { type: 'string', required: true, description: 'Exact role ID from the current subagent catalog.' },
211
+ agent: { type: 'string', required: true, description: 'Exact callable role name from the current subagent catalog.' },
205
212
  prompt: { type: 'string', required: true, description: 'Complete task and context for the isolated child.' }
206
213
  },
207
- output: { schema: { type: 'json' }, render: (_args, result) => [{ type: 'text', text: JSON.stringify(result) }] },
214
+ output: {
215
+ schema: {
216
+ type: 'object',
217
+ additionalProperties: false,
218
+ properties: { subagentId: { type: 'string', required: true } }
219
+ },
220
+ render: (_args, value) => [{ type: 'text', text: `started subagent ${value.subagentId}` }]
221
+ },
208
222
  isConcurrencySafe: () => true,
209
223
  async execute(args, exec) {
210
- return executeAgentRole(host, listRoles, args.role, args.prompt, exec.agent, exec.signal);
224
+ return executeAgentRole(host, listRoles, args.agent, args.prompt, exec.agent, exec.signal, message => ctx.logger?.warn(message));
211
225
  }
212
226
  });
213
227
  const disposeTool = host.tools.register(tool);
@@ -19,7 +19,7 @@ export class CommandMountRegistry {
19
19
  const diagnostics = [];
20
20
  const wanted = new Map();
21
21
  for (const suite of enabledSuites) {
22
- const specs = suite.activeSurfaces?.commands === false ? [] : await readCommands(suite.root, suite.resources?.commands);
22
+ const specs = suite.activeSurfaces.commands === false ? [] : await readCommands(suite.root, suite.resources?.commands);
23
23
  for (const spec of specs) {
24
24
  // The registry key is source-qualified: bare suite ids are unique per
25
25
  // source only, so two sources' same-named suites would collide.
@@ -58,9 +58,12 @@ export class CommandMountRegistry {
58
58
  ...(spec.hint === undefined ? {} : { input: { hint: spec.hint } }),
59
59
  handler: invocation => {
60
60
  const agent = invocation.agent;
61
- const text = [this.t('commandForwardTitle', { command: spec.name, suite: spec.suiteId }), '', spec.body.replaceAll('$ARGUMENTS', invocation.rawInput.trim())].join('\n');
61
+ // The template rides the agent verbatim: a decorator line naming
62
+ // this plugin or the suite would be text the command author never
63
+ // wrote, and the follow-up's `source` already records provenance.
64
+ const text = spec.body.replaceAll('$ARGUMENTS', invocation.rawInput.trim());
62
65
  agent.followup({ content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'dsh-agent-plugins-market' } });
63
- return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name, suite: spec.suiteId }) };
66
+ return { kind: 'success', text: this.t('commandAcknowledged', { command: spec.name }) };
64
67
  }
65
68
  });
66
69
  this.live.set(key, disposer);
@@ -111,11 +114,11 @@ export async function readCommands(root, resources) {
111
114
  return specs;
112
115
  }
113
116
  function commandMeta(text) {
114
- const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
115
- if (match === null)
117
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text)?.[1];
118
+ if (frontmatter === undefined)
116
119
  return undefined;
117
120
  try {
118
- const raw = parseYaml(match[1]);
121
+ const raw = parseYaml(frontmatter);
119
122
  if (typeof raw !== 'object' || raw === null)
120
123
  return undefined;
121
124
  const record = raw;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Bounded waits for work this plugin cannot cancel.
3
+ *
4
+ * Every wait here is a race against a timer: the caller stops waiting and the
5
+ * underlying work keeps running. That is the only safe shape for a mount and
6
+ * for the change pipeline that drives it — a spawned server process or a
7
+ * browser authorization the user is completing must not be abandoned just
8
+ * because nobody is waiting for it any more.
9
+ *
10
+ * @module runtime/deadline
11
+ */
12
+ /**
13
+ * Resolve `true` once `work` settles, or `false` when `deadlineMs` elapses.
14
+ *
15
+ * A rejection counts as settled: this helper only reports timing, so callers
16
+ * that need the failure attach their own handler to `work`.
17
+ */
18
+ export function settlesWithin(work, deadlineMs) {
19
+ return new Promise(resolve => {
20
+ const timer = setTimeout(() => resolve(false), deadlineMs);
21
+ timer.unref?.();
22
+ const settle = () => {
23
+ clearTimeout(timer);
24
+ resolve(true);
25
+ };
26
+ void work.then(settle, settle);
27
+ });
28
+ }
@@ -5,18 +5,26 @@
5
5
  * default true), the model can call `report_market_issue` when it concludes a
6
6
  * problem it is looking at is caused by dsh-agent-plugins-market — a failed
7
7
  * install, a misdetected source, a market UI defect the user just described.
8
- * The tool files a GitHub issue on the plugin repository through the GitHub
9
- * REST API, or falls back to appending a local JSONL record under the plugin
10
- * data root when no repository host context is available.
8
+ *
9
+ * Filing happens in three steps, most preferred first: the `gh` CLI when it is
10
+ * installed and authenticated, the GitHub REST API when the process carries a
11
+ * `GITHUB_TOKEN` / `GH_TOKEN`, and otherwise nothing is filed — the caller
12
+ * gets the complete issue text plus a prefilled "new issue" URL that is opened
13
+ * in the browser and handed back to the user to submit.
11
14
  *
12
15
  * The tool is deliberately narrow: the title/body must describe the problem,
13
- * submissions are rate-limited, and the setting switch unregisters the tool
14
- * on the next reconcile pass.
16
+ * real submissions are rate-limited, and the setting switch unregisters the
17
+ * tool on the next reconcile pass.
15
18
  * @module runtime/feedback-tool
16
19
  */
17
- import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
20
+ import { execFile } from 'node:child_process';
21
+ import { readFile } from 'node:fs/promises';
18
22
  import { join } from 'node:path';
23
+ import { promisify } from 'node:util';
24
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
19
25
  import { defineTool } from '@deepseek-ai/dsh-tools';
26
+ import { platformBrowserOpener } from './mcp-client/oauth.js';
27
+ const execFileAsync = promisify(execFile);
20
28
  /** The GitHub repository experience feedback files issues against. */
21
29
  export const FEEDBACK_REPO_OWNER = 'Sivan757';
22
30
  export const FEEDBACK_REPO_NAME = 'dsh-agent-plugins-market';
@@ -24,12 +32,19 @@ export const FEEDBACK_REPO_NAME = 'dsh-agent-plugins-market';
24
32
  export const FEEDBACK_LABEL = 'agent-feedback';
25
33
  /** Minimum milliseconds between two accepted submissions (burst guard). */
26
34
  const SUBMIT_COOLDOWN_MS = 60_000;
35
+ /** Timeout for one `gh issue create` invocation. */
36
+ const GH_TIMEOUT_MS = 30_000;
37
+ /** The public "new issue" page for one report, prefilled with title and body. */
38
+ export function newIssueUrl(title, body) {
39
+ const query = new URLSearchParams({ title, body, labels: FEEDBACK_LABEL });
40
+ return `https://github.com/${FEEDBACK_REPO_OWNER}/${FEEDBACK_REPO_NAME}/issues/new?${query.toString()}`;
41
+ }
27
42
  /**
28
- * Submit one experience report. Tries the GitHub issue API first (works when
29
- * the deployment carries a GITHUB_TOKEN / GH_TOKEN with repo scope), then
30
- * falls back to the local JSONL spool under `<dataRoot>/feedback/`.
43
+ * Submit one experience report through `gh`, then the REST API, then a
44
+ * prefilled issue page the user submits themselves. Only a real submission
45
+ * advances the burst cooldown.
31
46
  */
32
- export async function submitFeedback(dataRoot, report, now = Date.now()) {
47
+ export async function submitFeedback(dataRoot, report, now = Date.now(), ports = {}) {
33
48
  const title = report.title.trim();
34
49
  if (title === '')
35
50
  return { ok: false, reason: 'title is required' };
@@ -38,6 +53,18 @@ export async function submitFeedback(dataRoot, report, now = Date.now()) {
38
53
  if (now - lastAt < SUBMIT_COOLDOWN_MS) {
39
54
  return { ok: false, reason: 'a feedback report was submitted less than a minute ago; wait and try again' };
40
55
  }
56
+ const ghIssue = ports.ghIssue ?? createIssueWithGh;
57
+ let ghFailure;
58
+ try {
59
+ const url = await ghIssue(title, body);
60
+ if (url !== undefined) {
61
+ await stampSubmitAt(dataRoot, now);
62
+ return { ok: true, location: url };
63
+ }
64
+ }
65
+ catch (error) {
66
+ ghFailure = error instanceof Error ? error.message : String(error);
67
+ }
41
68
  const token = process.env['GITHUB_TOKEN'] ?? process.env['GH_TOKEN'];
42
69
  if (token !== undefined && token !== '') {
43
70
  try {
@@ -46,22 +73,40 @@ export async function submitFeedback(dataRoot, report, now = Date.now()) {
46
73
  return { ok: true, location: url };
47
74
  }
48
75
  catch (error) {
49
- // Network or auth failure: fall through to the local spool so the
50
- // report is never lost, and say so in the outcome.
51
- const local = await spoolLocally(dataRoot, title, body);
52
- await stampSubmitAt(dataRoot, now);
53
- return {
54
- ok: true,
55
- location: local,
56
- reason: `GitHub issue failed (${error instanceof Error ? error.message : String(error)}); saved locally instead`
57
- };
76
+ ghFailure ??= error instanceof Error ? error.message : String(error);
58
77
  }
59
78
  }
60
- const local = await spoolLocally(dataRoot, title, body);
61
- await stampSubmitAt(dataRoot, now);
62
- return { ok: true, location: local };
79
+ const url = newIssueUrl(title, body);
80
+ let opened = true;
81
+ try {
82
+ await (ports.openPage ?? openInBrowser)(url);
83
+ }
84
+ catch {
85
+ // A headless host has no browser to open; the link in `location` still works.
86
+ opened = false;
87
+ }
88
+ const detail = ghFailure === undefined ? 'nothing could file the issue automatically' : `filing failed (${ghFailure})`;
89
+ return {
90
+ ok: true,
91
+ location: url,
92
+ issueText: `${title}\n\n${body}`,
93
+ reason: opened ? `${detail}; the prefilled issue page was opened — submit it in the browser` : `${detail}; open the issue page at the returned location to submit it`
94
+ };
95
+ }
96
+ /** Open one URL in the platform browser; a failure is the caller's to report. */
97
+ async function openInBrowser(url) {
98
+ await platformBrowserOpener(new URL(url), 'feedback', () => { });
99
+ }
100
+ /**
101
+ * File the issue with the `gh` CLI. Undefined means gh is absent, not
102
+ * authenticated, or otherwise unable to create the issue — never a thrown
103
+ * failure, so the caller can fall through to the REST API.
104
+ */
105
+ async function createIssueWithGh(title, body) {
106
+ const { stdout } = await execFileAsync('gh', ['issue', 'create', '--repo', `${FEEDBACK_REPO_OWNER}/${FEEDBACK_REPO_NAME}`, '--title', title, '--body', body, '--label', FEEDBACK_LABEL], { timeout: GH_TIMEOUT_MS });
107
+ return /https:\/\/\S+/.exec(stdout)?.[0];
63
108
  }
64
- /** POST one issue; returns its html_url. */
109
+ /** POST one issue through the REST API; returns its html_url. */
65
110
  async function createIssue(token, title, body) {
66
111
  const response = await fetch(`https://api.github.com/repos/${FEEDBACK_REPO_OWNER}/${FEEDBACK_REPO_NAME}/issues`, {
67
112
  method: 'POST',
@@ -84,25 +129,26 @@ async function createIssue(token, title, body) {
84
129
  throw new Error('GitHub API returned no issue URL');
85
130
  return payload.html_url;
86
131
  }
132
+ /**
133
+ * Render one tool argument as text. Arguments arrive as untrusted values, so
134
+ * `String` keeps the coercion the report has always applied for anything the
135
+ * declared `type: 'string'` schema does not already cover.
136
+ */
137
+ function textOf(value) {
138
+ return String(value);
139
+ }
87
140
  /** Render the model's structured report into a deterministic issue body. */
88
141
  export function renderFeedbackBody(report) {
89
142
  const lines = ['## Experience feedback', '', `**Problem:** ${report.description.trim()}`, ''];
90
- if (report.expected?.trim() !== undefined && report.expected.trim() !== '')
91
- lines.push(`**Expected:** ${report.expected.trim()}`, '');
92
- if (report.actual?.trim() !== undefined && report.actual.trim() !== '')
93
- lines.push(`**Actual:** ${report.actual.trim()}`, '');
143
+ const expected = report.expected?.trim();
144
+ const actual = report.actual?.trim();
145
+ if (expected !== undefined && expected !== '')
146
+ lines.push(`**Expected:** ${expected}`, '');
147
+ if (actual !== undefined && actual !== '')
148
+ lines.push(`**Actual:** ${actual}`, '');
94
149
  lines.push('---', `Filed automatically by the dsh-agent-plugins-market \`${FEEDBACK_TOOL_NAME}\` tool.`);
95
150
  return lines.join('\n');
96
151
  }
97
- /** Append one report to the local spool; returns the spool file path. */
98
- async function spoolLocally(dataRoot, title, body) {
99
- const dir = join(dataRoot, 'feedback');
100
- await mkdir(dir, { recursive: true });
101
- const file = join(dir, 'reports.jsonl');
102
- const record = JSON.stringify({ at: new Date().toISOString(), title, body });
103
- await appendFile(file, `${record}\n`, 'utf8');
104
- return file;
105
- }
106
152
  /** The last accepted submission's timestamp; 0 when the stamp file is absent. */
107
153
  async function readLastSubmitAt(dataRoot) {
108
154
  try {
@@ -115,11 +161,9 @@ async function readLastSubmitAt(dataRoot) {
115
161
  }
116
162
  }
117
163
  async function stampSubmitAt(dataRoot, now) {
118
- const dir = join(dataRoot, 'feedback');
119
- await mkdir(dir, { recursive: true });
120
164
  // Overwrite, not append: an appending stamp concatenates timestamps and
121
165
  // parseInt on the merged digits disables the cooldown permanently.
122
- await writeFile(join(dir, 'last-submit-at'), String(now), 'utf8');
166
+ await writeFileAtomic(join(dataRoot, 'feedback', 'last-submit-at'), String(now), { mode: 0o600, dirMode: 0o700 });
123
167
  }
124
168
  export const FEEDBACK_TOOL_NAME = 'report_market_issue';
125
169
  const FEEDBACK_DESCRIPTION = 'File an experience-feedback issue for dsh-agent-plugins-market (the DeepSeek Harness agent plugin market). ' +
@@ -127,8 +171,14 @@ const FEEDBACK_DESCRIPTION = 'File an experience-feedback issue for dsh-agent-pl
127
171
  'stuck install/uninstall, a market source that will not scan, wrong market UI behavior, or a runtime injection ' +
128
172
  'defect the user attributes to a market suite. Do not call it for problems in other plugins, in the harness, ' +
129
173
  "or in the user's own repositories. Compose a specific title; describe what happened, what was expected, and " +
130
- 'the exact error text when one appeared.';
131
- /** Register the feedback tool; returns the disposer. Undefined when the host has no tools service. */
174
+ 'the exact error text when one appeared. When the result carries `issueText`, the issue was not filed for you: ' +
175
+ 'show the human that text and the `location` link so they can submit it on GitHub.';
176
+ /**
177
+ * Register the feedback tool; returns the disposer. Undefined when the host has
178
+ * no tools service to register on — the caller turns that into a mount-path
179
+ * diagnostic instead of an error, since the setting can simply have no effect
180
+ * on this host.
181
+ */
132
182
  export function mountFeedbackTool(hostCtx, dataRoot, t) {
133
183
  const host = hostCtx;
134
184
  if (typeof host.tools?.register !== 'function')
@@ -152,6 +202,7 @@ export function mountFeedbackTool(hostCtx, dataRoot, t) {
152
202
  properties: {
153
203
  ok: { type: 'boolean', required: true },
154
204
  location: { type: 'string' },
205
+ issueText: { type: 'string' },
155
206
  reason: { type: 'string' }
156
207
  }
157
208
  },
@@ -160,8 +211,8 @@ export function mountFeedbackTool(hostCtx, dataRoot, t) {
160
211
  async execute(args) {
161
212
  const input = args;
162
213
  const report = {
163
- title: String(input['title'] ?? ''),
164
- description: String(input['description'] ?? ''),
214
+ title: textOf(input['title'] ?? ''),
215
+ description: textOf(input['description'] ?? ''),
165
216
  ...(typeof input['expected'] === 'string' ? { expected: input['expected'] } : {}),
166
217
  ...(typeof input['actual'] === 'string' ? { actual: input['actual'] } : {})
167
218
  };
@@ -10,29 +10,29 @@
10
10
  * on every enable/disable/install/uninstall; a missing bridge package, a
11
11
  * broken hook file, or a mount failure is contained per suite.
12
12
  */
13
- import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
13
+ import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
14
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
14
15
  import { tmpdir } from 'node:os';
15
16
  import { createHash } from 'node:crypto';
16
17
  import { join } from 'node:path';
18
+ import { SerialPassQueue } from './mount-lifecycle.js';
17
19
  import { qualifiedSuiteId } from '../catalog/paths.js';
18
20
  export class HooksMountRegistry {
19
21
  ctx;
20
22
  live = new Map();
21
23
  fingerprints = new Map();
22
24
  temporary = new Map();
23
- queue = Promise.resolve();
25
+ passes = new SerialPassQueue();
24
26
  constructor(ctx) {
25
27
  this.ctx = ctx;
26
28
  }
27
29
  /** Mount/unmount one bridge per suite to match the enabled suites exactly. */
28
- async reconcile(enabledSuites) {
29
- const run = this.queue.then(() => this.reconcileNow(enabledSuites));
30
- this.queue = run.then(() => { }, () => { });
31
- return run;
30
+ reconcile(enabledSuites) {
31
+ return this.passes.run(() => this.reconcileNow(enabledSuites));
32
32
  }
33
33
  async reconcileNow(enabledSuites) {
34
34
  const diagnostics = [];
35
- const active = enabledSuites.filter(suite => suite.activeSurfaces?.hooks !== false && (suite.resources === undefined || suite.hooks !== undefined));
35
+ const active = enabledSuites.filter(suite => suite.activeSurfaces.hooks !== false && (suite.resources === undefined || suite.hooks !== undefined));
36
36
  // Keys are the qualified suite id: bare ids are unique per source only.
37
37
  const wanted = new Set(active.map(suite => qualifiedSuiteId(suite.sourceId, suite.id)));
38
38
  for (const [suiteId, handle] of [...this.live]) {
@@ -68,12 +68,10 @@ export class HooksMountRegistry {
68
68
  }
69
69
  /** Dispose every live bridge; used at plugin teardown. */
70
70
  async disposeAll() {
71
- const run = this.queue.then(async () => {
71
+ await this.passes.run(async () => {
72
72
  for (const [suiteId, handle] of [...this.live])
73
73
  await this.unmount(suiteId, handle);
74
74
  });
75
- this.queue = run.catch(() => { });
76
- await run;
77
75
  }
78
76
  async mount(key, suite, content, fingerprint, originalPath) {
79
77
  let bridge;
@@ -93,7 +91,7 @@ export class HooksMountRegistry {
93
91
  if (configPath === undefined) {
94
92
  temporary = await mkdtemp(join(tmpdir(), 'dsh-project-hooks-'));
95
93
  configPath = join(temporary, 'hooks.json');
96
- await writeFile(configPath, content, { mode: 0o600 });
94
+ await writeFileAtomic(configPath, content, { mode: 0o600 });
97
95
  }
98
96
  handle = mountCtx.plugin(bridge, {
99
97
  configPath,