@sgtbeatdown/wolfpack-tasks 0.0.0-stage → 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/README.md +213 -2
- package/docs/protocol-reference.md +124 -0
- package/package.json +55 -4
- package/skills/task-context-summary/SKILL.md +34 -0
- package/skills/wolfpack-task-delegation/SKILL.md +107 -0
- package/src/abortable-sleep.ts +25 -0
- package/src/agent-task-runtime.ts +709 -0
- package/src/agent-task-tool-schemas.ts +30 -0
- package/src/claude/adapter.ts +336 -0
- package/src/claude/constants.ts +40 -0
- package/src/claude/environment.ts +54 -0
- package/src/claude/json-rpc.ts +50 -0
- package/src/claude/mcp-server.ts +144 -0
- package/src/claude/server.ts +37 -0
- package/src/claude/status-file.ts +66 -0
- package/src/claude/validation.ts +28 -0
- package/src/claude/wake.ts +215 -0
- package/src/claude/worker-gate-hook.ts +26 -0
- package/src/claude/worker-gate.ts +260 -0
- package/src/configured-task-core.ts +76 -0
- package/src/extension.ts +51 -0
- package/src/in-memory-task-relay.ts +115 -0
- package/src/index.ts +35 -0
- package/src/task-core.ts +925 -0
- package/src/task-errors.ts +111 -0
- package/src/task-inbox.ts +292 -0
- package/src/task-protocol.ts +243 -0
- package/src/task-relay-conformance.ts +33 -0
- package/src/task-store.ts +342 -0
- package/src/task-work-scope.ts +98 -0
- package/src/task-worker-mode.ts +9 -0
- package/src/volatile-task-session.ts +310 -0
- package/src/wolfpack-endpoint-view.ts +46 -0
- package/src/wolfpack-local-auth.ts +20 -0
- package/src/wolfpack-task-relay.ts +39 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
/** Same JSON as `StringEnum` from pi-ai without options; kept local so non-pi hosts need no pi packages. */
|
|
4
|
+
function stringEnum<const T extends readonly string[]>(values: T) {
|
|
5
|
+
return Type.Unsafe<T[number]>({ type: "string", enum: values });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const MIN_TASK_TIMEOUT_MS = 1_000;
|
|
9
|
+
export const MAX_TASK_TIMEOUT_MS = 86_400_000;
|
|
10
|
+
export const SUMMARY_MAX_CHARS = 1_200;
|
|
11
|
+
|
|
12
|
+
const EndpointParams = Type.Object({
|
|
13
|
+
relay: Type.String({ minLength: 1, description: "relay identifier" }),
|
|
14
|
+
id: Type.String({ minLength: 1, description: "opaque endpoint identifier" }),
|
|
15
|
+
});
|
|
16
|
+
export const SendParams = Type.Object({
|
|
17
|
+
to: EndpointParams,
|
|
18
|
+
task: Type.String({ minLength: 1, maxLength: 16 * 1024 }),
|
|
19
|
+
timeoutMs: Type.Optional(Type.Integer({ minimum: MIN_TASK_TIMEOUT_MS, maximum: MAX_TASK_TIMEOUT_MS })),
|
|
20
|
+
});
|
|
21
|
+
export const TaskIdParams = Type.Object({ taskId: Type.String({ minLength: 1 }) });
|
|
22
|
+
export const WaitParams = Type.Object({ taskId: Type.String({ minLength: 1 }), timeoutMs: Type.Optional(Type.Integer({ minimum: MIN_TASK_TIMEOUT_MS, maximum: MAX_TASK_TIMEOUT_MS })) });
|
|
23
|
+
export const InboxParams = Type.Object({});
|
|
24
|
+
export const MessageParams = Type.Object({ taskId: Type.String({ minLength: 1 }), type: stringEnum(["question", "answer", "information"] as const), message: Type.String({ minLength: 1, maxLength: 16 * 1024 }) });
|
|
25
|
+
export const DoneParams = Type.Object({
|
|
26
|
+
taskId: Type.String({ minLength: 1 }), status: stringEnum(["completed", "failed", "cancelled"] as const), summary: Type.String({ minLength: 1, maxLength: SUMMARY_MAX_CHARS }),
|
|
27
|
+
result: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
28
|
+
error: Type.Optional(Type.Object({ code: Type.String(), message: Type.String(), retryable: Type.Boolean() })),
|
|
29
|
+
artifacts: Type.Optional(Type.Array(Type.Object({ path: Type.String({ minLength: 1, description: "artifact paths are receiver-project-relative regular files" }), mimeType: Type.Optional(Type.String()), description: Type.Optional(Type.String()) }))),
|
|
30
|
+
});
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { createAgentTaskRuntime, WORKER_GATE_DENIAL_CODE } from "../agent-task-runtime";
|
|
2
|
+
import type { AgentTaskHost, AgentTaskRuntime, AgentTaskRuntimeContext, AgentTaskToolDefinition, AgentTaskToolResult, ConfiguredCoreFactory } from "../agent-task-runtime";
|
|
3
|
+
import { createConfiguredTaskCore } from "../configured-task-core";
|
|
4
|
+
import type { TaskCore } from "../task-core";
|
|
5
|
+
import { TaskEnvelopeKind, TaskProtocolError } from "../task-protocol";
|
|
6
|
+
import type { TaskInboxItem } from "../task-core";
|
|
7
|
+
import { CLAUDE_PLUGIN, HARNESS_PRE_ASSIGNMENT_TOOLS } from "./constants";
|
|
8
|
+
import type { ClaudeEnvironment } from "./environment";
|
|
9
|
+
import { isRecord } from "./json-rpc";
|
|
10
|
+
import { createStatusFile } from "./status-file";
|
|
11
|
+
import type { ClaudeStatusFile } from "./status-file";
|
|
12
|
+
import { validateToolArguments } from "./validation";
|
|
13
|
+
import { createWakeController } from "./wake";
|
|
14
|
+
import { listenWorkerGate, WORKER_GATE_ANSWER_WAIT_MS } from "./worker-gate";
|
|
15
|
+
import type { WorkerGateAnswer, WorkerGateListener } from "./worker-gate";
|
|
16
|
+
import type { CommandRunner, WakeController, WakeTimers, WakeTiming } from "./wake";
|
|
17
|
+
|
|
18
|
+
export interface McpToolResult {
|
|
19
|
+
readonly content: readonly { readonly type: "text"; readonly text: string }[];
|
|
20
|
+
readonly structuredContent?: Record<string, unknown>;
|
|
21
|
+
readonly isError?: true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface McpToolListing {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly title: string;
|
|
27
|
+
readonly description: string;
|
|
28
|
+
readonly inputSchema: unknown;
|
|
29
|
+
readonly _meta?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ClaudeTaskAdapterOptions {
|
|
33
|
+
readonly environment: ClaudeEnvironment;
|
|
34
|
+
/** Test seam: a fixed core instead of the configured Wolfpack transport. */
|
|
35
|
+
readonly core?: TaskCore;
|
|
36
|
+
readonly createCore?: ConfiguredCoreFactory;
|
|
37
|
+
sendChannel(meta: Readonly<Record<string, string>>): void;
|
|
38
|
+
readonly runCommand: CommandRunner;
|
|
39
|
+
readonly timers: WakeTimers;
|
|
40
|
+
readonly timing?: WakeTiming;
|
|
41
|
+
readonly log: (message: string) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ClaudeTaskAdapter {
|
|
45
|
+
readonly tools: readonly McpToolListing[];
|
|
46
|
+
/** Channel capability is declared only for a ready Wolfpack session. */
|
|
47
|
+
readonly channel: boolean;
|
|
48
|
+
start(protocol: { readonly protocolVersion: string; readonly clientProtocolVersion: string | null }): Promise<void>;
|
|
49
|
+
callTool(name: string, args: unknown, signal: AbortSignal): Promise<McpToolResult>;
|
|
50
|
+
/** Hook endpoint: returns hook output text, empty for no decision. */
|
|
51
|
+
hook(input: unknown): Promise<string>;
|
|
52
|
+
shutdown(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const INBOX_TOOL = "agent_task_inbox";
|
|
56
|
+
/** Any native tool name: the runtime gates every non-task tool alike, so this probe stands for all of them. */
|
|
57
|
+
const GATE_PROBE_TOOL = "Bash";
|
|
58
|
+
/** Events the plugin's hooks/hooks.json sends to the lifecycle tool. */
|
|
59
|
+
export const LIFECYCLE_HOOK_EVENTS = Object.freeze(["prompt", "pre_tool", "tool_batch", "stop", "stop_failure"] as const);
|
|
60
|
+
const LIFECYCLE_HOOK_FIELDS = Object.freeze(["session_id", "tool_name", "agent_id"] as const);
|
|
61
|
+
|
|
62
|
+
export function createClaudeTaskAdapter(options: ClaudeTaskAdapterOptions): ClaudeTaskAdapter {
|
|
63
|
+
const environment = options.environment;
|
|
64
|
+
if (environment.kind !== "ready") return inactiveAdapter(environment);
|
|
65
|
+
|
|
66
|
+
const entries: unknown[] = [];
|
|
67
|
+
const sessionManager = { getEntries: (): readonly unknown[] => entries };
|
|
68
|
+
/** Model-visible messages inserted since the last inbox result. */
|
|
69
|
+
const pendingMessages: string[] = [];
|
|
70
|
+
/**
|
|
71
|
+
* Non-triggering records (relay-loss notes) inserted outside a presentation. They wait for the next inbox
|
|
72
|
+
* result, request no pointer, and never count as pending, so they cannot hold back later triggering mail.
|
|
73
|
+
*/
|
|
74
|
+
const deferredMessages: string[] = [];
|
|
75
|
+
const notes: string[] = [];
|
|
76
|
+
let presenting = false;
|
|
77
|
+
let lastIntake: readonly TaskInboxItem[] = [];
|
|
78
|
+
let status: ClaudeStatusFile | undefined;
|
|
79
|
+
let wake: WakeController | undefined;
|
|
80
|
+
let started = false;
|
|
81
|
+
let closed = false;
|
|
82
|
+
|
|
83
|
+
const pointerMeta = (details?: unknown): Record<string, string> => {
|
|
84
|
+
if (isRecord(details) && typeof details.taskId === "string") return { task_id: details.taskId, ...(typeof details.eventId === "string" && { event_id: details.eventId }) };
|
|
85
|
+
// Hint only: the first model-facing item of the latest intake.
|
|
86
|
+
for (const item of lastIntake) {
|
|
87
|
+
if (item.kind === "local") return { task_id: item.event.taskId, event_id: item.event.eventId };
|
|
88
|
+
if (item.delivery.envelope.kind !== TaskEnvelopeKind.intent) return { task_id: item.delivery.envelope.taskId };
|
|
89
|
+
}
|
|
90
|
+
return {};
|
|
91
|
+
};
|
|
92
|
+
const requestPointer = (details?: unknown): void => { if (!presenting) wake?.requestPointer(pointerMeta(details)); };
|
|
93
|
+
|
|
94
|
+
// Pi entry shapes, so the shared evidence index (incorporation, wakes, records) reads them unchanged.
|
|
95
|
+
const host: AgentTaskHost = {
|
|
96
|
+
sendMessage(message, delivery) {
|
|
97
|
+
entries.push({ type: "custom_message", customType: message.customType, content: message.content, display: message.display, details: message.details });
|
|
98
|
+
if (delivery.triggerTurn) requestPointer(message.details);
|
|
99
|
+
else if (presenting) pendingMessages.push(message.content);
|
|
100
|
+
else deferredMessages.push(message.content);
|
|
101
|
+
},
|
|
102
|
+
appendEntry(customType, data) { entries.push({ type: "custom", customType, data }); },
|
|
103
|
+
};
|
|
104
|
+
// Background refreshes are never idle: they hold model-visible mail and point at it. Only an inbox call presents.
|
|
105
|
+
const context: AgentTaskRuntimeContext = {
|
|
106
|
+
isIdle(): boolean {
|
|
107
|
+
if (presenting) return true;
|
|
108
|
+
requestPointer();
|
|
109
|
+
return false;
|
|
110
|
+
},
|
|
111
|
+
hasPendingMessages: (): boolean => !presenting && pendingMessages.length > 0,
|
|
112
|
+
sessionManager,
|
|
113
|
+
ui: {
|
|
114
|
+
setStatus(_key, text) { status?.update({ lastStatus: text ?? null }); },
|
|
115
|
+
notify(message, type = "info") { notes.push(`${type}: ${message}`); },
|
|
116
|
+
theme: { fg: (_color, text) => text },
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
const observeIntake = (core: TaskCore): TaskCore => ({
|
|
120
|
+
...core,
|
|
121
|
+
async intakeInbox(signal, work) {
|
|
122
|
+
const items = await core.intakeInbox(signal, work);
|
|
123
|
+
lastIntake = items;
|
|
124
|
+
return items;
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const createCore: ConfiguredCoreFactory = options.createCore ?? ((signal, rebind) => createConfiguredTaskCore({ sessionName: environment.sessionName, rebind }, signal));
|
|
128
|
+
const runtime = options.core
|
|
129
|
+
? createAgentTaskRuntime(host, observeIntake(options.core))
|
|
130
|
+
: createAgentTaskRuntime(host, undefined, async (signal, rebind) => observeIntake(await createCore(signal, rebind)) as Awaited<ReturnType<ConfiguredCoreFactory>>);
|
|
131
|
+
const tools = new Map(runtime.tools.map((tool) => [tool.name, tool]));
|
|
132
|
+
|
|
133
|
+
// Worker mode only: the gate decision that the fail-closed PreToolUse command hook asks for over the gate socket
|
|
134
|
+
// (see worker-gate.ts). It lives only in this process, so no answer can come from a previous server lifetime.
|
|
135
|
+
const gateEnabled = runtime.workerGateEnabled;
|
|
136
|
+
const GATE_STOPPED: WorkerGateAnswer = { authorized: false, reason: `${WORKER_GATE_DENIAL_CODE}: the wolfpack-tasks MCP server stopped` };
|
|
137
|
+
let gateAnswer: WorkerGateAnswer = { authorized: false, reason: `${WORKER_GATE_DENIAL_CODE}: the worker gate has not been evaluated` };
|
|
138
|
+
let gateListener: WorkerGateListener | undefined;
|
|
139
|
+
let gateQueue: Promise<void> = Promise.resolve();
|
|
140
|
+
let queuedGateRefresh: Promise<void> | undefined;
|
|
141
|
+
/**
|
|
142
|
+
* Re-evaluates the gate. Serialized, so an older evaluation never overwrites a newer one. A refresh that has not
|
|
143
|
+
* started yet is shared: it still runs after every earlier change, and a hung evaluation cannot grow the queue.
|
|
144
|
+
*/
|
|
145
|
+
const refreshGate = (): Promise<void> => {
|
|
146
|
+
if (!gateEnabled) return gateQueue;
|
|
147
|
+
if (queuedGateRefresh) return queuedGateRefresh;
|
|
148
|
+
const run = gateQueue.then(async () => {
|
|
149
|
+
queuedGateRefresh = undefined;
|
|
150
|
+
if (closed) return;
|
|
151
|
+
let decision: { readonly block: boolean; readonly reason: string } | undefined;
|
|
152
|
+
try {
|
|
153
|
+
decision = await runtime.lifecycle.tool_call({ toolName: GATE_PROBE_TOOL, input: {} }, { sessionManager });
|
|
154
|
+
} catch (error) {
|
|
155
|
+
decision = { block: true, reason: error instanceof Error ? error.message : String(error) };
|
|
156
|
+
}
|
|
157
|
+
gateAnswer = decision?.block ? { authorized: false, reason: decision.reason } : { authorized: true, reason: null };
|
|
158
|
+
});
|
|
159
|
+
queuedGateRefresh = run;
|
|
160
|
+
gateQueue = run;
|
|
161
|
+
return run;
|
|
162
|
+
};
|
|
163
|
+
/** Answers one hook request: a fresh evaluation when it completes in time, otherwise the latest one. */
|
|
164
|
+
const answerGate = async (): Promise<WorkerGateAnswer> => {
|
|
165
|
+
if (closed) return GATE_STOPPED;
|
|
166
|
+
let cancelWait: (() => void) | undefined;
|
|
167
|
+
await Promise.race([refreshGate(), new Promise<void>((resolve) => { cancelWait = options.timers.setTimeout(resolve, WORKER_GATE_ANSWER_WAIT_MS); })]);
|
|
168
|
+
cancelWait?.();
|
|
169
|
+
return closed ? GATE_STOPPED : gateAnswer;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const executeTool = async (name: string, args: unknown, signal: AbortSignal): Promise<McpToolResult> => {
|
|
173
|
+
const tool = tools.get(name);
|
|
174
|
+
if (!tool) return errorResult(`unknown tool ${JSON.stringify(name.slice(0, 64))}`);
|
|
175
|
+
let params: unknown;
|
|
176
|
+
try { params = validateToolArguments(name, tool.parameters, args ?? {}); } catch (error) { return errorResult(error instanceof Error ? error.message : String(error)); }
|
|
177
|
+
// A model tool call means the session is working, even if the activity hooks are unavailable.
|
|
178
|
+
wake?.activity("busy");
|
|
179
|
+
const decision = await runtime.lifecycle.tool_call({ toolName: name, input: params }, { sessionManager, signal });
|
|
180
|
+
if (decision?.block) return errorResult(decision.reason);
|
|
181
|
+
try {
|
|
182
|
+
if (name === INBOX_TOOL) return await runInbox(tool, params, signal);
|
|
183
|
+
return mcpResult(await tool.execute(name, params as never, signal, undefined, context), []);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
let inboxQueue: Promise<unknown> = Promise.resolve();
|
|
190
|
+
const runInbox = (tool: AgentTaskToolDefinition, params: unknown, signal: AbortSignal): Promise<McpToolResult> => {
|
|
191
|
+
const run = inboxQueue.then(async (): Promise<McpToolResult> => {
|
|
192
|
+
const pointed = wake?.pointerOutstanding === true;
|
|
193
|
+
wake?.inboxCalled();
|
|
194
|
+
presenting = true;
|
|
195
|
+
try {
|
|
196
|
+
let result = await tool.execute("inbox", params as never, signal, undefined, context);
|
|
197
|
+
// A refresh already in flight may have held the pointed mail before presentation began; one more pass presents it.
|
|
198
|
+
if (pointed && pendingMessages.length === 0 && !signal.aborted) result = await tool.execute("inbox", params as never, signal, undefined, context);
|
|
199
|
+
const delivered = [...deferredMessages.splice(0), ...pendingMessages.splice(0)];
|
|
200
|
+
const noted = notes.splice(0);
|
|
201
|
+
return mcpResult(result, [...delivered, ...noted]);
|
|
202
|
+
} finally {
|
|
203
|
+
presenting = false;
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
inboxQueue = run.catch(() => undefined);
|
|
207
|
+
return run;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
tools: listing(runtime, true),
|
|
212
|
+
channel: true,
|
|
213
|
+
async start(protocol) {
|
|
214
|
+
if (started || closed) return;
|
|
215
|
+
started = true;
|
|
216
|
+
const now = options.timers.now();
|
|
217
|
+
status = createStatusFile(environment.statusPath, {
|
|
218
|
+
sessionId: environment.sessionId, sessionName: environment.sessionName, pid: process.pid, wake: "channel",
|
|
219
|
+
protocolVersion: protocol.protocolVersion, clientProtocolVersion: protocol.clientProtocolVersion, startedAt: now,
|
|
220
|
+
lastPointerAt: null, lastInboxAt: null, lastNudgeAt: null, fallbackAt: null, lastNudgeError: null, statusFailures: 0, wakeStalledAt: null, lastStatus: null,
|
|
221
|
+
}, options.timers.now, options.log);
|
|
222
|
+
wake = createWakeController({
|
|
223
|
+
sessionId: environment.sessionId,
|
|
224
|
+
sendChannel: options.sendChannel,
|
|
225
|
+
runCommand: options.runCommand,
|
|
226
|
+
timers: options.timers,
|
|
227
|
+
...(options.timing && { timing: options.timing }),
|
|
228
|
+
onChange: (patch) => status?.update(patch),
|
|
229
|
+
});
|
|
230
|
+
await runtime.lifecycle.session_start({}, context);
|
|
231
|
+
if (gateEnabled && !closed) {
|
|
232
|
+
await refreshGate();
|
|
233
|
+
const listener = await listenWorkerGate({ path: environment.gateSocketPath, sessionId: environment.sessionId, answer: answerGate, log: options.log });
|
|
234
|
+
// A shutdown during listen did not see this listener.
|
|
235
|
+
if (closed) await listener?.close();
|
|
236
|
+
else gateListener = listener;
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
async callTool(name, args, signal) {
|
|
240
|
+
const result = await executeTool(name, args, signal);
|
|
241
|
+
// The next native tool call must see the authorization this result granted or revoked.
|
|
242
|
+
await refreshGate();
|
|
243
|
+
return result;
|
|
244
|
+
},
|
|
245
|
+
async hook(input) {
|
|
246
|
+
if (!isRecord(input) || closed) return "";
|
|
247
|
+
switch (input.event) {
|
|
248
|
+
case "prompt":
|
|
249
|
+
wake?.activity("busy");
|
|
250
|
+
return "";
|
|
251
|
+
case "pre_tool": {
|
|
252
|
+
wake?.activity("busy");
|
|
253
|
+
const toolName = typeof input.tool_name === "string" ? input.tool_name : "";
|
|
254
|
+
// Own tools are gated inside tools/call with their validated input.
|
|
255
|
+
if (!runtime.workerGateEnabled || toolName.startsWith(CLAUDE_PLUGIN.toolPrefix) || HARNESS_PRE_ASSIGNMENT_TOOLS.has(toolName)) return "";
|
|
256
|
+
const decision = await runtime.lifecycle.tool_call({ toolName, input: {} }, { sessionManager });
|
|
257
|
+
return decision?.block ? JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: decision.reason } }) : "";
|
|
258
|
+
}
|
|
259
|
+
case "tool_batch":
|
|
260
|
+
void runtime.lifecycle.turn_end({}, context);
|
|
261
|
+
return "";
|
|
262
|
+
case "stop":
|
|
263
|
+
case "stop_failure":
|
|
264
|
+
wake?.activity("idle");
|
|
265
|
+
if (started) void runtime.lifecycle.agent_end({}, context);
|
|
266
|
+
return "";
|
|
267
|
+
default:
|
|
268
|
+
return "";
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
async shutdown() {
|
|
272
|
+
if (closed) return;
|
|
273
|
+
closed = true;
|
|
274
|
+
wake?.close();
|
|
275
|
+
await gateListener?.close();
|
|
276
|
+
gateListener = undefined;
|
|
277
|
+
await runtime.lifecycle.session_shutdown({}, context);
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** No identity or a misconfiguration: the same tool list, no runtime start, no contact, no files. */
|
|
283
|
+
function inactiveAdapter(environment: Exclude<ClaudeEnvironment, { kind: "ready" }>): ClaudeTaskAdapter {
|
|
284
|
+
const refuse = async (): Promise<never> => { throw new TaskProtocolError("RELAY_UNAVAILABLE", "task runtime is inactive", { retryable: false }); };
|
|
285
|
+
const inert: AgentTaskHost = { sendMessage() { /* inactive */ }, appendEntry() { /* inactive */ } };
|
|
286
|
+
const runtime = createAgentTaskRuntime(inert, undefined, refuse);
|
|
287
|
+
const message = environment.kind === "inactive"
|
|
288
|
+
? "not a wolfpack session: WOLFPACK_SESSION_ID and WOLFPACK_SESSION_NAME are required; task tools are unavailable"
|
|
289
|
+
: `wolfpack tasks misconfigured: ${environment.variable} ${environment.reason}`;
|
|
290
|
+
return {
|
|
291
|
+
tools: listing(runtime, false),
|
|
292
|
+
channel: false,
|
|
293
|
+
async start() { /* inactive */ },
|
|
294
|
+
async callTool() { return errorResult(message); },
|
|
295
|
+
async hook() { return ""; },
|
|
296
|
+
async shutdown() { /* inactive */ },
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function listing(runtime: AgentTaskRuntime, active: boolean): McpToolListing[] {
|
|
301
|
+
const tools: McpToolListing[] = runtime.tools.map((tool) => ({
|
|
302
|
+
name: tool.name,
|
|
303
|
+
title: tool.label,
|
|
304
|
+
description: tool.description,
|
|
305
|
+
inputSchema: JSON.parse(JSON.stringify(tool.parameters)) as unknown,
|
|
306
|
+
// The pointer names agent_task_inbox, so it must not be deferred behind tool search in a live session.
|
|
307
|
+
...(active && tool.name === INBOX_TOOL && { _meta: { "anthropic/alwaysLoad": true } }),
|
|
308
|
+
}));
|
|
309
|
+
tools.push({
|
|
310
|
+
name: CLAUDE_PLUGIN.lifecycleTool,
|
|
311
|
+
title: "Task lifecycle hook endpoint",
|
|
312
|
+
description: "Internal endpoint for this plugin's Claude Code hooks. Model calls are rejected.",
|
|
313
|
+
inputSchema: {
|
|
314
|
+
type: "object",
|
|
315
|
+
additionalProperties: false,
|
|
316
|
+
required: ["event"],
|
|
317
|
+
properties: { event: { type: "string", enum: [...LIFECYCLE_HOOK_EVENTS] }, ...Object.fromEntries(LIFECYCLE_HOOK_FIELDS.map((key) => [key, { type: "string", maxLength: 256 }])) },
|
|
318
|
+
},
|
|
319
|
+
_meta: { "anthropic/alwaysLoad": false },
|
|
320
|
+
});
|
|
321
|
+
return tools;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function mcpResult(result: AgentTaskToolResult, delivered: readonly string[]): McpToolResult {
|
|
325
|
+
const details = isRecord(result.details) ? result.details : { value: result.details };
|
|
326
|
+
const isError = isRecord(details.error);
|
|
327
|
+
return {
|
|
328
|
+
content: [...delivered, ...result.content.map((part) => part.text)].map((text) => ({ type: "text" as const, text })),
|
|
329
|
+
structuredContent: delivered.length > 0 ? { ...details, delivered } : details,
|
|
330
|
+
...(isError && { isError: true as const }),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function errorResult(text: string): McpToolResult {
|
|
335
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
336
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every Claude Code name the adapter, plugin manifests, hooks and tests agree on.
|
|
3
|
+
* Hook server names follow Claude Code's `plugin:<plugin>:<server>`; tool names follow `mcp__plugin_<plugin>_<server>__<tool>`.
|
|
4
|
+
*/
|
|
5
|
+
export const CLAUDE_PLUGIN = Object.freeze({
|
|
6
|
+
marketplace: "wolfpack-tasks",
|
|
7
|
+
plugin: "wolfpack-tasks",
|
|
8
|
+
/** Marketplace-relative plugin root. */
|
|
9
|
+
pluginSource: "./claude-plugin",
|
|
10
|
+
/** `mcpServers` key in the plugin manifest. */
|
|
11
|
+
serverKey: "tasks",
|
|
12
|
+
serverName: "wolfpack-tasks",
|
|
13
|
+
hookServer: "plugin:wolfpack-tasks:tasks",
|
|
14
|
+
toolPrefix: "mcp__plugin_wolfpack-tasks_tasks__",
|
|
15
|
+
/** Hook-only tool; model calls are rejected. */
|
|
16
|
+
lifecycleTool: "lifecycle",
|
|
17
|
+
skill: "wolfpack-task-delegation",
|
|
18
|
+
/** Plugin-root-relative bundle path. */
|
|
19
|
+
bundle: "dist/server.mjs",
|
|
20
|
+
/** Plugin-root-relative path of the fail-closed `PreToolUse` command hook script. */
|
|
21
|
+
gateBundle: "dist/worker-gate.mjs",
|
|
22
|
+
channelMethod: "notifications/claude/channel",
|
|
23
|
+
channelCapability: "claude/channel",
|
|
24
|
+
/** The only channel content; task payloads are returned by agent_task_inbox, never pushed. */
|
|
25
|
+
pointer: "wolfpack task mail: call agent_task_inbox",
|
|
26
|
+
/** Claude Code `_meta` key on model tool calls; hook-originated calls do not carry it. */
|
|
27
|
+
toolUseMeta: "claudecode/toolUseId",
|
|
28
|
+
/** Client protocol versions below this date are echoed; others are answered with the fallback version. */
|
|
29
|
+
protocolCeiling: "2026-07-28",
|
|
30
|
+
fallbackProtocolVersion: "2025-11-25",
|
|
31
|
+
/** State-directory-relative status file directory. */
|
|
32
|
+
statusDirectory: "wolfpack-tasks/claude",
|
|
33
|
+
/** The worker gate socket lives in this mode-0700 subdirectory of the status directory as `<hash>.sock`. */
|
|
34
|
+
gateSocketDirectory: "gate",
|
|
35
|
+
/** Hex characters of the session id's SHA-256 in the gate socket name. */
|
|
36
|
+
gateSocketHashChars: 16,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** Claude Code harness tools a worker may use before its assignment: ToolSearch loads the deferred task tools. */
|
|
40
|
+
export const HARNESS_PRE_ASSIGNMENT_TOOLS: ReadonlySet<string> = new Set(["ToolSearch"]);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { CLAUDE_PLUGIN } from "./constants";
|
|
5
|
+
|
|
6
|
+
/** Default local Wolfpack control port, the same default the configured task core uses. */
|
|
7
|
+
export const DEFAULT_WOLFPACK_PORT = "18790";
|
|
8
|
+
const JWT_SECRET_MIN_CHARS = 32;
|
|
9
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
10
|
+
|
|
11
|
+
export type ClaudeEnvironment =
|
|
12
|
+
/** Not a Wolfpack session: no registration, no relay or CLI contact, no files, no output. */
|
|
13
|
+
| { readonly kind: "inactive" }
|
|
14
|
+
/** Wolfpack identity is present but a required value is missing or invalid. */
|
|
15
|
+
| { readonly kind: "misconfigured"; readonly variable: string; readonly reason: string }
|
|
16
|
+
| { readonly kind: "ready"; readonly sessionId: string; readonly sessionName: string; readonly port: string; readonly stateDir: string; readonly statusPath: string; readonly gateSocketPath: string };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The identity gate is exactly WOLFPACK_SESSION_ID plus WOLFPACK_SESSION_NAME. With identity present, the values the
|
|
20
|
+
* configured core reads later are validated up front so a misconfiguration is named instead of failing per call.
|
|
21
|
+
*/
|
|
22
|
+
export function readClaudeEnvironment(env: Readonly<Record<string, string | undefined>>): ClaudeEnvironment {
|
|
23
|
+
const sessionId = env.WOLFPACK_SESSION_ID?.trim();
|
|
24
|
+
const sessionName = env.WOLFPACK_SESSION_NAME?.trim();
|
|
25
|
+
if (!sessionId || !sessionName) return { kind: "inactive" };
|
|
26
|
+
if (!SESSION_ID_PATTERN.test(sessionId)) return misconfigured("WOLFPACK_SESSION_ID", "must be a Wolfpack session id (letters, digits, '.', '_' or '-')");
|
|
27
|
+
const port = env.WOLFPACK_PORT ?? DEFAULT_WOLFPACK_PORT;
|
|
28
|
+
if (!/^[0-9]+$/.test(port) || Number(port) < 1 || Number(port) > 65535) return misconfigured("WOLFPACK_PORT", "must be a TCP port from 1 to 65535");
|
|
29
|
+
const secret = env.WOLFPACK_JWT_SECRET?.trim();
|
|
30
|
+
if (secret && secret.length < JWT_SECRET_MIN_CHARS) return misconfigured("WOLFPACK_JWT_SECRET", `must contain at least ${JWT_SECRET_MIN_CHARS} characters when set`);
|
|
31
|
+
let stateDir: string;
|
|
32
|
+
if (env.WOLFPACK_DIR !== undefined) {
|
|
33
|
+
if (!isAbsolute(env.WOLFPACK_DIR)) return misconfigured("WOLFPACK_DIR", "must be an absolute path when set");
|
|
34
|
+
stateDir = env.WOLFPACK_DIR;
|
|
35
|
+
} else {
|
|
36
|
+
if (!env.HOME || !isAbsolute(env.HOME)) return misconfigured("HOME", "must be an absolute path when WOLFPACK_DIR is unset");
|
|
37
|
+
stateDir = join(env.HOME, ".wolfpack");
|
|
38
|
+
}
|
|
39
|
+
const directory = join(stateDir, CLAUDE_PLUGIN.statusDirectory);
|
|
40
|
+
return { kind: "ready", sessionId, sessionName, port, stateDir, statusPath: join(directory, `${sessionId}.json`), gateSocketPath: gateSocketPath(directory, sessionId) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The worker gate socket of a session. A session id can be 128 characters, so the file name is a short hash of it: unix
|
|
45
|
+
* socket paths are limited to 103 bytes on macOS. A longer path is refused by both the server and the hook.
|
|
46
|
+
*/
|
|
47
|
+
function gateSocketPath(directory: string, sessionId: string): string {
|
|
48
|
+
const name = createHash("sha256").update(sessionId).digest("hex").slice(0, CLAUDE_PLUGIN.gateSocketHashChars);
|
|
49
|
+
return join(directory, CLAUDE_PLUGIN.gateSocketDirectory, `${name}.sock`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function misconfigured(variable: string, reason: string): ClaudeEnvironment {
|
|
53
|
+
return { kind: "misconfigured", variable, reason };
|
|
54
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Minimal JSON-RPC 2.0 shapes for newline-delimited MCP stdio. */
|
|
2
|
+
export type JsonRpcId = string | number;
|
|
3
|
+
|
|
4
|
+
export type JsonRpcMessage =
|
|
5
|
+
| { readonly jsonrpc: "2.0"; readonly id: JsonRpcId; readonly method: string; readonly params?: unknown }
|
|
6
|
+
| { readonly jsonrpc: "2.0"; readonly method: string; readonly params?: unknown }
|
|
7
|
+
| { readonly jsonrpc: "2.0"; readonly id: JsonRpcId; readonly result: unknown }
|
|
8
|
+
| { readonly jsonrpc: "2.0"; readonly id: JsonRpcId | null; readonly error: JsonRpcError };
|
|
9
|
+
|
|
10
|
+
export interface JsonRpcError {
|
|
11
|
+
readonly code: number;
|
|
12
|
+
readonly message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const JSON_RPC_ERROR = Object.freeze({
|
|
16
|
+
parse: -32700,
|
|
17
|
+
invalidRequest: -32600,
|
|
18
|
+
methodNotFound: -32601,
|
|
19
|
+
invalidParams: -32602,
|
|
20
|
+
internal: -32603,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export interface JsonRpcRequest {
|
|
24
|
+
readonly id?: JsonRpcId;
|
|
25
|
+
readonly method: string;
|
|
26
|
+
readonly params?: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Returns the request or notification, or the error to answer. Responses from the client are ignored (undefined). */
|
|
30
|
+
export function parseJsonRpc(line: string): JsonRpcRequest | JsonRpcError | undefined {
|
|
31
|
+
let value: unknown;
|
|
32
|
+
try {
|
|
33
|
+
value = JSON.parse(line);
|
|
34
|
+
} catch {
|
|
35
|
+
return { code: JSON_RPC_ERROR.parse, message: "parse error" };
|
|
36
|
+
}
|
|
37
|
+
if (!isRecord(value) || value.jsonrpc !== "2.0") return { code: JSON_RPC_ERROR.invalidRequest, message: "invalid request" };
|
|
38
|
+
const id = value.id;
|
|
39
|
+
if (id !== undefined && typeof id !== "string" && typeof id !== "number") return { code: JSON_RPC_ERROR.invalidRequest, message: "invalid request id" };
|
|
40
|
+
if (typeof value.method !== "string") return "result" in value || "error" in value ? undefined : { code: JSON_RPC_ERROR.invalidRequest, message: "invalid request" };
|
|
41
|
+
return { ...(id !== undefined && { id }), method: value.method, ...(value.params !== undefined && { params: value.params }) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isJsonRpcError(value: JsonRpcRequest | JsonRpcError): value is JsonRpcError {
|
|
45
|
+
return "code" in value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
50
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { ConfiguredCoreFactory } from "../agent-task-runtime";
|
|
2
|
+
import type { TaskCore } from "../task-core";
|
|
3
|
+
import { createClaudeTaskAdapter } from "./adapter";
|
|
4
|
+
import type { ClaudeTaskAdapter, McpToolResult } from "./adapter";
|
|
5
|
+
import { CLAUDE_PLUGIN } from "./constants";
|
|
6
|
+
import { readClaudeEnvironment } from "./environment";
|
|
7
|
+
import { isJsonRpcError, isRecord, JSON_RPC_ERROR, parseJsonRpc } from "./json-rpc";
|
|
8
|
+
import type { JsonRpcError, JsonRpcId, JsonRpcMessage, JsonRpcRequest } from "./json-rpc";
|
|
9
|
+
import type { CommandRunner, WakeTimers, WakeTiming } from "./wake";
|
|
10
|
+
|
|
11
|
+
export const SERVER_VERSION = "0.2.0";
|
|
12
|
+
|
|
13
|
+
export const SERVER_INSTRUCTIONS = [
|
|
14
|
+
"Wolfpack task delegation tools (agent_task_*), the same tools and semantics as the pi extension.",
|
|
15
|
+
`A <channel source="${CLAUDE_PLUGIN.serverName}"> message is a wolfpack task notification. It carries only a pointer, never the task content.`,
|
|
16
|
+
"When one arrives, call agent_task_inbox; the inbox result contains the delivered task messages and any assignment.",
|
|
17
|
+
"The same pointer may also be typed into the prompt as a fallback when the channel does not wake the session; treat it the same way.",
|
|
18
|
+
].join("\n");
|
|
19
|
+
|
|
20
|
+
export interface ClaudeTaskServerOptions {
|
|
21
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
22
|
+
send(message: JsonRpcMessage): void;
|
|
23
|
+
/** Test seam: a fixed task core instead of the configured Wolfpack transport. */
|
|
24
|
+
readonly core?: TaskCore;
|
|
25
|
+
readonly createCore?: ConfiguredCoreFactory;
|
|
26
|
+
readonly runCommand?: CommandRunner;
|
|
27
|
+
readonly timers?: WakeTimers;
|
|
28
|
+
readonly timing?: WakeTiming;
|
|
29
|
+
readonly log?: (message: string) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ClaudeTaskServer {
|
|
33
|
+
handle(message: unknown): Promise<void>;
|
|
34
|
+
handleLine(line: string): Promise<void>;
|
|
35
|
+
shutdown(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Echo the client's protocol revision below the ceiling; answer the pinned revision otherwise. ISO dates compare lexically. */
|
|
39
|
+
export function negotiateProtocolVersion(proposed: unknown): string {
|
|
40
|
+
return typeof proposed === "string" && /^\d{4}-\d{2}-\d{2}$/.test(proposed) && proposed < CLAUDE_PLUGIN.protocolCeiling ? proposed : CLAUDE_PLUGIN.fallbackProtocolVersion;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const realTimers: WakeTimers = {
|
|
44
|
+
now: () => Date.now(),
|
|
45
|
+
setTimeout(fire, ms) {
|
|
46
|
+
const timer = setTimeout(fire, ms);
|
|
47
|
+
timer.unref?.();
|
|
48
|
+
return () => clearTimeout(timer);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const noCommands: CommandRunner = async () => { throw new Error("no command runner configured"); };
|
|
53
|
+
|
|
54
|
+
/** Hand-rolled MCP over newline-delimited JSON-RPC. No SDK: the surface is initialize, ping, tools and one notification. */
|
|
55
|
+
export function createClaudeTaskServer(options: ClaudeTaskServerOptions): ClaudeTaskServer {
|
|
56
|
+
const environment = readClaudeEnvironment(options.env);
|
|
57
|
+
const send = options.send;
|
|
58
|
+
const adapter: ClaudeTaskAdapter = createClaudeTaskAdapter({
|
|
59
|
+
environment,
|
|
60
|
+
...(options.core && { core: options.core }),
|
|
61
|
+
...(options.createCore && { createCore: options.createCore }),
|
|
62
|
+
sendChannel: (meta) => send({ jsonrpc: "2.0", method: CLAUDE_PLUGIN.channelMethod, params: { content: CLAUDE_PLUGIN.pointer, meta } }),
|
|
63
|
+
runCommand: options.runCommand ?? noCommands,
|
|
64
|
+
timers: options.timers ?? realTimers,
|
|
65
|
+
...(options.timing && { timing: options.timing }),
|
|
66
|
+
log: options.log ?? (() => undefined),
|
|
67
|
+
});
|
|
68
|
+
let negotiated: { protocolVersion: string; clientProtocolVersion: string | null } | undefined;
|
|
69
|
+
const inFlight = new Map<JsonRpcId, AbortController>();
|
|
70
|
+
let closed = false;
|
|
71
|
+
|
|
72
|
+
const reply = (id: JsonRpcId, result: unknown): void => send({ jsonrpc: "2.0", id, result });
|
|
73
|
+
const fail = (id: JsonRpcId | null, error: JsonRpcError): void => send({ jsonrpc: "2.0", id, error });
|
|
74
|
+
|
|
75
|
+
const callTool = async (id: JsonRpcId, params: unknown): Promise<void> => {
|
|
76
|
+
if (!isRecord(params) || typeof params.name !== "string") { fail(id, { code: JSON_RPC_ERROR.invalidParams, message: "tools/call requires a tool name" }); return; }
|
|
77
|
+
const meta = isRecord(params._meta) ? params._meta : {};
|
|
78
|
+
let result: McpToolResult;
|
|
79
|
+
if (params.name === CLAUDE_PLUGIN.lifecycleTool) {
|
|
80
|
+
// Hooks call without a tool-use id; a model call always carries one.
|
|
81
|
+
result = meta[CLAUDE_PLUGIN.toolUseMeta] !== undefined
|
|
82
|
+
? { content: [{ type: "text", text: "lifecycle is reserved for this plugin's Claude Code hooks" }], isError: true }
|
|
83
|
+
: { content: [{ type: "text", text: await adapter.hook(params.arguments) }] };
|
|
84
|
+
} else {
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
inFlight.set(id, controller);
|
|
87
|
+
try { result = await adapter.callTool(params.name, params.arguments, controller.signal); } finally { inFlight.delete(id); }
|
|
88
|
+
}
|
|
89
|
+
reply(id, result);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const dispatch = async (request: JsonRpcRequest): Promise<void> => {
|
|
93
|
+
const { id, method, params } = request;
|
|
94
|
+
if (id === undefined) {
|
|
95
|
+
if (method === "notifications/initialized" && negotiated) await adapter.start(negotiated);
|
|
96
|
+
else if (method === "notifications/cancelled" && isRecord(params) && (typeof params.requestId === "string" || typeof params.requestId === "number")) inFlight.get(params.requestId)?.abort();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
switch (method) {
|
|
100
|
+
case "initialize": {
|
|
101
|
+
const proposed = isRecord(params) ? params.protocolVersion : undefined;
|
|
102
|
+
negotiated = { protocolVersion: negotiateProtocolVersion(proposed), clientProtocolVersion: typeof proposed === "string" ? proposed : null };
|
|
103
|
+
reply(id, {
|
|
104
|
+
protocolVersion: negotiated.protocolVersion,
|
|
105
|
+
capabilities: { tools: {}, ...(adapter.channel && { experimental: { [CLAUDE_PLUGIN.channelCapability]: {} } }) },
|
|
106
|
+
serverInfo: { name: CLAUDE_PLUGIN.serverName, version: SERVER_VERSION },
|
|
107
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
case "ping":
|
|
112
|
+
reply(id, {});
|
|
113
|
+
return;
|
|
114
|
+
case "tools/list":
|
|
115
|
+
reply(id, { tools: adapter.tools });
|
|
116
|
+
return;
|
|
117
|
+
case "tools/call":
|
|
118
|
+
await callTool(id, params);
|
|
119
|
+
return;
|
|
120
|
+
default:
|
|
121
|
+
fail(id, { code: JSON_RPC_ERROR.methodNotFound, message: `method not found: ${method.slice(0, 64)}` });
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const server: ClaudeTaskServer = {
|
|
126
|
+
handle(message) { return server.handleLine(JSON.stringify(message)); },
|
|
127
|
+
async handleLine(line) {
|
|
128
|
+
if (closed || line.trim() === "") return;
|
|
129
|
+
const parsed = parseJsonRpc(line);
|
|
130
|
+
if (parsed === undefined) return;
|
|
131
|
+
if (isJsonRpcError(parsed)) { fail(null, parsed); return; }
|
|
132
|
+
try { await dispatch(parsed); } catch (error) {
|
|
133
|
+
if (parsed.id !== undefined) fail(parsed.id, { code: JSON_RPC_ERROR.internal, message: error instanceof Error ? error.message : String(error) });
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
async shutdown() {
|
|
137
|
+
if (closed) return;
|
|
138
|
+
closed = true;
|
|
139
|
+
for (const controller of inFlight.values()) controller.abort();
|
|
140
|
+
await adapter.shutdown();
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
return server;
|
|
144
|
+
}
|