@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.
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Preset tool host: lets an external CLI member use the plugin tools of a
3
+ * host agent preset (a mode), through its member bridge, ON THE MEMBER'S OWN
4
+ * SESSION. The tool registry keys scoped tools by an agent identity, and
5
+ * plugin tools read only `agent.id` and `agent.session` from it — so the
6
+ * member gets a model-less stand-in: `{ id, session }` over its own child
7
+ * session, given a scope (`createScope`) with the preset mounted. Calls run
8
+ * through the registry's normal pipeline (policies, approvals, timeouts) as
9
+ * that stand-in, so plugin state (a video project) belongs to the member's
10
+ * session and the mode's panels show there; the session also records the mode
11
+ * (`agent-preset/selected`), so per-session plugin visibility follows it.
12
+ * Only PLUGIN tools are offered (`Symbol.for('dsh.tool.origin')` channel
13
+ * `plugin`; the CLI brings its own shell, files and todo).
14
+ *
15
+ * Everything host-side is probed, never injected: without `sessions`,
16
+ * `agentPresets` or `tools` the member simply gets no extra tools.
17
+ * @module @khorsheed/dsh-local-agent/preset-tool-host
18
+ */
19
+ import { randomUUID } from 'node:crypto';
20
+ import { appendFileSync } from 'node:fs';
21
+ import { createScope } from '@deepseek-ai/dsh-scope';
22
+ import { SessionId } from '@deepseek-ai/dsh-session';
23
+ /** Owners whose tools the bridge already serves itself (room), or that delegate from dsh (the local-agent family). */
24
+ const skippedOwner = (owner) => owner === '@khorsheed/dsh-room' || owner.startsWith('@khorsheed/dsh-local-agent');
25
+ /** How long a freshly mounted preset may take to finish registering its tools, and how often to look. */
26
+ const SETTLE_MS = 3_000;
27
+ const SETTLE_STEP_MS = 150;
28
+ const ORIGIN = Symbol.for('dsh.tool.origin');
29
+ /** The mode a session records: its header, overridden by the latest selection. */
30
+ function sessionPreset(session) {
31
+ let preset = session.header.agentPreset ?? undefined;
32
+ for (const event of session.snapshotEvents()) {
33
+ if (event.type === 'agent-preset/selected')
34
+ preset = event.data?.agentPreset ?? preset;
35
+ }
36
+ return preset;
37
+ }
38
+ /** Whether a definition is a plugin tool the bridge may forward. */
39
+ function isPluginTool(definition) {
40
+ const origin = definition?.[ORIGIN];
41
+ return origin?.channel === 'plugin' && !skippedOwner(String(origin.owner));
42
+ }
43
+ /** Map registry content blocks to MCP content (text and images; anything else as text). */
44
+ function mcpContent(blocks) {
45
+ return blocks.map((block) => {
46
+ if (block.type === 'text')
47
+ return { type: 'text', text: block.text ?? '' };
48
+ if (block.type === 'image' && typeof block.data === 'string')
49
+ return { type: 'image', data: block.data, mimeType: block.mediaType ?? block.mimeType ?? 'image/png' };
50
+ return { type: 'text', text: JSON.stringify(block) };
51
+ });
52
+ }
53
+ /** The per-member preset agents and the list/call operations the bridge forwards. */
54
+ export class PresetToolHost {
55
+ ctx;
56
+ diagnostics;
57
+ hosts = new Map();
58
+ /** Stand-in → the member's child session, for routing approvals its calls raise. */
59
+ standIns = new WeakMap();
60
+ /** The member a stand-in agent acts for, if it is one of ours. */
61
+ memberOf(agent) {
62
+ return typeof agent === 'object' && agent !== null ? this.standIns.get(agent) : undefined;
63
+ }
64
+ /**
65
+ * @param ctx - the local-agent plugin context.
66
+ * @param diagnostics - optional JSONL file each listing appends a summary to
67
+ * (what the preset agent's scope saw and which tools passed the filter).
68
+ */
69
+ constructor(ctx, diagnostics) {
70
+ this.ctx = ctx;
71
+ this.diagnostics = diagnostics;
72
+ }
73
+ /** Append one diagnostics line (best-effort). */
74
+ note(entry) {
75
+ if (this.diagnostics === undefined)
76
+ return;
77
+ try {
78
+ appendFileSync(this.diagnostics, `${JSON.stringify({ time: new Date().toISOString(), ...entry })}\n`);
79
+ }
80
+ catch { /* diagnostics never fail a call */ }
81
+ }
82
+ probe(name) {
83
+ return this.ctx.get.call(this.ctx, name);
84
+ }
85
+ /** The member's stand-in, scoped with its preset on first use (and re-scoped when the preset changed). */
86
+ async agentFor(childSessionId, preset, _cwd) {
87
+ const sessions = this.probe('sessions');
88
+ const presets = this.probe('agentPresets');
89
+ const session = sessions?.get(SessionId(childSessionId));
90
+ if (presets?.mount === undefined || session === undefined)
91
+ return undefined;
92
+ const current = this.hosts.get(childSessionId);
93
+ if (current !== undefined && current.preset === preset && (await current.agent).agent.session === session)
94
+ return (await current.agent).agent;
95
+ if (current !== undefined) {
96
+ this.hosts.delete(childSessionId);
97
+ void current.agent.then(handle => handle.dispose()).catch(() => { });
98
+ }
99
+ const agent = (async () => {
100
+ const resolved = await presets.resolve(preset);
101
+ const member = { id: childSessionId, session };
102
+ this.standIns.set(member, childSessionId);
103
+ const scope = createScope(this.ctx, member);
104
+ member.ctx = scope.ctx;
105
+ try {
106
+ await presets.mount(scope.ctx, resolved.id);
107
+ }
108
+ catch (error) {
109
+ await scope.dispose();
110
+ throw error;
111
+ }
112
+ // The member's session runs this mode now: the host's per-session
113
+ // plugin visibility reads it from here.
114
+ if (sessionPreset(session) !== resolved.id)
115
+ session.append('agent-preset/selected', { agentPreset: resolved.id });
116
+ return { agent: member, dispose: () => scope.dispose() };
117
+ })();
118
+ this.hosts.set(childSessionId, { preset, agent });
119
+ try {
120
+ return (await agent).agent;
121
+ }
122
+ catch (error) {
123
+ this.hosts.delete(childSessionId);
124
+ throw error;
125
+ }
126
+ }
127
+ /**
128
+ * The preset's plugin tools, for a member's `tools/list`.
129
+ * @param childSessionId - the member's child session.
130
+ * @param preset - the member's mode.
131
+ * @param cwd - the member's working directory.
132
+ * @returns the tools, empty when the host lacks the capabilities.
133
+ */
134
+ async list(childSessionId, preset, cwd) {
135
+ const tools = this.probe('tools');
136
+ const fresh = this.hosts.get(childSessionId)?.preset !== preset;
137
+ const agent = await this.agentFor(childSessionId, preset, cwd);
138
+ if (tools === undefined || agent === undefined) {
139
+ this.note({ childSessionId, preset, tools: tools !== undefined, agent: agent !== undefined });
140
+ return [];
141
+ }
142
+ // A preset mounted for the first time registers some rows' tools a
143
+ // moment later (deferred injects): wait until the scope stops growing.
144
+ let schemas = tools.schemas(agent);
145
+ if (fresh) {
146
+ for (let waited = 0; waited < SETTLE_MS; waited += SETTLE_STEP_MS) {
147
+ await new Promise(resolve => setTimeout(resolve, SETTLE_STEP_MS));
148
+ const next = tools.schemas(agent);
149
+ if (next.length === schemas.length && waited >= SETTLE_STEP_MS * 3)
150
+ break;
151
+ schemas = next;
152
+ }
153
+ }
154
+ const listed = schemas.filter(schema => isPluginTool(tools.get(schema.name, agent)));
155
+ this.note({ childSessionId, preset, agent: agent.id, scope: schemas.map(schema => {
156
+ const origin = tools.get(schema.name, agent)?.[ORIGIN];
157
+ return origin === undefined ? schema.name : `${schema.name}<${String(origin.channel)}:${String(origin.owner)}>`;
158
+ }), listed: listed.map(schema => schema.name) });
159
+ return listed
160
+ .map(schema => ({
161
+ name: schema.name,
162
+ description: schema.description ?? '',
163
+ inputSchema: (schema.parameters ?? { type: 'object', properties: {} }),
164
+ }));
165
+ }
166
+ /**
167
+ * Run one plugin tool for a member through the registry's execution
168
+ * pipeline (policies, approvals, timeouts), as the member's preset agent.
169
+ * @param childSessionId - the member's child session.
170
+ * @param preset - the member's mode.
171
+ * @param name - the tool name.
172
+ * @param args - the tool arguments.
173
+ * @param signal - cancellation.
174
+ * @param cwd - the member's working directory.
175
+ * @returns the MCP result.
176
+ */
177
+ async call(childSessionId, preset, name, args, signal, cwd) {
178
+ const tools = this.probe('tools');
179
+ const agent = await this.agentFor(childSessionId, preset, cwd);
180
+ if (tools === undefined || agent === undefined)
181
+ return { content: [{ type: 'text', text: 'Mode tools are unavailable on this host' }], isError: true };
182
+ if (!isPluginTool(tools.get(name, agent)))
183
+ return { content: [{ type: 'text', text: `Unknown tool ${name} in mode ${preset}` }], isError: true };
184
+ const result = await tools.execute({ callId: `bridge-${randomUUID()}`, name, arguments: args, agent, signal });
185
+ return { content: mcpContent(result.content), isError: result.isError };
186
+ }
187
+ /**
188
+ * The skills the member's mode contributes: visible in its scope, absent
189
+ * from the global view (installed skills every CLI already sees stay out).
190
+ * @param childSessionId - the member's child session.
191
+ * @param preset - the member's mode.
192
+ * @returns the mode's skills with their content; empty without a skill registry.
193
+ */
194
+ async skills(childSessionId, preset) {
195
+ const registry = this.probe('skills');
196
+ const agent = await this.agentFor(childSessionId, preset, undefined);
197
+ if (registry?.list === undefined || agent === undefined)
198
+ return [];
199
+ const global = new Set((await registry.list()).map(skill => skill.name));
200
+ const own = (await registry.list({ scope: agent })).filter(skill => !global.has(skill.name));
201
+ const loaded = await Promise.all(own.map(skill => registry.get(skill.name, { scope: agent })));
202
+ return loaded.flatMap(skill => skill === undefined ? [] : [{ name: skill.name, description: skill.description, content: skill.content }]);
203
+ }
204
+ /** Dispose every preset agent. */
205
+ async dispose() {
206
+ const hosts = [...this.hosts.values()];
207
+ this.hosts.clear();
208
+ await Promise.all(hosts.map(host => host.agent.then(handle => handle.dispose()).catch(() => { })));
209
+ }
210
+ }
@@ -653,6 +653,14 @@ export interface LocalAgentMemberMessage {
653
653
  parentSessionId: string;
654
654
  /** The sender's delegation view, recorded as the delivery's provenance. */
655
655
  provenance: Record<string, string>;
656
+ /** `task` (default): a claiming room reports the reply back; `notify`: FYI only. */
657
+ kind?: 'task' | 'notify';
658
+ /** The message id this one answers (e.g. `m12`). */
659
+ inReplyTo?: string;
660
+ /** What the sender waits for before acting on the result (a claiming room's report gate reads it). */
661
+ note?: string;
662
+ /** `new` or a task number (a claiming room files the message under it). */
663
+ task?: string;
656
664
  }
