@khorsheed/dsh-local-agent 0.2.0-rc.1 → 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 +31 -0
- package/README.en.md +23 -164
- package/README.i18n.yaml +2 -2
- package/README.md +23 -164
- package/lib/client.js +23707 -4768
- package/lib/index.js +827 -62
- package/lib/member-bridge.js +195 -25
- package/lib/tool-factory.js +52 -19
- package/lib/typert.host.js +1440 -353
- package/lib/typert.remote-client.d.ts +43 -3
- package/lib/typert.remote-client.js +973 -335
- package/lib/types/client/FamilyCard.d.ts +37 -0
- package/lib/types/client/FamilyCard.js +55 -0
- 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/ModelListSection.d.ts +49 -0
- package/lib/types/client/ModelListSection.js +132 -0
- package/lib/types/client/ProviderAuthBlock.d.ts +10 -2
- package/lib/types/client/ProviderAuthBlock.js +13 -6
- package/lib/types/client/auth-status.d.ts +6 -0
- package/lib/types/client/auth-status.js +8 -0
- package/lib/types/client/harness-cards.d.ts +72 -0
- package/lib/types/client/harness-cards.js +57 -0
- package/lib/types/client/icons.d.ts +2 -1
- package/lib/types/client/icons.js +1 -0
- package/lib/types/client/index.d.ts +32 -7
- package/lib/types/client/index.js +66 -9
- package/lib/types/client/locales.d.ts +25 -1
- package/lib/types/client/locales.js +50 -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/gateway.d.ts +49 -1
- package/lib/types/gateway.js +89 -0
- package/lib/types/index.d.ts +91 -38
- package/lib/types/index.js +190 -58
- 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/member-controls.d.ts +8 -0
- package/lib/types/member-controls.js +15 -2
- package/lib/types/mode-skills.d.ts +18 -0
- package/lib/types/mode-skills.js +56 -0
- package/lib/types/model-list.d.ts +55 -0
- package/lib/types/model-list.js +101 -0
- package/lib/types/preset-tool-host.d.ts +70 -0
- package/lib/types/preset-tool-host.js +210 -0
- package/lib/types/tool-factory.d.ts +9 -3
- package/lib/types/tool-factory.js +55 -18
- package/lib/types/types.d.ts +62 -5
- package/package.json +27 -23
- package/lib/invariant.js +0 -85
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -105
|
@@ -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,
|
|
@@ -8,6 +8,14 @@ export declare class MemberControls {
|
|
|
8
8
|
private readonly adapter;
|
|
9
9
|
private readonly onError;
|
|
10
10
|
private readonly admitted;
|
|
11
|
+
/**
|
|
12
|
+
* Latest admitted configuration per child session id. Host 0.2.1 note: the
|
|
13
|
+
* activation manager wraps the provider's run, so a facade/tool caller never
|
|
14
|
+
* holds the exact object {@link admitted} keys — its run adapter names the
|
|
15
|
+
* child session id instead. Rounds of one child serialize through this
|
|
16
|
+
* class's queue, so the latest entry at read time is the reader's own round.
|
|
17
|
+
*/
|
|
18
|
+
private readonly admittedByChild;
|
|
11
19
|
configurationOf(run: object): LocalAgentAppliedConfiguration | undefined;
|
|
12
20
|
private readonly members;
|
|
13
21
|
constructor(storage: MemberControlStorage, adapter: (binding: LocalAgentMemberBinding) => MemberConfigurationAdapter, onError: (error: unknown) => void);
|
|
@@ -6,9 +6,21 @@ export class MemberControls {
|
|
|
6
6
|
adapter;
|
|
7
7
|
onError;
|
|
8
8
|
admitted = new WeakMap();
|
|
9
|
+
/**
|
|
10
|
+
* Latest admitted configuration per child session id. Host 0.2.1 note: the
|
|
11
|
+
* activation manager wraps the provider's run, so a facade/tool caller never
|
|
12
|
+
* holds the exact object {@link admitted} keys — its run adapter names the
|
|
13
|
+
* child session id instead. Rounds of one child serialize through this
|
|
14
|
+
* class's queue, so the latest entry at read time is the reader's own round.
|
|
15
|
+
*/
|
|
16
|
+
admittedByChild = new Map();
|
|
9
17
|
configurationOf(run) {
|
|
10
|
-
const
|
|
11
|
-
|
|
18
|
+
const exact = this.admitted.get(run);
|
|
19
|
+
if (exact !== undefined)
|
|
20
|
+
return structuredClone(exact);
|
|
21
|
+
const id = run.id;
|
|
22
|
+
const byChild = typeof id === 'string' ? this.admittedByChild.get(id) : undefined;
|
|
23
|
+
return byChild === undefined ? undefined : structuredClone(byChild);
|
|
12
24
|
}
|
|
13
25
|
members = new Map();
|
|
14
26
|
constructor(storage, adapter, onError) {
|
|
@@ -113,6 +125,7 @@ export class MemberControls {
|
|
|
113
125
|
nativeStarted = true;
|
|
114
126
|
const run = await start(lease.configuration);
|
|
115
127
|
this.admitted.set(run, lease.configuration);
|
|
128
|
+
this.admittedByChild.set(binding.childSessionId, lease.configuration);
|
|
116
129
|
const release = () => { try {
|
|
117
130
|
lease.release();
|
|
118
131
|
}
|
|
@@ -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,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A harness's curated model list: `<scopedHome>/models.json` holds an
|
|
3
|
+
* ORDERED array of model identifiers the user picked from the endpoint's
|
|
4
|
+
* served catalog. The list's FIRST entry is the harness's default model —
|
|
5
|
+
* it answers wherever the plugin-config `model` key answered before (round
|
|
6
|
+
* resolution, the broker's settings layer, the eval snapshot's model
|
|
7
|
+
* field), ahead of that key; an absent or empty list leaves the
|
|
8
|
+
* pre-existing chain untouched (settings key → the scoped config file →
|
|
9
|
+
* the CLI's own default). One store shape serves every harness in the
|
|
10
|
+
* family (claude-code, codex, kimi): the file is keyed by the scoped home
|
|
11
|
+
* alone, so a scope carries its own list exactly like its own endpoint
|
|
12
|
+
* credential.
|
|
13
|
+
*
|
|
14
|
+
* Why a file and not the settings channel: the credential form's
|
|
15
|
+
* endpoint-auth.json proved the pattern — the harness owns the store, the
|
|
16
|
+
* volatile row-config channel rejects writes a deployment cannot explain,
|
|
17
|
+
* and the file keeps every scope free to carry its own list. The settings
|
|
18
|
+
* card edits the default scope's file through the gateway's
|
|
19
|
+
* `modelList`/`configureModelList` Remotes; nothing here is secret (0600 is
|
|
20
|
+
* for uniformity with the credential file, not necessity).
|
|
21
|
+
* @module @khorsheed/dsh-local-agent/model-list
|
|
22
|
+
*/
|
|
23
|
+
/** The scoped-home model list file. */
|
|
24
|
+
export declare const MODEL_LIST_FILE = "models.json";
|
|
25
|
+
/** Normalize one list: trimmed, blank-dropped, deduplicated keeping first occurrence. */
|
|
26
|
+
export declare function normalizeModelList(models: readonly unknown[]): string[];
|
|
27
|
+
/**
|
|
28
|
+
* Read the scope's model list. Absent, unreadable, or malformed content all
|
|
29
|
+
* read as an empty list — the fallback chain then decides, exactly as
|
|
30
|
+
* before the file existed.
|
|
31
|
+
* @param homeDir - the harness's scoped home.
|
|
32
|
+
* @returns the ordered model identifiers.
|
|
33
|
+
*/
|
|
34
|
+
export declare function readModelList(homeDir: string): Promise<readonly string[]>;
|
|
35
|
+
/**
|
|
36
|
+
* The sync twin of {@link readModelList} for the per-round model resolver —
|
|
37
|
+
* one small file read per resolution, no caching, so a card write always
|
|
38
|
+
* reaches the very next round.
|
|
39
|
+
* @param homeDir - the scope's scoped home.
|
|
40
|
+
* @returns the ordered model identifiers.
|
|
41
|
+
*/
|
|
42
|
+
export declare function readModelListSync(homeDir: string): readonly string[];
|
|
43
|
+
/** The list's default model: its first entry, or undefined when empty. */
|
|
44
|
+
export declare function firstModelOf(models: readonly string[]): string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Persist the scope's model list (normalized; an empty list DELETES the
|
|
47
|
+
* file rather than shadowing the fallback chain with nothing). Answers the
|
|
48
|
+
* normalized list as written, so the writer can flip its surface without a
|
|
49
|
+
* re-read.
|
|
50
|
+
* @param homeDir - the scope's scoped home.
|
|
51
|
+
* @param models - the new ordered list.
|
|
52
|
+
* @returns the normalized list.
|
|
53
|
+
*/
|
|
54
|
+
export declare function writeModelList(homeDir: string, models: readonly string[]): Promise<readonly string[]>;
|
|
55
|
+
//# sourceMappingURL=model-list.d.ts.map
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A harness's curated model list: `<scopedHome>/models.json` holds an
|
|
3
|
+
* ORDERED array of model identifiers the user picked from the endpoint's
|
|
4
|
+
* served catalog. The list's FIRST entry is the harness's default model —
|
|
5
|
+
* it answers wherever the plugin-config `model` key answered before (round
|
|
6
|
+
* resolution, the broker's settings layer, the eval snapshot's model
|
|
7
|
+
* field), ahead of that key; an absent or empty list leaves the
|
|
8
|
+
* pre-existing chain untouched (settings key → the scoped config file →
|
|
9
|
+
* the CLI's own default). One store shape serves every harness in the
|
|
10
|
+
* family (claude-code, codex, kimi): the file is keyed by the scoped home
|
|
11
|
+
* alone, so a scope carries its own list exactly like its own endpoint
|
|
12
|
+
* credential.
|
|
13
|
+
*
|
|
14
|
+
* Why a file and not the settings channel: the credential form's
|
|
15
|
+
* endpoint-auth.json proved the pattern — the harness owns the store, the
|
|
16
|
+
* volatile row-config channel rejects writes a deployment cannot explain,
|
|
17
|
+
* and the file keeps every scope free to carry its own list. The settings
|
|
18
|
+
* card edits the default scope's file through the gateway's
|
|
19
|
+
* `modelList`/`configureModelList` Remotes; nothing here is secret (0600 is
|
|
20
|
+
* for uniformity with the credential file, not necessity).
|
|
21
|
+
* @module @khorsheed/dsh-local-agent/model-list
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
/** The scoped-home model list file. */
|
|
27
|
+
export const MODEL_LIST_FILE = 'models.json';
|
|
28
|
+
/** Normalize one list: trimmed, blank-dropped, deduplicated keeping first occurrence. */
|
|
29
|
+
export function normalizeModelList(models) {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const candidate of models) {
|
|
32
|
+
if (typeof candidate !== 'string')
|
|
33
|
+
continue;
|
|
34
|
+
const trimmed = candidate.trim();
|
|
35
|
+
if (trimmed !== '' && !out.includes(trimmed))
|
|
36
|
+
out.push(trimmed);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/** Fold a parsed file payload into a list; any other shape reads as empty. */
|
|
41
|
+
function fold(parsed) {
|
|
42
|
+
if (Array.isArray(parsed))
|
|
43
|
+
return normalizeModelList(parsed);
|
|
44
|
+
if (typeof parsed === 'object' && parsed !== null && Array.isArray(parsed.models)) {
|
|
45
|
+
return normalizeModelList(parsed.models);
|
|
46
|
+
}
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Read the scope's model list. Absent, unreadable, or malformed content all
|
|
51
|
+
* read as an empty list — the fallback chain then decides, exactly as
|
|
52
|
+
* before the file existed.
|
|
53
|
+
* @param homeDir - the harness's scoped home.
|
|
54
|
+
* @returns the ordered model identifiers.
|
|
55
|
+
*/
|
|
56
|
+
export async function readModelList(homeDir) {
|
|
57
|
+
try {
|
|
58
|
+
return fold(JSON.parse(await readFile(join(homeDir, MODEL_LIST_FILE), 'utf8')));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The sync twin of {@link readModelList} for the per-round model resolver —
|
|
66
|
+
* one small file read per resolution, no caching, so a card write always
|
|
67
|
+
* reaches the very next round.
|
|
68
|
+
* @param homeDir - the scope's scoped home.
|
|
69
|
+
* @returns the ordered model identifiers.
|
|
70
|
+
*/
|
|
71
|
+
export function readModelListSync(homeDir) {
|
|
72
|
+
try {
|
|
73
|
+
return fold(JSON.parse(readFileSync(join(homeDir, MODEL_LIST_FILE), 'utf8')));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** The list's default model: its first entry, or undefined when empty. */
|
|
80
|
+
export function firstModelOf(models) {
|
|
81
|
+
return models.length === 0 ? undefined : models[0];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Persist the scope's model list (normalized; an empty list DELETES the
|
|
85
|
+
* file rather than shadowing the fallback chain with nothing). Answers the
|
|
86
|
+
* normalized list as written, so the writer can flip its surface without a
|
|
87
|
+
* re-read.
|
|
88
|
+
* @param homeDir - the scope's scoped home.
|
|
89
|
+
* @param models - the new ordered list.
|
|
90
|
+
* @returns the normalized list.
|
|
91
|
+
*/
|
|
92
|
+
export async function writeModelList(homeDir, models) {
|
|
93
|
+
const normalized = normalizeModelList(models);
|
|
94
|
+
if (normalized.length === 0) {
|
|
95
|
+
await rm(join(homeDir, MODEL_LIST_FILE), { force: true });
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
await mkdir(homeDir, { recursive: true });
|
|
99
|
+
await writeFile(join(homeDir, MODEL_LIST_FILE), JSON.stringify({ models: normalized }, undefined, 2) + '\n', { mode: 0o600 });
|
|
100
|
+
return normalized;
|
|
101
|
+
}
|