@khorsheed/dsh-local-agent 0.2.0-rc.3 → 0.2.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.
@@ -10,7 +10,9 @@
10
10
  * it bare: `node <this file>`, with the socket path and token in its
11
11
  * environment (written into the CLI's scoped MCP config by the provider).
12
12
  * Covers the MCP handshake subset a client needs: `initialize`,
13
- * `notifications/initialized`, `ping`, `tools/list`, `tools/call`.
13
+ * `notifications/initialized`, `ping`, `tools/list`, `tools/call`. A member
14
+ * with a mode also gets that preset's plugin tools: `tools/list` asks the
15
+ * host for them and any other tool name is forwarded as a mode-tool call.
14
16
  * @module @khorsheed/dsh-local-agent/member-bridge
15
17
  */
16
18
  import type { Readable, Writable } from 'node:stream';
@@ -25,6 +27,10 @@ export interface MemberBridgeEnv {
25
27
  /** This run's member-channel token. */
26
28
  readonly token?: string | undefined;
27
29
  }
30
+ /** The member's mode tools (the preset's plugin tools), empty when it has none or the host is unreachable. */
31
+ export declare function listModeTools(env: MemberBridgeEnv): Promise<readonly Record<string, unknown>[]>;
32
+ /** Run one mode tool on the host; the host's MCP result passes through. */
33
+ export declare function callModeTool(env: MemberBridgeEnv, name: string, args: unknown): Promise<Record<string, unknown>>;
28
34
  /**
29
35
  * Handle one `member_message` tool call: forward to the host listener and map
30
36
  * the outcome to a tool result. Channel failures (missing env, unreachable
@@ -36,6 +42,10 @@ export interface MemberBridgeEnv {
36
42
  export declare function callMemberMessage(env: MemberBridgeEnv, args: {
37
43
  to?: unknown;
38
44
  text?: unknown;
45
+ kind?: unknown;
46
+ inReplyTo?: unknown;
47
+ note?: unknown;
48
+ task?: unknown;
39
49
  }): Promise<Record<string, unknown>>;
40
50
  /**
41
51
  * Run the stdio JSON-RPC loop over the given streams (injectable for tests).
@@ -10,7 +10,9 @@
10
10
  * it bare: `node <this file>`, with the socket path and token in its
11
11
  * environment (written into the CLI's scoped MCP config by the provider).
12
12
  * Covers the MCP handshake subset a client needs: `initialize`,
13
- * `notifications/initialized`, `ping`, `tools/list`, `tools/call`.
13
+ * `notifications/initialized`, `ping`, `tools/list`, `tools/call`. A member
14
+ * with a mode also gets that preset's plugin tools: `tools/list` asks the
15
+ * host for them and any other tool name is forwarded as a mode-tool call.
14
16
  * @module @khorsheed/dsh-local-agent/member-bridge
15
17
  */
16
18
  import { createInterface } from 'node:readline';
@@ -21,15 +23,24 @@ import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV } from './types.js';
21
23
  /** The one tool this server exposes. */
22
24
  export const MEMBER_MESSAGE_TOOL = 'member_message';
23
25
  const TOOL_DESCRIPTION = [
24
- 'Notify another CLI member of this room/session group (member-to-member message).',
25
- '`to` is the target member\'s dsh child session id (or a member name when a room manages the group).',
26
- 'The delivery receipt is returned verbatim: sent / pending-confirm / busy / an error reason.',
26
+ 'Send a message to another member of this room/session group.',
27
+ '`to` is the member name (or its dsh child session id).',
28
+ 'kind "task" (default) hands it work: in a room, its final reply is reported back to you automatically, citing the message id — do not poll.',
29
+ 'kind "notify" is an FYI and is never reported back. Pass inReplyTo (e.g. m12) when answering a message.',
30
+ 'Never sleep or poll for the result (room_read included): end your turn, the report arrives on its own.',
31
+ 'Writing "@name" in a reply reaches nobody; only this tool does.',
32
+ 'Optional note: what you will wait for before acting on the result; optional task: "new" or a task number from room_read.',
33
+ 'The receipt is returned verbatim: sent #m<id> … / pending-confirm … / busy / an error reason.',
27
34
  ].join(' ');
