@khorsheed/dsh-local-agent 0.1.0-rc.6
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/README.en.md +181 -0
- package/README.i18n.yaml +2 -0
- package/README.md +181 -0
- package/cordis.patch.yml +21 -0
- package/lib/client.js +8832 -0
- package/lib/index.js +5281 -0
- package/lib/invariant.js +85 -0
- package/lib/member-bridge.js +298 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +3137 -0
- package/lib/typert.remote-client.d.ts +67 -0
- package/lib/typert.remote-client.js +1971 -0
- package/lib/types/cli-version.d.ts +82 -0
- package/lib/types/cli-version.js +165 -0
- package/lib/types/client/AuthStatusDot.d.ts +17 -0
- package/lib/types/client/AuthStatusDot.js +9 -0
- package/lib/types/client/HarnessModelPicker.d.ts +17 -0
- package/lib/types/client/HarnessModelPicker.js +48 -0
- package/lib/types/client/LocalAgentRecordsAction.d.ts +41 -0
- package/lib/types/client/LocalAgentRecordsAction.js +74 -0
- package/lib/types/client/MemberComposer.d.ts +79 -0
- package/lib/types/client/MemberComposer.js +308 -0
- package/lib/types/client/MemberConfiguration.d.ts +9 -0
- package/lib/types/client/MemberConfiguration.js +46 -0
- package/lib/types/client/MemberInboxView.d.ts +12 -0
- package/lib/types/client/MemberInboxView.js +38 -0
- package/lib/types/client/MemberLiveNode.d.ts +14 -0
- package/lib/types/client/MemberLiveNode.js +74 -0
- package/lib/types/client/MemberPaintDiagnostics.d.ts +7 -0
- package/lib/types/client/MemberPaintDiagnostics.js +20 -0
- package/lib/types/client/ModelConfigurationFields.d.ts +15 -0
- package/lib/types/client/ModelConfigurationFields.js +23 -0
- package/lib/types/client/ProviderAuthBlock.d.ts +59 -0
- package/lib/types/client/ProviderAuthBlock.js +164 -0
- package/lib/types/client/auth-status.d.ts +28 -0
- package/lib/types/client/auth-status.js +56 -0
- package/lib/types/client/icons.d.ts +22 -0
- package/lib/types/client/icons.js +6 -0
- package/lib/types/client/index.d.ts +62 -0
- package/lib/types/client/index.js +135 -0
- package/lib/types/client/live-node.d.ts +14 -0
- package/lib/types/client/live-node.js +34 -0
- package/lib/types/client/live-output.d.ts +34 -0
- package/lib/types/client/live-output.js +94 -0
- package/lib/types/client/live-paint.d.ts +40 -0
- package/lib/types/client/live-paint.js +74 -0
- package/lib/types/client/locales.d.ts +120 -0
- package/lib/types/client/locales.js +227 -0
- package/lib/types/client/member-configuration.d.ts +41 -0
- package/lib/types/client/member-configuration.js +114 -0
- package/lib/types/client/member-dock.d.ts +94 -0
- package/lib/types/client/member-dock.js +102 -0
- package/lib/types/client/member-feed.d.ts +24 -0
- package/lib/types/client/member-feed.js +134 -0
- package/lib/types/client/request-id.d.ts +3 -0
- package/lib/types/client/request-id.js +6 -0
- package/lib/types/client/settings-scope.d.ts +74 -0
- package/lib/types/client/settings-scope.js +98 -0
- package/lib/types/client/use-configuration-menu.d.ts +10 -0
- package/lib/types/client/use-configuration-menu.js +61 -0
- package/lib/types/container.d.ts +69 -0
- package/lib/types/container.js +129 -0
- package/lib/types/env.d.ts +17 -0
- package/lib/types/env.js +89 -0
- package/lib/types/gateway.d.ts +162 -0
- package/lib/types/gateway.js +470 -0
- package/lib/types/index.d.ts +1463 -0
- package/lib/types/index.js +2704 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +105 -0
- package/lib/types/live-flush.d.ts +21 -0
- package/lib/types/live-flush.js +59 -0
- package/lib/types/live-stream.d.ts +41 -0
- package/lib/types/live-stream.js +178 -0
- package/lib/types/member-bridge.d.ts +50 -0
- package/lib/types/member-bridge.js +216 -0
- package/lib/types/member-channel.d.ts +86 -0
- package/lib/types/member-channel.js +232 -0
- package/lib/types/member-control-storage.d.ts +10 -0
- package/lib/types/member-control-storage.js +100 -0
- package/lib/types/member-control.d.ts +62 -0
- package/lib/types/member-control.js +359 -0
- package/lib/types/member-controls.d.ts +23 -0
- package/lib/types/member-controls.js +143 -0
- package/lib/types/member-feed.d.ts +4 -0
- package/lib/types/member-feed.js +45 -0
- package/lib/types/member-inbox.d.ts +30 -0
- package/lib/types/member-inbox.js +225 -0
- package/lib/types/model-directory.d.ts +43 -0
- package/lib/types/model-directory.js +192 -0
- package/lib/types/prepared-members.d.ts +15 -0
- package/lib/types/prepared-members.js +61 -0
- package/lib/types/settings-face.d.ts +61 -0
- package/lib/types/settings-face.js +61 -0
- package/lib/types/types.d.ts +959 -0
- package/lib/types/types.js +24 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +112 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Member bridge: a minimal standalone MCP server (stdio, newline-delimited
|
|
3
|
+
* JSON-RPC) exposing member messaging and room coordination tools to the CLI
|
|
4
|
+
* member that spawned it. Each call is forwarded to the host's member-channel
|
|
5
|
+
* listener over the loopback socket named by `DSH_MEMBER_SOCKET`,
|
|
6
|
+
* authenticated by the per-run token in `DSH_MEMBER_TOKEN`; the host's
|
|
7
|
+
* receipt is the tool result verbatim.
|
|
8
|
+
*
|
|
9
|
+
* The process is dependency-free (node builtins only) because the CLI spawns
|
|
10
|
+
* it bare: `node <this file>`, with the socket path and token in its
|
|
11
|
+
* environment (written into the CLI's scoped MCP config by the provider).
|
|
12
|
+
* Covers the MCP handshake subset a client needs: `initialize`,
|
|
13
|
+
* `notifications/initialized`, `ping`, `tools/list`, `tools/call`.
|
|
14
|
+
* @module @khorsheed/dsh-local-agent/member-bridge
|
|
15
|
+
*/
|
|
16
|
+
import { createInterface } from 'node:readline';
|
|
17
|
+
import { connect } from 'node:net';
|
|
18
|
+
import { realpathSync } from 'node:fs';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV } from './types.js';
|
|
21
|
+
/** The one tool this server exposes. */
|
|
22
|
+
export const MEMBER_MESSAGE_TOOL = 'member_message';
|
|
23
|
+
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.',
|
|
27
|
+
].join(' ');
|
|
28
|
+
const TOOL_SCHEMA = {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
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.' },
|
|
33
|
+
},
|
|
34
|
+
required: ['to', 'text'],
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
};
|
|
37
|
+
const ROOM_TOOLS = [
|
|
38
|
+
{ 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
|
+
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.',
|
|
41
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
42
|
+
{ name: 'room_invite', description: 'Coordinator only: invite a member. An optional firstTask starts in the background and returns its result to you automatically.',
|
|
43
|
+
inputSchema: { type: 'object', properties: {
|
|
44
|
+
provider: { type: 'string' }, name: { type: 'string' }, instructions: { type: 'string' },
|
|
45
|
+
cwd: { type: 'string' }, model: { type: 'string' }, firstTask: { type: 'string' },
|
|
46
|
+
}, 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 } },
|
|
49
|
+
];
|
|
50
|
+
export async function callRoomCommand(env, name, args) {
|
|
51
|
+
if (env.socket === undefined || env.token === undefined)
|
|
52
|
+
return toolText('member channel is not configured for this run', true);
|
|
53
|
+
if (args === null || typeof args !== 'object' || Array.isArray(args))
|
|
54
|
+
return toolText('Room tool arguments must be an object', true);
|
|
55
|
+
try {
|
|
56
|
+
const outcome = await callHost(env.socket, { token: env.token, command: { name, arguments: args } });
|
|
57
|
+
return toolText(outcome.ok ? outcome.receipt ?? 'accepted' : outcome.error ?? 'Room command rejected', !outcome.ok);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
return toolText(`Room command unavailable: ${String(error)}`, true);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** One socket round trip: send the request line, read the one-line response. */
|
|
64
|
+
function callHost(socket, payload) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const connection = connect(socket);
|
|
67
|
+
let buffer = '';
|
|
68
|
+
connection.on('connect', () => {
|
|
69
|
+
connection.write(`${JSON.stringify(payload)}\n`);
|
|
70
|
+
});
|
|
71
|
+
connection.on('data', (chunk) => {
|
|
72
|
+
buffer += chunk.toString();
|
|
73
|
+
const newline = buffer.indexOf('\n');
|
|
74
|
+
if (newline < 0)
|
|
75
|
+
return;
|
|
76
|
+
connection.end();
|
|
77
|
+
try {
|
|
78
|
+
resolve(JSON.parse(buffer.slice(0, newline)));
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
connection.on('error', reject);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** A successful MCP tool result carrying the receipt text. */
|
|
88
|
+
function toolText(text, isError = false) {
|
|
89
|
+
return { content: [{ type: 'text', text }], isError };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Handle one `member_message` tool call: forward to the host listener and map
|
|
93
|
+
* the outcome to a tool result. Channel failures (missing env, unreachable
|
|
94
|
+
* listener, rejected token) are tool errors; delivery verdicts are text.
|
|
95
|
+
* @param env - the bridge's environment (socket, token).
|
|
96
|
+
* @param args - the tool arguments (`to`, `text`).
|
|
97
|
+
* @returns the MCP tool result object.
|
|
98
|
+
*/
|
|
99
|
+
export async function callMemberMessage(env, args) {
|
|
100
|
+
if (env.socket === undefined || env.token === undefined) {
|
|
101
|
+
return toolText('member channel is not configured for this run (missing socket/token environment)', true);
|
|
102
|
+
}
|
|
103
|
+
if (typeof args.to !== 'string' || typeof args.text !== 'string') {
|
|
104
|
+
return toolText('member_message requires string arguments `to` and `text`', true);
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const outcome = await callHost(env.socket, {
|
|
108
|
+
token: env.token,
|
|
109
|
+
to: args.to,
|
|
110
|
+
text: args.text,
|
|
111
|
+
});
|
|
112
|
+
if (outcome.ok)
|
|
113
|
+
return toolText(outcome.receipt ?? 'sent');
|
|
114
|
+
return toolText(outcome.error ?? 'member channel rejected the message', true);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
return toolText(`member channel unavailable: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Run the stdio JSON-RPC loop over the given streams (injectable for tests).
|
|
122
|
+
* Unknown methods with an id get a MethodNotFound error; notifications are
|
|
123
|
+
* ignored. Never throws — a malformed line is answered with a parse error and
|
|
124
|
+
* the loop continues.
|
|
125
|
+
* @param env - the bridge's environment.
|
|
126
|
+
* @param input - the stdin side.
|
|
127
|
+
* @param output - the stdout side.
|
|
128
|
+
*/
|
|
129
|
+
export function serveMemberBridge(env, input, output) {
|
|
130
|
+
const write = (message) => {
|
|
131
|
+
output.write(`${JSON.stringify(message)}\n`);
|
|
132
|
+
};
|
|
133
|
+
const lines = createInterface({ input, terminal: false });
|
|
134
|
+
lines.on('line', (line) => {
|
|
135
|
+
if (line.trim() === '')
|
|
136
|
+
return;
|
|
137
|
+
let request;
|
|
138
|
+
try {
|
|
139
|
+
request = JSON.parse(line);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const id = request.id;
|
|
146
|
+
switch (request.method) {
|
|
147
|
+
case 'initialize': {
|
|
148
|
+
const params = request.params;
|
|
149
|
+
write({
|
|
150
|
+
jsonrpc: '2.0',
|
|
151
|
+
id: id ?? null,
|
|
152
|
+
result: {
|
|
153
|
+
protocolVersion: params?.protocolVersion ?? '2024-11-05',
|
|
154
|
+
capabilities: { tools: {} },
|
|
155
|
+
serverInfo: { name: 'dsh-member-bridge', version: '0.1.0' },
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
case 'ping':
|
|
161
|
+
write({ jsonrpc: '2.0', id: id ?? null, result: {} });
|
|
162
|
+
return;
|
|
163
|
+
case 'tools/list':
|
|
164
|
+
write({
|
|
165
|
+
jsonrpc: '2.0',
|
|
166
|
+
id: id ?? null,
|
|
167
|
+
result: {
|
|
168
|
+
tools: [{
|
|
169
|
+
name: MEMBER_MESSAGE_TOOL,
|
|
170
|
+
description: TOOL_DESCRIPTION,
|
|
171
|
+
inputSchema: TOOL_SCHEMA,
|
|
172
|
+
}, ...ROOM_TOOLS],
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
case 'tools/call': {
|
|
177
|
+
const params = request.params;
|
|
178
|
+
if (ROOM_TOOLS.some(tool => tool.name === params?.name)) {
|
|
179
|
+
void callRoomCommand(env, params.name, params.arguments ?? {}).then(result => write({ jsonrpc: '2.0', id: id ?? null, result }));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (params?.name !== MEMBER_MESSAGE_TOOL) {
|
|
183
|
+
write({ jsonrpc: '2.0', id: id ?? null, error: { code: -32602, message: `unknown tool ${String(params?.name)}` } });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
void callMemberMessage(env, params.arguments ?? {}).then((result) => {
|
|
187
|
+
write({ jsonrpc: '2.0', id: id ?? null, result });
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
default:
|
|
192
|
+
// Notifications (no id) — e.g. notifications/initialized — get no reply.
|
|
193
|
+
if (id !== undefined) {
|
|
194
|
+
write({ jsonrpc: '2.0', id, error: { code: -32601, message: `method not found: ${String(request.method)}` } });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/** True when this file is the process entry (`node member-bridge.js`). */
|
|
200
|
+
function isMain() {
|
|
201
|
+
const entry = process.argv[1];
|
|
202
|
+
if (entry === undefined)
|
|
203
|
+
return false;
|
|
204
|
+
try {
|
|
205
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (isMain()) {
|
|
212
|
+
serveMemberBridge({
|
|
213
|
+
socket: process.env[MEMBER_BRIDGE_SOCKET_ENV],
|
|
214
|
+
token: process.env[MEMBER_BRIDGE_TOKEN_ENV],
|
|
215
|
+
}, process.stdin, process.stdout);
|
|
216
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Member channel, host side: the loopback listener the bridge MCP servers call
|
|
3
|
+
* back on, and the member-to-member delivery chain. One newline-delimited JSON
|
|
4
|
+
* request per line (`{ token, to, text }`), one response line
|
|
5
|
+
* (`{ ok: true, receipt } | { ok: false, error }`).
|
|
6
|
+
*
|
|
7
|
+
* Delivery chain for one `member_message` call:
|
|
8
|
+
*
|
|
9
|
+
* 1. token → the registered in-flight run (sender A's identity; never
|
|
10
|
+
* self-reported). The per-run token is the SOLE credential (host 0.1.5
|
|
11
|
+
* removed the child pid the parentage cross-check used): minted at run
|
|
12
|
+
* start, delivered through the CLI's scoped MCP config (0700 scoped home),
|
|
13
|
+
* invalidated the moment the run settles. The residual exposure — a
|
|
14
|
+
* same-host same-user sibling CLI replaying a token it read — is
|
|
15
|
+
* documented in the package README and tracked by the auth-hardening
|
|
16
|
+
* proposal;
|
|
17
|
+
* 2. resolve B (`to` = a member's dsh child session id; member NAMES resolve
|
|
18
|
+
* only through a claiming room's roster);
|
|
19
|
+
* 3. same-parent check: B's delegation must share A's parent session;
|
|
20
|
+
* 4. gate handoff: probe `ctx.get('room')` for the duck-typed
|
|
21
|
+
* {@link RoomMemberMessageGate} (NO import of any room package — room
|
|
22
|
+
* absent or declining is invisible); a claiming room owns the dispatch and
|
|
23
|
+
* its receipt passes back verbatim;
|
|
24
|
+
* 5. room absent/declining → family direct-send: B's resume lock busy →
|
|
25
|
+
* `busy`; else the facade `resume` with a provenance-tagged prompt.
|
|
26
|
+
* @module @khorsheed/dsh-local-agent/member-channel
|
|
27
|
+
*/
|
|
28
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
29
|
+
import type { LocalAgentRegistry } from './index.ts';
|
|
30
|
+
import type { MemberRoomCommand, MemberMessageOutcome } from './types.ts';
|
|
31
|
+
/** One bridge callback on the wire. */
|
|
32
|
+
export interface MemberBridgeRequest {
|
|
33
|
+
/** The per-run token injected into the bridge's environment (the sole credential). */
|
|
34
|
+
token: string;
|
|
35
|
+
/** The raw `to` argument: a member child session id, or a room-roster name. */
|
|
36
|
+
to: string;
|
|
37
|
+
/** The notification text. */
|
|
38
|
+
text: string;
|
|
39
|
+
}
|
|
40
|
+
export interface MemberCommandBridgeRequest {
|
|
41
|
+
token: string;
|
|
42
|
+
command: MemberRoomCommand;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The member-channel host: owns the loopback unix-socket listener and the
|
|
46
|
+
* delivery chain. Mounted by the plugin's `apply` and disposed with its fiber.
|
|
47
|
+
*/
|
|
48
|
+
export declare class MemberChannel {
|
|
49
|
+
private readonly ctx;
|
|
50
|
+
private readonly registry;
|
|
51
|
+
private readonly socketPath;
|
|
52
|
+
private server;
|
|
53
|
+
constructor(ctx: Context, registry: LocalAgentRegistry, socketPath: string);
|
|
54
|
+
/**
|
|
55
|
+
* Start listening. A listener failure (e.g. a socket path beyond the
|
|
56
|
+
* platform limit) degrades the whole member channel to absent — the run
|
|
57
|
+
* itself is never at risk, so this warns instead of throwing.
|
|
58
|
+
*/
|
|
59
|
+
start(): Promise<void>;
|
|
60
|
+
/** Close the listener and remove the socket file. */
|
|
61
|
+
dispose(): Promise<void>;
|
|
62
|
+
/** Serve one bridge connection: newline-delimited JSON request lines. */
|
|
63
|
+
private serve;
|
|
64
|
+
/** Handle one raw request line and write the one-line response. */
|
|
65
|
+
private answer;
|
|
66
|
+
/**
|
|
67
|
+
* The delivery chain documented in the module header. Exposed for tests and
|
|
68
|
+
* for the listener loop; never throws — every failure is a structured
|
|
69
|
+
* outcome.
|
|
70
|
+
* @param request - the bridge callback.
|
|
71
|
+
* @returns the delivery outcome.
|
|
72
|
+
*/
|
|
73
|
+
handle(request: MemberBridgeRequest | MemberCommandBridgeRequest): Promise<MemberMessageOutcome>;
|
|
74
|
+
/** Probe the optional room gate without importing any room package. */
|
|
75
|
+
private probeRoom;
|
|
76
|
+
/**
|
|
77
|
+
* Offer the notification to the room gate. A returned receipt means room
|
|
78
|
+
* owns the dispatch (passed back verbatim); a THROW declines (the parent
|
|
79
|
+
* session is not a room this instance manages) and the family path
|
|
80
|
+
* continues. The message is the frozen contract: `from`/`to` as plain
|
|
81
|
+
* strings (the sender's child session id; room resolves roster names),
|
|
82
|
+
* the delegation view as provenance.
|
|
83
|
+
*/
|
|
84
|
+
private handToGate;
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=member-channel.d.ts.map
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Member channel, host side: the loopback listener the bridge MCP servers call
|
|
3
|
+
* back on, and the member-to-member delivery chain. One newline-delimited JSON
|
|
4
|
+
* request per line (`{ token, to, text }`), one response line
|
|
5
|
+
* (`{ ok: true, receipt } | { ok: false, error }`).
|
|
6
|
+
*
|
|
7
|
+
* Delivery chain for one `member_message` call:
|
|
8
|
+
*
|
|
9
|
+
* 1. token → the registered in-flight run (sender A's identity; never
|
|
10
|
+
* self-reported). The per-run token is the SOLE credential (host 0.1.5
|
|
11
|
+
* removed the child pid the parentage cross-check used): minted at run
|
|
12
|
+
* start, delivered through the CLI's scoped MCP config (0700 scoped home),
|
|
13
|
+
* invalidated the moment the run settles. The residual exposure — a
|
|
14
|
+
* same-host same-user sibling CLI replaying a token it read — is
|
|
15
|
+
* documented in the package README and tracked by the auth-hardening
|
|
16
|
+
* proposal;
|
|
17
|
+
* 2. resolve B (`to` = a member's dsh child session id; member NAMES resolve
|
|
18
|
+
* only through a claiming room's roster);
|
|
19
|
+
* 3. same-parent check: B's delegation must share A's parent session;
|
|
20
|
+
* 4. gate handoff: probe `ctx.get('room')` for the duck-typed
|
|
21
|
+
* {@link RoomMemberMessageGate} (NO import of any room package — room
|
|
22
|
+
* absent or declining is invisible); a claiming room owns the dispatch and
|
|
23
|
+
* its receipt passes back verbatim;
|
|
24
|
+
* 5. room absent/declining → family direct-send: B's resume lock busy →
|
|
25
|
+
* `busy`; else the facade `resume` with a provenance-tagged prompt.
|
|
26
|
+
* @module @khorsheed/dsh-local-agent/member-channel
|
|
27
|
+
*/
|
|
28
|
+
import { createServer } from 'node:net';
|
|
29
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
30
|
+
import { dirname } from 'node:path';
|
|
31
|
+
/**
|
|
32
|
+
* The member-channel host: owns the loopback unix-socket listener and the
|
|
33
|
+
* delivery chain. Mounted by the plugin's `apply` and disposed with its fiber.
|
|
34
|
+
*/
|
|
35
|
+
export class MemberChannel {
|
|
36
|
+
ctx;
|
|
37
|
+
registry;
|
|
38
|
+
socketPath;
|
|
39
|
+
server;
|
|
40
|
+
constructor(ctx, registry, socketPath) {
|
|
41
|
+
this.ctx = ctx;
|
|
42
|
+
this.registry = registry;
|
|
43
|
+
this.socketPath = socketPath;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Start listening. A listener failure (e.g. a socket path beyond the
|
|
47
|
+
* platform limit) degrades the whole member channel to absent — the run
|
|
48
|
+
* itself is never at risk, so this warns instead of throwing.
|
|
49
|
+
*/
|
|
50
|
+
async start() {
|
|
51
|
+
try {
|
|
52
|
+
mkdirSync(dirname(this.socketPath), { recursive: true });
|
|
53
|
+
// A stale socket from a crashed host is safe to replace: the path is
|
|
54
|
+
// per-homes-root, and only this plugin instance binds it.
|
|
55
|
+
rmSync(this.socketPath, { force: true });
|
|
56
|
+
const server = createServer((socket) => { this.serve(socket); });
|
|
57
|
+
await new Promise((resolve, reject) => {
|
|
58
|
+
server.once('error', reject);
|
|
59
|
+
server.listen(this.socketPath, () => { resolve(); });
|
|
60
|
+
});
|
|
61
|
+
server.removeAllListeners('error');
|
|
62
|
+
this.server = server;
|
|
63
|
+
this.registry.setMemberBridgeAvailable(true);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
this.ctx.logger.warn(`localAgent: member channel listener unavailable (${error instanceof Error ? error.message : String(error)}); member_message calls will fail`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Close the listener and remove the socket file. */
|
|
70
|
+
async dispose() {
|
|
71
|
+
const server = this.server;
|
|
72
|
+
this.server = undefined;
|
|
73
|
+
this.registry.setMemberBridgeAvailable(false);
|
|
74
|
+
if (server === undefined)
|
|
75
|
+
return;
|
|
76
|
+
await new Promise((resolve) => { server.close(() => { resolve(); }); });
|
|
77
|
+
rmSync(this.socketPath, { force: true });
|
|
78
|
+
}
|
|
79
|
+
/** Serve one bridge connection: newline-delimited JSON request lines. */
|
|
80
|
+
serve(socket) {
|
|
81
|
+
let buffer = '';
|
|
82
|
+
socket.on('data', (chunk) => {
|
|
83
|
+
buffer += chunk.toString();
|
|
84
|
+
for (;;) {
|
|
85
|
+
const newline = buffer.indexOf('\n');
|
|
86
|
+
if (newline < 0)
|
|
87
|
+
break;
|
|
88
|
+
const line = buffer.slice(0, newline);
|
|
89
|
+
buffer = buffer.slice(newline + 1);
|
|
90
|
+
if (line.trim() === '')
|
|
91
|
+
continue;
|
|
92
|
+
void this.answer(socket, line);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
socket.on('error', () => { });
|
|
96
|
+
}
|
|
97
|
+
/** Handle one raw request line and write the one-line response. */
|
|
98
|
+
async answer(socket, line) {
|
|
99
|
+
let outcome;
|
|
100
|
+
try {
|
|
101
|
+
outcome = await this.handle(JSON.parse(line));
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
outcome = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
105
|
+
}
|
|
106
|
+
if (!socket.destroyed)
|
|
107
|
+
socket.end(`${JSON.stringify(outcome)}\n`);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The delivery chain documented in the module header. Exposed for tests and
|
|
111
|
+
* for the listener loop; never throws — every failure is a structured
|
|
112
|
+
* outcome.
|
|
113
|
+
* @param request - the bridge callback.
|
|
114
|
+
* @returns the delivery outcome.
|
|
115
|
+
*/
|
|
116
|
+
async handle(request) {
|
|
117
|
+
if (request === null || typeof request !== 'object' || typeof request.token !== 'string')
|
|
118
|
+
return { ok: false, error: 'Malformed member bridge request' };
|
|
119
|
+
const run = this.registry.resolveMemberRun(request.token);
|
|
120
|
+
if (run === undefined) {
|
|
121
|
+
return { ok: false, error: 'localAgent: unknown or expired member token' };
|
|
122
|
+
}
|
|
123
|
+
if ('command' in request) {
|
|
124
|
+
const room = this.probeRoom();
|
|
125
|
+
if (room?.receiveMemberCommand === undefined)
|
|
126
|
+
return { ok: false, error: 'Room coordination tools are unavailable' };
|
|
127
|
+
try {
|
|
128
|
+
const round = this.registry.memberConfiguration(run.childSessionId).round;
|
|
129
|
+
if (round === undefined || round.id.startsWith('prepare:'))
|
|
130
|
+
return { ok: false, error: 'Room commands require an active member turn' };
|
|
131
|
+
return await room.receiveMemberCommand(run, request.command);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
return { ok: false, error: String(error) };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (typeof request.to !== 'string' || typeof request.text !== 'string' || request.text.trim() === '')
|
|
138
|
+
return { ok: false, error: 'member_message requires a target and non-empty text' };
|
|
139
|
+
const harness = this.registry.list()
|
|
140
|
+
.map(name => this.registry.get(name))
|
|
141
|
+
.find(candidate => candidate?.delegationProvider === run.provider);
|
|
142
|
+
const from = {
|
|
143
|
+
childSessionId: run.childSessionId,
|
|
144
|
+
provider: run.provider,
|
|
145
|
+
parentSessionId: run.parentSessionId,
|
|
146
|
+
...harness === undefined ? {} : { harnessDisplayName: harness.displayName },
|
|
147
|
+
};
|
|
148
|
+
const room = this.probeRoom();
|
|
149
|
+
const target = this.registry.getDelegation(request.to);
|
|
150
|
+
if (target === undefined) {
|
|
151
|
+
// The family registry cannot resolve B — a member NAME resolves only
|
|
152
|
+
// through room's roster, so the gate gets the raw argument first.
|
|
153
|
+
if (room !== undefined) {
|
|
154
|
+
const handed = await this.handToGate(room, from, request);
|
|
155
|
+
if (handed !== undefined)
|
|
156
|
+
return handed;
|
|
157
|
+
}
|
|
158
|
+
return { ok: false, error: `localAgent: unknown member ${JSON.stringify(request.to)} (room absent: pass the member's child session id)` };
|
|
159
|
+
}
|
|
160
|
+
if (target.parentSessionId !== run.parentSessionId) {
|
|
161
|
+
return { ok: false, error: `localAgent: member ${request.to} belongs to another parent session; cross-room notification is rejected` };
|
|
162
|
+
}
|
|
163
|
+
if (room !== undefined) {
|
|
164
|
+
const handed = await this.handToGate(room, from, request);
|
|
165
|
+
if (handed !== undefined)
|
|
166
|
+
return handed;
|
|
167
|
+
}
|
|
168
|
+
// Family direct-send: the resume lock doubles as the busy signal.
|
|
169
|
+
if (this.registry.isResumeLocked(target.childSessionId) && !this.registry.supportsMemberConfiguration(target.provider)) {
|
|
170
|
+
return { ok: true, receipt: 'busy' };
|
|
171
|
+
}
|
|
172
|
+
const label = from.harnessDisplayName ?? from.provider;
|
|
173
|
+
try {
|
|
174
|
+
await this.registry.resume(target.parentSessionId, target.provider, target.childSessionId, [{
|
|
175
|
+
type: 'text',
|
|
176
|
+
text: `成员 ${label}(会话 ${from.childSessionId})转告:\n${request.text}\n\n(回复请调用 member_message 工具,to 填 "${from.childSessionId}")`,
|
|
177
|
+
}]);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
return { ok: true, receipt: `error: ${error instanceof Error ? error.message : String(error)}` };
|
|
181
|
+
}
|
|
182
|
+
return { ok: true, receipt: 'sent' };
|
|
183
|
+
}
|
|
184
|
+
/** Probe the optional room gate without importing any room package. */
|
|
185
|
+
probeRoom() {
|
|
186
|
+
const room = this.ctx.get.call(this.ctx, 'room');
|
|
187
|
+
if (typeof room !== 'object' || room === null)
|
|
188
|
+
return undefined;
|
|
189
|
+
const gate = room;
|
|
190
|
+
return typeof gate.receiveMemberMessage === 'function' ? gate : undefined;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Offer the notification to the room gate. A returned receipt means room
|
|
194
|
+
* owns the dispatch (passed back verbatim); a THROW declines (the parent
|
|
195
|
+
* session is not a room this instance manages) and the family path
|
|
196
|
+
* continues. The message is the frozen contract: `from`/`to` as plain
|
|
197
|
+
* strings (the sender's child session id; room resolves roster names),
|
|
198
|
+
* the delegation view as provenance.
|
|
199
|
+
*/
|
|
200
|
+
async handToGate(room, from, request) {
|
|
201
|
+
const message = {
|
|
202
|
+
from: from.childSessionId,
|
|
203
|
+
to: request.to,
|
|
204
|
+
content: request.text,
|
|
205
|
+
parentSessionId: from.parentSessionId,
|
|
206
|
+
provenance: {
|
|
207
|
+
childSessionId: from.childSessionId,
|
|
208
|
+
provider: from.provider,
|
|
209
|
+
parentSessionId: from.parentSessionId,
|
|
210
|
+
...from.harnessDisplayName === undefined ? {} : { harnessDisplayName: from.harnessDisplayName },
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
let ownsRoom = false;
|
|
214
|
+
try {
|
|
215
|
+
if (room.isRoom !== undefined) {
|
|
216
|
+
ownsRoom = true; // An unreadable ownership check also fails closed.
|
|
217
|
+
if (!await room.isRoom({ sessionId: from.parentSessionId }))
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
const receipt = await room.receiveMemberMessage(message);
|
|
221
|
+
return { ok: true, receipt };
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (ownsRoom)
|
|
225
|
+
return { ok: false, error: `Room rejected member message: ${String(error)}` };
|
|
226
|
+
// Legacy gates have no ownership probe; preserve their decline contract. The family
|
|
227
|
+
// direct-sends.
|
|
228
|
+
this.ctx.logger.warn(`localAgent: room gate declined (${error instanceof Error ? error.message : String(error)}); direct-sending the member message`);
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MemberControlRecord, MemberControlStorage } from './member-control.ts';
|
|
2
|
+
/** Per-member atomic files; acknowledgement requires a flushed file and rename. */
|
|
3
|
+
export declare class FileMemberControlStorage implements MemberControlStorage {
|
|
4
|
+
private readonly directory;
|
|
5
|
+
constructor(directory: string);
|
|
6
|
+
private path;
|
|
7
|
+
read(memberId: string): MemberControlRecord | undefined;
|
|
8
|
+
write(memberId: string, record: MemberControlRecord): void;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=member-control-storage.d.ts.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const object = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
5
|
+
const revision = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
6
|
+
const optionalString = (value) => value === undefined || typeof value === 'string';
|
|
7
|
+
const choice = (value) => object(value) && (value['mode'] === 'inherit' || value['mode'] === 'default'
|
|
8
|
+
|| (value['mode'] === 'value' && typeof value['value'] === 'string' && value['value'].trim() !== ''));
|
|
9
|
+
const selection = (value) => object(value) && choice(value['model']) && choice(value['effort']);
|
|
10
|
+
const resolved = (value) => object(value) && optionalString(value['model']) && optionalString(value['effort']);
|
|
11
|
+
const applied = (value) => object(value) && revision(value['revision']) && selection(value['selection']) && resolved(value['resolved']);
|
|
12
|
+
const pending = (value) => object(value) && revision(value['revision']) && typeof value['requestId'] === 'string'
|
|
13
|
+
&& (value['kind'] === 'selection' || value['kind'] === 'cancel') && selection(value['selection']);
|
|
14
|
+
const states = new Set(['idle', 'pending', 'applying', 'reconciling', 'failed']);
|
|
15
|
+
const receipts = new Set(['pending', 'applying', 'applied', 'cancelled', 'failed', 'conflict', 'locked', 'unsupported']);
|
|
16
|
+
/** Fail closed for malformed recovery state; never silently turn it into defaults. */
|
|
17
|
+
function parseRecord(value) {
|
|
18
|
+
if (!object(value) || value['version'] !== 1 || !object(value['state']) || !object(value['requests']))
|
|
19
|
+
throw new Error('Invalid member control file');
|
|
20
|
+
const state = value['state'];
|
|
21
|
+
const operation = state['operation'];
|
|
22
|
+
const round = state['round'];
|
|
23
|
+
if (typeof state['memberId'] !== 'string' || !revision(state['revision']) || !applied(state['current'])
|
|
24
|
+
|| (state['frozen'] !== undefined && (!applied(state['frozen']) || typeof state['lockedReason'] !== 'string'))
|
|
25
|
+
|| (state['pending'] !== undefined && !pending(state['pending']))
|
|
26
|
+
|| (operation !== undefined && (!object(operation) || !pending(operation) || !applied(operation['previous'])))
|
|
27
|
+
|| (round !== undefined && (!object(round) || typeof round['id'] !== 'string' || !applied(round['configuration'])))
|
|
28
|
+
|| typeof state['status'] !== 'string' || !states.has(state['status'])
|
|
29
|
+
|| !optionalString(state['error']) || !optionalString(state['lockedReason']))
|
|
30
|
+
throw new Error('Invalid member control state');
|
|
31
|
+
for (const [id, request] of Object.entries(value['requests'])) {
|
|
32
|
+
if (!object(request) || typeof request['signature'] !== 'string' || !object(request['receipt']))
|
|
33
|
+
throw new Error('Invalid member control receipt');
|
|
34
|
+
const receipt = request['receipt'];
|
|
35
|
+
if (receipt['requestId'] !== id || !revision(receipt['revision']) || typeof receipt['status'] !== 'string'
|
|
36
|
+
|| !receipts.has(receipt['status']) || !optionalString(receipt['error']))
|
|
37
|
+
throw new Error('Invalid member control receipt');
|
|
38
|
+
}
|
|
39
|
+
return { state, requests: value['requests'] };
|
|
40
|
+
}
|
|
41
|
+
/** Per-member atomic files; acknowledgement requires a flushed file and rename. */
|
|
42
|
+
export class FileMemberControlStorage {
|
|
43
|
+
directory;
|
|
44
|
+
constructor(directory) {
|
|
45
|
+
this.directory = directory;
|
|
46
|
+
}
|
|
47
|
+
path(memberId) { return join(this.directory, createHash('sha256').update(memberId).digest('hex') + '.json'); }
|
|
48
|
+
read(memberId) {
|
|
49
|
+
let text;
|
|
50
|
+
try {
|
|
51
|
+
text = readFileSync(this.path(memberId), 'utf8');
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error.code === 'ENOENT')
|
|
55
|
+
return undefined;
|
|
56
|
+
throw new Error('Persisted member controls could not be read', { cause: error });
|
|
57
|
+
}
|
|
58
|
+
const record = parseRecord(JSON.parse(text));
|
|
59
|
+
const state = record.state;
|
|
60
|
+
if (state.memberId !== memberId || state.current.revision > state.revision
|
|
61
|
+
|| (state.frozen !== undefined && state.frozen.revision > state.revision)
|
|
62
|
+
|| (state.pending !== undefined && (state.pending.revision > state.revision || !Object.hasOwn(record.requests, state.pending.requestId)))
|
|
63
|
+
|| (state.operation !== undefined && (state.operation.revision > state.revision || !Object.hasOwn(record.requests, state.operation.requestId)))
|
|
64
|
+
|| (state.round !== undefined && state.round.configuration.revision > state.revision)) {
|
|
65
|
+
throw new Error('Persisted member controls are inconsistent; admission is blocked');
|
|
66
|
+
}
|
|
67
|
+
return { state, requests: record.requests };
|
|
68
|
+
}
|
|
69
|
+
write(memberId, record) {
|
|
70
|
+
mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
71
|
+
const path = this.path(memberId);
|
|
72
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
73
|
+
let fd;
|
|
74
|
+
try {
|
|
75
|
+
fd = openSync(temporary, 'wx', 0o600);
|
|
76
|
+
writeFileSync(fd, JSON.stringify({ version: 1, ...record }) + '\n', 'utf8');
|
|
77
|
+
fsyncSync(fd);
|
|
78
|
+
closeSync(fd);
|
|
79
|
+
fd = undefined;
|
|
80
|
+
renameSync(temporary, path);
|
|
81
|
+
// Windows does not expose directory fsync through this API. The file
|
|
82
|
+
// itself is flushed on every platform before the atomic replacement.
|
|
83
|
+
if (process.platform !== 'win32') {
|
|
84
|
+
fd = openSync(this.directory, 'r');
|
|
85
|
+
fsyncSync(fd);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
if (fd !== undefined)
|
|
90
|
+
closeSync(fd);
|
|
91
|
+
try {
|
|
92
|
+
unlinkSync(temporary);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error.code !== 'ENOENT')
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|