657
665
  /**
658
666
  * The room gate's answer to a member notification, passed back to the sender
@@ -661,7 +669,7 @@ export interface LocalAgentMemberMessage {
661
669
  * DECLINE is not a value: the gate throws (the parent session is not a room
662
670
  * this instance manages) and the family direct-sends.
663
671
  */
664
- export type RoomMemberMessageReceipt = 'sent' | 'pending-confirm' | 'busy';
672
+ export type RoomMemberMessageReceipt = 'sent' | 'pending-confirm' | 'busy' | `sent ${string}` | `pending-confirm ${string}`;
665
673
  /**
666
674
  * Duck-typed shape of the room service's member-message gate, probed via
667
675
  * `ctx.get('room')` — deliberately NO import of any room package, so the
@@ -669,7 +677,7 @@ export type RoomMemberMessageReceipt = 'sent' | 'pending-confirm' | 'busy';
669
677
  * independence-checker sanction: room absent or declining is invisible to the
670
678
  * family path. Room implements this shape to own the dispatch gate.
671
679
  */
672
- export type MemberRoomCommandName = 'room_plan' | 'room_read' | 'room_invite' | 'room_message';
680
+ export type MemberRoomCommandName = 'room_plan' | 'room_read' | 'room_invite' | 'room_message' | 'ask_user_question' | 'room_help';
673
681
  export interface MemberRoomCommand {
674
682
  name: MemberRoomCommandName;
675
683
  arguments: Record<string, unknown>;
@@ -681,6 +689,23 @@ export interface RoomMemberMessageGate {
681
689
  }): Promise<boolean>;