28
35
  const TOOL_SCHEMA = {
29
36
  type: 'object',
30
37
  properties: {
31
38
  to: { type: 'string', description: 'The target member\'s child session id (or room-roster name).' },
32
- text: { type: 'string', description: 'The notification text, delivered with your provenance.' },
39
+ text: { type: 'string', description: 'The message text, delivered with your provenance.' },
40
+ kind: { type: 'string', enum: ['task', 'notify'], description: 'task (default): the reply is reported back to you; notify: FYI only.' },
41
+ inReplyTo: { type: 'string', description: 'Optional id of the message this answers, e.g. m12.' },
42
+ note: { type: 'string', description: 'Optional: what you will wait for before acting on the result (the room uses it to decide when to hand you the report).' },
43
+ task: { type: 'string', description: 'Optional: "new" or a task number from room_read; omit to stay in the task you are working on.' },
33
44
  },
34
45
  required: ['to', 'text'],
35
46
  additionalProperties: false,
@@ -37,15 +48,38 @@ const TOOL_SCHEMA = {
37
48
  const ROOM_TOOLS = [
38
49
  { name: 'room_plan', description: 'Manage a formal goal with stages, dependent tasks and evidence review. Read room_read for plan state and the command contract. Ordinary small work uses room_message. Workers may submit only their own current attempt.',
39
50
  inputSchema: { type: 'object', properties: { command: { type: 'string', description: 'JSON command with action, requestId and expectedRevision; see room_read planCommands.' } }, required: ['command'], additionalProperties: false } },
40
- { name: 'room_read', description: 'Read your room roster, coordinator, deliveries, recent outcomes and available harness providers. Identity comes from the host.',
51
+ { name: 'room_read', description: 'Read your room: roster with roles, coordinator, tasks with their numbers, deliveries, recent outcomes and available harness providers. Identity comes from the host. Use it to look around, not to wait for a result.',
41
52
  inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
42
53
  { name: 'room_invite', description: 'Coordinator only: invite a member. An optional firstTask starts in the background and returns its result to you automatically.',
43
54
  inputSchema: { type: 'object', properties: {
44
- provider: { type: 'string' }, name: { type: 'string' }, instructions: { type: 'string' },
55
+ provider: { type: 'string' }, name: { type: 'string' }, note: { type: 'string', description: 'What you will wait for before acting on the first task result.' }, role: { type: 'string', description: 'Short public role (≤30 chars) the other members see, e.g. 代码审查.' }, instructions: { type: 'string', description: 'Private instructions only this member reads.' },
45
56
  cwd: { type: 'string' }, model: { type: 'string' }, firstTask: { type: 'string' },
57
+ mode: { type: 'string', description: 'The member\'s dsh mode (a host preset id, e.g. dsh-gamedev). Omit to keep this room\'s own mode; "none" for only its own CLI tools.' },
46
58
  }, required: ['provider', 'name'], additionalProperties: false } },
47
- { name: 'room_message', description: 'Coordinator only: dispatch background work to a room member by name. Returns acceptance immediately; completion arrives as a correlated report. Do not poll.',
48
- inputSchema: { type: 'object', properties: { member: { type: 'string' }, text: { type: 'string' } }, required: ['member', 'text'], additionalProperties: false } },
59
+ { name: 'ask_user_question', description: 'Ask the human questions they answer by picking options (or typing), instead of writing the questions into your reply. '
60
+ + 'Use it whenever you need the human to decide or fill in something before you go on: give each question 2-4 short options with a one-line description each. '
61
+ + 'The call waits for the answer and returns {answers:[{id, selected:[labels], custom?}]}; if nobody answers it fails — then go on with your best judgement and say what you assumed.',
62
+ inputSchema: { type: 'object', properties: {
63
+ questions: { type: 'array', description: 'At most 4 questions.', items: { type: 'object', properties: {
64
+ id: { type: 'string', description: 'Short unique id, echoed in the answer.' },
65
+ question: { type: 'string', description: 'The full question.' },
66
+ header: { type: 'string', description: 'Optional 2-6 word topic chip.' },
67
+ options: { type: 'array', items: { type: 'object', properties: { label: { type: 'string' }, description: { type: 'string' } }, required: ['label'] } },
68
+ multi_select: { type: 'boolean', description: 'True when several options may be picked.' },
69
+ }, required: ['id', 'question'] } },
70
+ }, required: ['questions'], additionalProperties: false } },
71
+ { name: 'room_message', description: 'Send a room member a message by name (same as member_message). kind "task" (default): its reply comes back to you as a report citing the message id; kind "notify": FYI only. Do not poll.',
72
+ inputSchema: { type: 'object', properties: { member: { type: 'string' }, text: { type: 'string' }, kind: { type: 'string', enum: ['task', 'notify'] }, inReplyTo: { type: 'string' }, note: { type: 'string', description: 'What you will wait for before acting on the result.' }, task: { type: 'string', description: '"new" or a task number; omit to stay in your current task.' } }, required: ['member', 'text'], additionalProperties: false } },
73
+ { name: 'room_help', description: 'Ask another session outside this room for help — typically the one that built what you are stuck on. It works in its own session (a busy one queues you); '
74
+ + 'its final answer plus a short handoff comes back to you on its own, so end your turn after asking and do not poll. Name the session by title, id or the @ mention the human wrote; '
75
+ + 'a session that is another room goes to its coordinator, or to the member you name. The first ask of a session the human did not @ in the room waits for their approval. '
76
+ + 'To answer its question or ask more, call again with the help id and no session.',
77
+ inputSchema: { type: 'object', properties: {
78
+ session: { type: 'string', description: 'The session to ask (title, id, or @ mention). Omit when continuing a thread.' },
79
+ member: { type: 'string', description: 'When the session is another room: the member to ask. Omit to let its coordinator decide.' },
80
+ help: { type: 'string', description: 'The help id of an existing thread.' },
81
+ text: { type: 'string', description: 'What you need, self-contained: the helper does not see this room.' },
82
+ }, required: ['text'], additionalProperties: false } },
49
83
  ];
50
84
  export async function callRoomCommand(env, name, args) {
51
85
  if (env.socket === undefined || env.token === undefined)
@@ -60,6 +94,30 @@ export async function callRoomCommand(env, name, args) {
60
94
  return toolText(`Room command unavailable: ${String(error)}`, true);
61
95
  }
62
96
  }
97
+ /** The member's mode tools (the preset's plugin tools), empty when it has none or the host is unreachable. */
98
+ export async function listModeTools(env) {
99
+ if (env.socket === undefined || env.token === undefined)
100
+ return [];
101
+ try {
102
+ const outcome = await callHost(env.socket, { token: env.token, modeTools: { list: true } });
103
+ return outcome.ok && Array.isArray(outcome.tools) ? outcome.tools : [];
104
+ }
105
+ catch {
106
+ return [];
107
+ }
108
+ }
109
+ /** Run one mode tool on the host; the host's MCP result passes through. */
110
+ export async function callModeTool(env, name, args) {
111
+ if (env.socket === undefined || env.token === undefined)
112
+ return toolText('member channel is not configured for this run', true);
113
+ try {
114
+ const outcome = await callHost(env.socket, { token: env.token, modeTools: { call: { name, arguments: args } } });
115
+ return outcome.ok && outcome.result !== undefined ? outcome.result : toolText(outcome.error ?? `Tool ${name} failed`, true);
116
+ }
117
+ catch (error) {
118
+ return toolText(`Mode tool unavailable: ${String(error)}`, true);
119
+ }
120
+ }
63
121
  /** One socket round trip: send the request line, read the one-line response. */
64
122
  function callHost(socket, payload) {
65
123
  return new Promise((resolve, reject) => {
@@ -108,6 +166,10 @@ export async function callMemberMessage(env, args) {
108
166
  token: env.token,
109
167
  to: args.to,
110
168
  text: args.text,
169
+ ...args.kind === 'task' || args.kind === 'notify' ? { kind: args.kind } : {},
170
+ ...typeof args.inReplyTo === 'string' ? { inReplyTo: args.inReplyTo } : {},
171
+ ...typeof args.note === 'string' ? { note: args.note } : {},
172
+ ...typeof args.task === 'string' ? { task: args.task } : {},
111
173
  });
112
174
  if (outcome.ok)
113
175
  return toolText(outcome.receipt ?? 'sent');
@@ -161,7 +223,8 @@ export function serveMemberBridge(env, input, output) {
161
223
  write({ jsonrpc: '2.0', id: id ?? null, result: {} });
162
224
  return;
163
225
  case 'tools/list':
164
- write({
226
+ // The member's mode tools (its preset's plugin tools) join the room set.
227
+ void listModeTools(env).then(modeTools => write({
165
228
  jsonrpc: '2.0',
166
229
  id: id ?? null,
167
230
  result: {
@@ -169,9 +232,9 @@ export function serveMemberBridge(env, input, output) {
169
232
  name: MEMBER_MESSAGE_TOOL,
170
233
  description: TOOL_DESCRIPTION,
171
234
  inputSchema: TOOL_SCHEMA,
172
- }, ...ROOM_TOOLS],
235
+ }, ...ROOM_TOOLS, ...modeTools.filter(tool => tool['name'] !== MEMBER_MESSAGE_TOOL && !ROOM_TOOLS.some(room => room.name === tool['name']))],
173
236
  },
174
- });
237
+ }));
175
238
  return;
176
239
  case 'tools/call': {
177
240
  const params = request.params;
@@ -180,7 +243,12 @@ export function serveMemberBridge(env, input, output) {
180
243
  return;
181
244
  }
182
245
  if (params?.name !== MEMBER_MESSAGE_TOOL) {
183
- write({ jsonrpc: '2.0', id: id ?? null, error: { code: -32602, message: `unknown tool ${String(params?.name)}` } });
246
+ if (typeof params?.name !== 'string') {
247
+ write({ jsonrpc: '2.0', id: id ?? null, error: { code: -32602, message: 'tools/call requires a tool name' } });
248
+ return;
249
+ }
250
+ // Anything else is a mode tool; the host rejects names its mode lacks.
251
+ void callModeTool(env, params.name, params.arguments ?? {}).then(result => write({ jsonrpc: '2.0', id: id ?? null, result }));
184
252
  return;
185
253
  }
186
254
  void callMemberMessage(env, params.arguments ?? {}).then((result) => {
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import type { Context } from '@deepseek-ai/cordis';
29
29
  import type { LocalAgentRegistry } from './index.ts';
30
+ import type { BridgedTool, BridgedToolResult, PresetToolHost } from './preset-tool-host.ts';
30
31
  import type { MemberRoomCommand, MemberMessageOutcome } from './types.ts';
31
32
  /** One bridge callback on the wire. */
32
33
  export interface MemberBridgeRequest {
@@ -36,11 +37,42 @@ export interface MemberBridgeRequest {
36
37
  to: string;
37
38
  /** The notification text. */
38
39
  text: string;
40
+ /** `task` (default) or `notify`; passed to a claiming room. */
41
+ kind?: 'task' | 'notify';
42
+ /** The message id this one answers (e.g. `m12`); passed to a claiming room. */
43
+ inReplyTo?: string;
44
+ /** What the sender waits for before acting on the result; passed to a claiming room. */
45
+ note?: string;
46
+ /** `new` or a task number; passed to a claiming room. */
47
+ task?: string;
39
48
  }
40
49
  export interface MemberCommandBridgeRequest {
41
50
  token: string;
42
51
  command: MemberRoomCommand;
43
52
  }
53
+ /** The bridge asking for (`list`) or calling (`call`) the member's mode tools. */
54
+ export interface MemberModeToolsBridgeRequest {
55
+ token: string;
56
+ modeTools: {
57
+ readonly list: true;
58
+ } | {
59
+ readonly call: {
60
+ readonly name: string;
61
+ readonly arguments?: unknown;
62
+ };
63
+ };
64
+ }
65
+ /** A mode-tools answer: the list, or one call's MCP result. */
66
+ export type MemberModeToolsOutcome = {
67
+ readonly ok: true;
68
+ readonly tools: readonly BridgedTool[];
69
+ } | {
70
+ readonly ok: true;
71
+ readonly result: BridgedToolResult;
72
+ } | {
73
+ readonly ok: false;
74
+ readonly error: string;
75
+ };
44
76
  /**
45
77
  * The member-channel host: owns the loopback unix-socket listener and the
46
78
  * delivery chain. Mounted by the plugin's `apply` and disposed with its fiber.
@@ -49,8 +81,9 @@ export declare class MemberChannel {
49
81
  private readonly ctx;
50
82
  private readonly registry;
51
83
  private readonly socketPath;
84
+ private readonly modeTools?;
52
85
  private server;
53
- constructor(ctx: Context, registry: LocalAgentRegistry, socketPath: string);
86
+ constructor(ctx: Context, registry: LocalAgentRegistry, socketPath: string, modeTools?: PresetToolHost | undefined);
54
87
  /**
55
88
  * Start listening. A listener failure (e.g. a socket path beyond the
56
89
  * platform limit) degrades the whole member channel to absent — the run
@@ -71,6 +104,16 @@ export declare class MemberChannel {
71
104
  * @returns the delivery outcome.
72
105
  */
73
106
  handle(request: MemberBridgeRequest | MemberCommandBridgeRequest): Promise<MemberMessageOutcome>;
107
+ /**
108
+ * The member's mode tools: the room says which mode the member has; the
109
+ * preset tool host lists or runs that mode's plugin tools. No room, no mode
110
+ * or no host = an empty list.
111
+ * @param request - the bridge callback.
112
+ * @returns the list or the call result.
113
+ */
114
+ handleModeTools(request: MemberModeToolsBridgeRequest): Promise<MemberModeToolsOutcome>;
115
+ /** Install the mode's skills into the member's harness skill directory (best-effort). */
116
+ private installSkills;
74
117
  /** Probe the optional room gate without importing any room package. */
75
118
  private probeRoom;
76
119
  /**
@@ -28,6 +28,9 @@
28
28
  import { createServer } from 'node:net';
29
29
  import { mkdirSync, rmSync } from 'node:fs';
30
30
  import { dirname } from 'node:path';
31
+ import { harnessSkillsDir, installModeSkills } from './mode-skills.js';
32
+ /** How long one bridged mode-tool call may run before it is cancelled. */
33
+ const MODE_TOOL_TIMEOUT_MS = 30 * 60_000;
31
34
  /**
32
35
  * The member-channel host: owns the loopback unix-socket listener and the
33
36
  * delivery chain. Mounted by the plugin's `apply` and disposed with its fiber.
@@ -36,11 +39,13 @@ export class MemberChannel {
36
39
  ctx;
37
40
  registry;
38
41
  socketPath;
42
+ modeTools;
39
43
  server;
40
- constructor(ctx, registry, socketPath) {
44
+ constructor(ctx, registry, socketPath, modeTools) {
41
45
  this.ctx = ctx;
42
46
  this.registry = registry;
43
47
  this.socketPath = socketPath;
48
+ this.modeTools = modeTools;
44
49
  }
45
50
  /**
46
51
  * Start listening. A listener failure (e.g. a socket path beyond the
@@ -98,7 +103,10 @@ export class MemberChannel {
98
103
  async answer(socket, line) {
99
104
  let outcome;
100
105
  try {
101
- outcome = await this.handle(JSON.parse(line));
106
+ const request = JSON.parse(line);
107
+ outcome = request !== null && typeof request === 'object' && 'modeTools' in request
108
+ ? await this.handleModeTools(request)
109
+ : await this.handle(request);
102
110
  }
103
111
  catch (error) {
104
112
  outcome = { ok: false, error: error instanceof Error ? error.message : String(error) };
@@ -181,6 +189,57 @@ export class MemberChannel {
181
189
  }
182
190
  return { ok: true, receipt: 'sent' };
183
191
  }
192
+ /**
193
+ * The member's mode tools: the room says which mode the member has; the
194
+ * preset tool host lists or runs that mode's plugin tools. No room, no mode
195
+ * or no host = an empty list.
196
+ * @param request - the bridge callback.
197
+ * @returns the list or the call result.
198
+ */
199
+ async handleModeTools(request) {
200
+ const run = typeof request.token === 'string' ? this.registry.resolveMemberRun(request.token) : undefined;
201
+ if (run === undefined)
202
+ return { ok: false, error: 'localAgent: unknown or expired member token' };
203
+ const mode = this.modeTools === undefined ? undefined : await this.probeRoom()?.memberMode?.(run);
204
+ if ('list' in request.modeTools) {
205
+ if (mode === undefined) {
206
+ // Visible in the diagnostics: a member whose room gave it no mode.
207
+ this.modeTools?.note({ childSessionId: run.childSessionId, provider: run.provider, mode: false });
208
+ return { ok: true, tools: [] };
209
+ }
210
+ try {
211
+ const tools = await this.modeTools.list(run.childSessionId, mode.preset, mode.cwd);
212
+ await this.installSkills(run, mode.preset);
213
+ return { ok: true, tools };
214
+ }
215
+ catch (error) {
216
+ this.ctx.logger.warn(`localAgent: mode tools for ${run.childSessionId} unavailable: ${String(error)}`);
217
+ return { ok: true, tools: [] };
218
+ }
219
+ }
220
+ if (mode === undefined)
221
+ return { ok: false, error: 'This member has no mode tools' };
222
+ const { name, arguments: args } = request.modeTools.call;
223
+ try {
224
+ return { ok: true, result: await this.modeTools.call(run.childSessionId, mode.preset, name, args ?? {}, AbortSignal.timeout(MODE_TOOL_TIMEOUT_MS), mode.cwd) };
225
+ }
226
+ catch (error) {
227
+ return { ok: false, error: String(error) };
228
+ }
229
+ }
230
+ /** Install the mode's skills into the member's harness skill directory (best-effort). */
231
+ async installSkills(run, preset) {
232
+ try {
233
+ const harness = this.registry.list().map(name => this.registry.get(name)).find(candidate => candidate?.delegationProvider === run.provider);
234
+ const dir = harness === undefined ? undefined : harnessSkillsDir(harness.name, this.registry.homeDir(harness.name));
235
+ if (dir === undefined)
236
+ return;
237
+ installModeSkills(dir, preset, await this.modeTools.skills(run.childSessionId, preset));
238
+ }
239
+ catch (error) {
240
+ this.ctx.logger.warn(`localAgent: mode skills for ${run.childSessionId} not installed: ${String(error)}`);
241
+ }
242
+ }
184
243
  /** Probe the optional room gate without importing any room package. */
185
244
  probeRoom() {
186
245
  const room = this.ctx.get.call(this.ctx, 'room');
@@ -203,6 +262,10 @@ export class MemberChannel {
203
262
  to: request.to,
204
263
  content: request.text,
205
264
  parentSessionId: from.parentSessionId,
265
+ ...request.kind === 'task' || request.kind === 'notify' ? { kind: request.kind } : {},
266
+ ...typeof request.inReplyTo === 'string' ? { inReplyTo: request.inReplyTo } : {},
267
+ ...typeof request.note === 'string' ? { note: request.note } : {},
268
+ ...typeof request.task === 'string' ? { task: request.task } : {},
206
269
  provenance: {
207
270
  childSessionId: from.childSessionId,
208
271
  provider: from.provider,
@@ -0,0 +1,18 @@
1
+ import type { ModeSkill } from './preset-tool-host.ts';
2
+ /** The skill directory of a harness home, or undefined when its CLI has none we know. */
3
+ export declare function harnessSkillsDir(harness: string, home: string): string | undefined;
4
+ /** The SKILL.md a mode skill installs as. */
5
+ export declare function modeSkillFile(skill: ModeSkill, mode: string): {
6
+ readonly name: string;
7
+ readonly text: string;
8
+ };
9
+ /**
10
+ * Write the mode's skills into the harness's skill directory, skipping files
11
+ * already up to date.
12
+ * @param dir - the harness skill directory.
13
+ * @param mode - the member's mode id.
14
+ * @param skills - the mode's skills.
15
+ * @returns the installed skill names.
16
+ */
17
+ export declare function installModeSkills(dir: string, mode: string, skills: readonly ModeSkill[]): string[];
18
+ //# sourceMappingURL=mode-skills.d.ts.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Mode skills, installed the CLI's own way: a member with a mode gets the
3
+ * skills that mode contributes (e.g. the 剪辑 mode's `video-studio`) written
4
+ * into its harness home's skill directory — `skills/<name>/SKILL.md`, the
5
+ * layout Codex (`$CODEX_HOME`), Claude Code (`$CLAUDE_CONFIG_DIR`) and Kimi
6
+ * (`$KIMI_CODE_HOME`, its auto-discovered user skill directory) load. The home is shared by every member of that harness, so each skill is
7
+ * named `dsh-<name>` and says it applies only alongside the mode's tools.
8
+ * Harnesses without a known skill directory get none. Best-effort:
9
+ * a failed write never fails the member's tool listing.
10
+ * @module @khorsheed/dsh-local-agent/mode-skills
11
+ */
12
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ /** Harnesses whose CLI loads `<home>/skills/<name>/SKILL.md`. */
15
+ const SKILL_HOMES = new Set(['codex', 'claude-code', 'kimi']);
16
+ /** The skill directory of a harness home, or undefined when its CLI has none we know. */
17
+ export function harnessSkillsDir(harness, home) {
18
+ return SKILL_HOMES.has(harness) ? join(home, 'skills') : undefined;
19
+ }
20
+ /** The SKILL.md a mode skill installs as. */
21
+ export function modeSkillFile(skill, mode) {
22
+ const name = `dsh-${skill.name}`;
23
+ const description = `${skill.description} (dsh mode "${mode}": use only when your tools include this mode's tools.)`.replace(/\s+/g, ' ');
24
+ return {
25
+ name,
26
+ text: `---\nname: ${name}\ndescription: ${JSON.stringify(description)}\n---\n\n${skill.content.trim()}\n`,
27
+ };
28
+ }
29
+ /**
30
+ * Write the mode's skills into the harness's skill directory, skipping files
31
+ * already up to date.
32
+ * @param dir - the harness skill directory.
33
+ * @param mode - the member's mode id.
34
+ * @param skills - the mode's skills.
35
+ * @returns the installed skill names.
36
+ */
37
+ export function installModeSkills(dir, mode, skills) {
38
+ const installed = [];
39
+ for (const skill of skills) {
40
+ const file = modeSkillFile(skill, mode);
41
+ const target = join(dir, file.name, 'SKILL.md');
42
+ let current;
43
+ try {
44
+ current = readFileSync(target, 'utf8');
45
+ }
46
+ catch {
47
+ current = undefined;
48
+ }
49
+ if (current !== file.text) {
50
+ mkdirSync(join(dir, file.name), { recursive: true });
51
+ writeFileSync(target, file.text);
52
+ }
53
+ installed.push(file.name);
54
+ }
55
+ return installed;
56
+ }
@@ -0,0 +1,70 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ /** One plugin tool as the bridge lists it (MCP `tools/list` shape). */
3
+ export interface BridgedTool {
4
+ readonly name: string;
5
+ readonly description: string;
6
+ readonly inputSchema: Record<string, unknown>;
7
+ }
8
+ /** One bridged call's outcome (MCP `tools/call` result shape). */
9
+ export interface BridgedToolResult {
10
+ readonly content: readonly Record<string, unknown>[];
11
+ readonly isError: boolean;
12
+ }
13
+ /** One skill the mode itself contributes (absent from the global view). */
14
+ export interface ModeSkill {
15
+ readonly name: string;
16
+ readonly description: string;
17
+ readonly content: string;
18
+ }
19
+ /** The per-member preset agents and the list/call operations the bridge forwards. */
20
+ export declare class PresetToolHost {
21
+ private readonly ctx;
22
+ private readonly diagnostics?;
23
+ private readonly hosts;
24
+ /** Stand-in → the member's child session, for routing approvals its calls raise. */
25
+ private readonly standIns;
26
+ /** The member a stand-in agent acts for, if it is one of ours. */
27
+ memberOf(agent: unknown): string | undefined;
28
+ /**
29
+ * @param ctx - the local-agent plugin context.
30
+ * @param diagnostics - optional JSONL file each listing appends a summary to
31
+ * (what the preset agent's scope saw and which tools passed the filter).
32
+ */
33
+ constructor(ctx: Context, diagnostics?: string | undefined);
34
+ /** Append one diagnostics line (best-effort). */
35
+ note(entry: Record<string, unknown>): void;
36
+ private probe;
37
+ /** The member's stand-in, scoped with its preset on first use (and re-scoped when the preset changed). */
38
+ private agentFor;
39
+ /**
40
+ * The preset's plugin tools, for a member's `tools/list`.
41
+ * @param childSessionId - the member's child session.
42
+ * @param preset - the member's mode.
43
+ * @param cwd - the member's working directory.
44
+ * @returns the tools, empty when the host lacks the capabilities.
45
+ */
46
+ list(childSessionId: string, preset: string, cwd?: string): Promise<BridgedTool[]>;
47
+ /**
48
+ * Run one plugin tool for a member through the registry's execution
49
+ * pipeline (policies, approvals, timeouts), as the member's preset agent.
50
+ * @param childSessionId - the member's child session.
51
+ * @param preset - the member's mode.
52
+ * @param name - the tool name.
53
+ * @param args - the tool arguments.
54
+ * @param signal - cancellation.
55
+ * @param cwd - the member's working directory.
56
+ * @returns the MCP result.
57
+ */
58
+ call(childSessionId: string, preset: string, name: string, args: unknown, signal: AbortSignal, cwd?: string): Promise<BridgedToolResult>;
59
+ /**
60
+ * The skills the member's mode contributes: visible in its scope, absent
61
+ * from the global view (installed skills every CLI already sees stay out).
62
+ * @param childSessionId - the member's child session.
63
+ * @param preset - the member's mode.
64
+ * @returns the mode's skills with their content; empty without a skill registry.
65
+ */
66
+ skills(childSessionId: string, preset: string): Promise<ModeSkill[]>;
67
+ /** Dispose every preset agent. */
68
+ dispose(): Promise<void>;
69
+ }
70
+ //# sourceMappingURL=preset-tool-host.d.ts.map