@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.
- package/CHANGELOG.md +19 -1
- package/README.en.md +23 -164
- package/README.i18n.yaml +2 -2
- package/README.md +23 -164
- package/lib/client.js +373 -133
- package/lib/index.js +483 -14
- package/lib/member-bridge.js +195 -25
- package/lib/typert.host.js +107 -15
- package/lib/typert.remote-client.js +4 -4
- package/lib/types/client/HarnessModelPicker.d.ts +21 -1
- package/lib/types/client/HarnessModelPicker.js +31 -3
- package/lib/types/client/MemberLiveNode.js +38 -4
- package/lib/types/client/icons.d.ts +2 -1
- package/lib/types/client/icons.js +1 -0
- package/lib/types/client/index.js +16 -2
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/locales.js +2 -2
- package/lib/types/client/model-directory-scope.d.ts +14 -0
- package/lib/types/client/model-directory-scope.js +20 -0
- package/lib/types/index.d.ts +18 -2
- package/lib/types/index.js +81 -14
- package/lib/types/live-stream.d.ts +11 -0
- package/lib/types/live-stream.js +26 -0
- package/lib/types/member-bridge.d.ts +11 -1
- package/lib/types/member-bridge.js +81 -13
- package/lib/types/member-channel.d.ts +44 -1
- package/lib/types/member-channel.js +65 -2
- package/lib/types/mode-skills.d.ts +18 -0
- package/lib/types/mode-skills.js +56 -0
- package/lib/types/preset-tool-host.d.ts +70 -0
- package/lib/types/preset-tool-host.js +210 -0
- package/lib/types/types.d.ts +27 -2
- package/package.json +5 -1
|
@@ -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
|
-
'
|
|
25
|
-
'`to` is the
|
|
26
|
-
'
|
|
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
|
|
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: '
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|