@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.en.md +23 -164
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +23 -164
  5. package/lib/client.js +23707 -4768
  6. package/lib/index.js +827 -62
  7. package/lib/member-bridge.js +195 -25
  8. package/lib/tool-factory.js +52 -19
  9. package/lib/typert.host.js +1440 -353
  10. package/lib/typert.remote-client.d.ts +43 -3
  11. package/lib/typert.remote-client.js +973 -335
  12. package/lib/types/client/FamilyCard.d.ts +37 -0
  13. package/lib/types/client/FamilyCard.js +55 -0
  14. package/lib/types/client/HarnessModelPicker.d.ts +21 -1
  15. package/lib/types/client/HarnessModelPicker.js +31 -3
  16. package/lib/types/client/MemberLiveNode.js +38 -4
  17. package/lib/types/client/ModelListSection.d.ts +49 -0
  18. package/lib/types/client/ModelListSection.js +132 -0
  19. package/lib/types/client/ProviderAuthBlock.d.ts +10 -2
  20. package/lib/types/client/ProviderAuthBlock.js +13 -6
  21. package/lib/types/client/auth-status.d.ts +6 -0
  22. package/lib/types/client/auth-status.js +8 -0
  23. package/lib/types/client/harness-cards.d.ts +72 -0
  24. package/lib/types/client/harness-cards.js +57 -0
  25. package/lib/types/client/icons.d.ts +2 -1
  26. package/lib/types/client/icons.js +1 -0
  27. package/lib/types/client/index.d.ts +32 -7
  28. package/lib/types/client/index.js +66 -9
  29. package/lib/types/client/locales.d.ts +25 -1
  30. package/lib/types/client/locales.js +50 -2
  31. package/lib/types/client/model-directory-scope.d.ts +14 -0
  32. package/lib/types/client/model-directory-scope.js +20 -0
  33. package/lib/types/gateway.d.ts +49 -1
  34. package/lib/types/gateway.js +89 -0
  35. package/lib/types/index.d.ts +91 -38
  36. package/lib/types/index.js +190 -58
  37. package/lib/types/live-stream.d.ts +11 -0
  38. package/lib/types/live-stream.js +26 -0
  39. package/lib/types/member-bridge.d.ts +11 -1
  40. package/lib/types/member-bridge.js +81 -13
  41. package/lib/types/member-channel.d.ts +44 -1
  42. package/lib/types/member-channel.js +65 -2
  43. package/lib/types/member-controls.d.ts +8 -0
  44. package/lib/types/member-controls.js +15 -2
  45. package/lib/types/mode-skills.d.ts +18 -0
  46. package/lib/types/mode-skills.js +56 -0
  47. package/lib/types/model-list.d.ts +55 -0
  48. package/lib/types/model-list.js +101 -0
  49. package/lib/types/preset-tool-host.d.ts +70 -0
  50. package/lib/types/preset-tool-host.js +210 -0
  51. package/lib/types/tool-factory.d.ts +9 -3
  52. package/lib/types/tool-factory.js +55 -18
  53. package/lib/types/types.d.ts +62 -5
  54. package/package.json +27 -23
  55. package/lib/invariant.js +0 -85
  56. package/lib/types/invariant.d.ts +0 -16
  57. 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
- '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,
@@ -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 configuration = this.admitted.get(run);
11
- return configuration === undefined ? undefined : structuredClone(configuration);
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
+ }