682
690
  receiveMemberCommand?(actor: LocalAgentMemberRun, command: MemberRoomCommand): Promise<MemberMessageOutcome>;
683
691
  receiveMemberMessage(message: LocalAgentMemberMessage): Promise<RoomMemberMessageReceipt>;
692
+ /**
693
+ * The member's mode (a host agent preset id) whose plugin tools its bridge
694
+ * offers, with the member's working directory; undefined = no mode tools.
695
+ */
696
+ memberMode?(member: LocalAgentMemberRun): Promise<{
697
+ readonly preset: string;
698
+ readonly cwd?: string;
699
+ } | undefined>;
700
+ /**
701
+ * A member's mode tool needs the human's approval: the room asks in its
702
+ * conversation and answers with the registry's approval outcome.
703
+ */
704
+ askMemberApproval?(member: LocalAgentMemberRun, request: {
705
+ readonly toolName: string;
706
+ readonly reason?: string;
707
+ readonly signal?: AbortSignal;
708
+ }): Promise<'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'>;
684
709
  }
685
710
  /**
686
711
  * Outcome of one `member_message` delivery. Channel-level failures (unknown or
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "local-agent",
9
9
  "cli"
10
10
  ],
11
- "version": "0.2.0-rc.3",
11
+ "version": "0.2.0",
12
12
  "type": "module",
13
13
  "main": "lib/index.js",
14
14
  "types": "lib/types/index.d.ts",
@@ -59,6 +59,9 @@
59
59
  "bundle": {
60
60
  "patch": "./cordis.patch.yml"
61
61
  },
62
+ "references": [
63
+ "@khorsheed/dsh-room"
64
+ ],
62
65
  "compat": {
63
66
  "minHost": "0.1.5-rc.1",
64
67
  "verifiedHost": "0.2.1-rc.1",
@@ -91,6 +94,7 @@
91
94
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
92
95
  "@deepseek-ai/dsh-commands": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
93
96
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
97
+ "@deepseek-ai/dsh-scope": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
94
98
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
95
99
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
96
100
  "@deepseek-ai/dsh-subagent": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",