hoomanjs 1.0.0 → 1.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 +40 -2
- package/package.json +2 -1
- package/src/acp/acp-agent.ts +14 -0
- package/src/acp/mcp-servers.ts +64 -0
- package/src/acp/sessions/store.ts +3 -0
- package/src/cli.ts +38 -0
- package/src/core/agent/index.ts +3 -2
- package/src/core/index.ts +3 -1
- package/src/core/mcp/index.ts +8 -4
- package/src/core/mcp/manager.ts +132 -5
- package/src/daemon/index.ts +56 -0
- package/src/daemon/queue.ts +48 -0
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ It gives you:
|
|
|
20
20
|
|
|
21
21
|
- a one-shot `exec` command for single prompts
|
|
22
22
|
- a stateful `chat` interface for interactive sessions
|
|
23
|
+
- a `daemon` command for processing MCP channel notifications in background
|
|
23
24
|
- an Ink-powered `configure` workflow for editing app config, `instructions.md`, MCP servers, and installed skills
|
|
24
25
|
- an `acp` command for running Hooman as an Agent Client Protocol (ACP) agent over stdio
|
|
25
26
|
|
|
@@ -28,6 +29,8 @@ It gives you:
|
|
|
28
29
|
- Multiple LLM providers: `ollama`, `openai`, `anthropic`, `google`, `bedrock`
|
|
29
30
|
- Local configuration under `~/.hooman`
|
|
30
31
|
- MCP server support via `stdio`, `streamable-http`, and `sse`
|
|
32
|
+
- MCP server `instructions` support: server-provided instructions are appended to the agent system prompt
|
|
33
|
+
- MCP channel notification support through `hooman daemon --channel <name>`
|
|
31
34
|
- Skill discovery / install / removal through the integrated configure flow
|
|
32
35
|
- Interactive terminal UI for chat and configuration
|
|
33
36
|
|
|
@@ -132,9 +135,35 @@ Choose a toolkit size:
|
|
|
132
135
|
hooman chat --toolkit max
|
|
133
136
|
```
|
|
134
137
|
|
|
138
|
+
### `hooman daemon`
|
|
139
|
+
|
|
140
|
+
Run a long-lived daemon that subscribes to one or more MCP notification channels and feeds each received notification into the agent as a queued prompt.
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
hooman daemon --channel hooman/channel
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Subscribe to multiple channels:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
hooman daemon --channel hooman/channel --channel alerts/channel
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Resume or pin a session id:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
hooman daemon --session my-daemon --channel hooman/channel
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Choose a toolkit size:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
hooman daemon --toolkit full --channel hoomanjs/channel
|
|
162
|
+
```
|
|
163
|
+
|
|
135
164
|
### Toolkit Levels
|
|
136
165
|
|
|
137
|
-
`exec`, `chat`, and `acp` support `-t, --toolkit <lite|full|max>`.
|
|
166
|
+
`exec`, `chat`, `daemon`, and `acp` support `-t, --toolkit <lite|full|max>`.
|
|
138
167
|
|
|
139
168
|
- `lite` - time, fetch, long-term-memory, installed skills, and configured MCP server tools
|
|
140
169
|
- `full` - `lite` plus filesystem, shell, and thinking tools
|
|
@@ -174,7 +203,7 @@ hooman acp --toolkit max
|
|
|
174
203
|
ACP notes:
|
|
175
204
|
|
|
176
205
|
- ACP sessions are stored under `~/.hooman/acp-sessions`
|
|
177
|
-
- ACP
|
|
206
|
+
- ACP loads MCP servers passed on `session/new` and `session/load`, in addition to Hooman's local `mcp.json`
|
|
178
207
|
- ACP `session/new` and `session/load` support `_meta.userId` and `_meta.systemPrompt`
|
|
179
208
|
- when `_meta.systemPrompt` is provided, it is appended to the agent system prompt with a section break
|
|
180
209
|
|
|
@@ -359,6 +388,15 @@ Supports `region`, `clientConfig`, and optional `apiKey`, with all other values
|
|
|
359
388
|
}
|
|
360
389
|
```
|
|
361
390
|
|
|
391
|
+
## MCP Notes
|
|
392
|
+
|
|
393
|
+
- MCP server `instructions` from the protocol `initialize` response are appended to Hooman's system prompt, after local `instructions.md` and session-specific prompt overrides.
|
|
394
|
+
- Hooman reads these instructions automatically from connected MCP servers when building the agent.
|
|
395
|
+
- `hooman daemon` can subscribe to server-published notification channels such as `hoomanjs/channel`.
|
|
396
|
+
- Only MCP servers that advertise the requested channel capability are subscribed.
|
|
397
|
+
- When a matching notification is received, Hooman uses `params.content` as the prompt if it is a string; otherwise it JSON-stringifies the notification params and sends that to the agent.
|
|
398
|
+
- Daemon mode processes notifications sequentially, reuses the same agent session over time, and **auto-approves tool calls**.
|
|
399
|
+
|
|
362
400
|
## Skills
|
|
363
401
|
|
|
364
402
|
Skills are installed under:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hoomanjs",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Bun-powered local AI agent CLI with chat, exec, ACP, MCP, and skills support.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Vaibhav Pandey",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"chromadb": "^3.4.3",
|
|
61
61
|
"cli-spinners": "^3.4.0",
|
|
62
62
|
"commander": "^14.0.3",
|
|
63
|
+
"fastq": "^1.20.1",
|
|
63
64
|
"gray-matter": "^4.0.3",
|
|
64
65
|
"handlebars": "^4.7.9",
|
|
65
66
|
"ink": "^7.0.0",
|
package/src/acp/acp-agent.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { extractAcpClientUserId } from "./meta/user-id.ts";
|
|
|
42
42
|
import { deriveSessionTitleFromEcho } from "./sessions/title.ts";
|
|
43
43
|
import { acpPromptEchoText, acpPromptToInvokeArgs } from "./prompt-invoke.ts";
|
|
44
44
|
import type { Config } from "../core/config.ts";
|
|
45
|
+
import { normalizeAcpSessionMcpServers } from "./mcp-servers.ts";
|
|
45
46
|
import {
|
|
46
47
|
listStoredSessionIds,
|
|
47
48
|
loadSessionMessages,
|
|
@@ -206,6 +207,10 @@ export class AcpAgent implements AgentContract {
|
|
|
206
207
|
authMethods: [],
|
|
207
208
|
agentCapabilities: {
|
|
208
209
|
loadSession: true,
|
|
210
|
+
mcpCapabilities: {
|
|
211
|
+
http: true,
|
|
212
|
+
sse: true,
|
|
213
|
+
},
|
|
209
214
|
promptCapabilities: {
|
|
210
215
|
embeddedContext: true,
|
|
211
216
|
image: true,
|
|
@@ -315,6 +320,7 @@ export class AcpAgent implements AgentContract {
|
|
|
315
320
|
const clientSystemPrompt =
|
|
316
321
|
extractAcpClientSystemPrompt(params._meta) ?? null;
|
|
317
322
|
const bootstrapUserId = clientUserId ?? sessionId;
|
|
323
|
+
const mcpServers = normalizeAcpSessionMcpServers(params.mcpServers);
|
|
318
324
|
|
|
319
325
|
const now = new Date().toISOString();
|
|
320
326
|
const meta: SessionMetaFile = {
|
|
@@ -324,6 +330,7 @@ export class AcpAgent implements AgentContract {
|
|
|
324
330
|
title: null,
|
|
325
331
|
userId: clientUserId,
|
|
326
332
|
systemPrompt: clientSystemPrompt,
|
|
333
|
+
mcpServers,
|
|
327
334
|
};
|
|
328
335
|
await writeSessionMeta(this.#acpRoot, sessionId, meta);
|
|
329
336
|
|
|
@@ -336,6 +343,7 @@ export class AcpAgent implements AgentContract {
|
|
|
336
343
|
userId: bootstrapUserId,
|
|
337
344
|
sessionId,
|
|
338
345
|
toolkit: this.#toolkit,
|
|
346
|
+
mcpServers,
|
|
339
347
|
...(clientSystemPrompt ? { systemPrompt: clientSystemPrompt } : {}),
|
|
340
348
|
},
|
|
341
349
|
false,
|
|
@@ -419,6 +427,10 @@ export class AcpAgent implements AgentContract {
|
|
|
419
427
|
? requestedSystemPrompt
|
|
420
428
|
: storedSystemPrompt;
|
|
421
429
|
const bootstrapUserId = clientUserId ?? params.sessionId;
|
|
430
|
+
const mcpServers =
|
|
431
|
+
params.mcpServers.length > 0
|
|
432
|
+
? normalizeAcpSessionMcpServers(params.mcpServers)
|
|
433
|
+
: (existing.mcpServers ?? []);
|
|
422
434
|
|
|
423
435
|
const {
|
|
424
436
|
config,
|
|
@@ -429,6 +441,7 @@ export class AcpAgent implements AgentContract {
|
|
|
429
441
|
userId: bootstrapUserId,
|
|
430
442
|
sessionId: params.sessionId,
|
|
431
443
|
toolkit: this.#toolkit,
|
|
444
|
+
mcpServers,
|
|
432
445
|
...(clientSystemPrompt ? { systemPrompt: clientSystemPrompt } : {}),
|
|
433
446
|
},
|
|
434
447
|
false,
|
|
@@ -487,6 +500,7 @@ export class AcpAgent implements AgentContract {
|
|
|
487
500
|
...(requestedSystemPrompt !== undefined
|
|
488
501
|
? { systemPrompt: requestedSystemPrompt || null }
|
|
489
502
|
: {}),
|
|
503
|
+
mcpServers,
|
|
490
504
|
});
|
|
491
505
|
|
|
492
506
|
return {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { RequestError, type McpServer } from "@agentclientprotocol/sdk";
|
|
2
|
+
import type { NamedMcpTransport } from "../core/mcp/index.ts";
|
|
3
|
+
|
|
4
|
+
function pairsToRecord(
|
|
5
|
+
pairs: ReadonlyArray<{ name: string; value: string }>,
|
|
6
|
+
): Record<string, string> | undefined {
|
|
7
|
+
if (pairs.length === 0) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
return Object.fromEntries(pairs.map((pair) => [pair.name, pair.value]));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function toNamedTransport(server: McpServer): NamedMcpTransport {
|
|
14
|
+
if ("command" in server) {
|
|
15
|
+
return {
|
|
16
|
+
name: server.name,
|
|
17
|
+
transport: {
|
|
18
|
+
type: "stdio",
|
|
19
|
+
command: server.command,
|
|
20
|
+
args: server.args,
|
|
21
|
+
env: pairsToRecord(server.env),
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (server.type === "http") {
|
|
26
|
+
return {
|
|
27
|
+
name: server.name,
|
|
28
|
+
transport: {
|
|
29
|
+
type: "streamable-http",
|
|
30
|
+
url: server.url,
|
|
31
|
+
headers: pairsToRecord(server.headers),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
name: server.name,
|
|
37
|
+
transport: {
|
|
38
|
+
type: "sse",
|
|
39
|
+
url: server.url,
|
|
40
|
+
headers: pairsToRecord(server.headers),
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Convert ACP session MCP server definitions into the transport shape used by
|
|
47
|
+
* Hooman's MCP manager.
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeAcpSessionMcpServers(
|
|
50
|
+
servers: readonly McpServer[] | null | undefined,
|
|
51
|
+
): NamedMcpTransport[] {
|
|
52
|
+
const normalized = (servers ?? []).map(toNamedTransport);
|
|
53
|
+
const seen = new Set<string>();
|
|
54
|
+
for (const { name } of normalized) {
|
|
55
|
+
if (seen.has(name)) {
|
|
56
|
+
throw RequestError.invalidParams({
|
|
57
|
+
mcpServers: servers,
|
|
58
|
+
message: `Duplicate ACP MCP server name "${name}" in session request`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
seen.add(name);
|
|
62
|
+
}
|
|
63
|
+
return normalized;
|
|
64
|
+
}
|
|
@@ -2,6 +2,7 @@ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { SessionInfo } from "@agentclientprotocol/sdk";
|
|
4
4
|
import type { MessageData } from "@strands-agents/sdk";
|
|
5
|
+
import type { NamedMcpTransport } from "../../core/mcp/config.ts";
|
|
5
6
|
|
|
6
7
|
export type SessionMetaFile = {
|
|
7
8
|
cwd: string;
|
|
@@ -12,6 +13,8 @@ export type SessionMetaFile = {
|
|
|
12
13
|
userId?: string | null;
|
|
13
14
|
/** Session-level system prompt from ACP client `_meta`. */
|
|
14
15
|
systemPrompt?: string | null;
|
|
16
|
+
/** Session-scoped MCP servers requested by the ACP client. */
|
|
17
|
+
mcpServers?: NamedMcpTransport[];
|
|
15
18
|
};
|
|
16
19
|
|
|
17
20
|
const META = "meta.json";
|
package/src/cli.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { createToolApprovalHandler } from "./exec/approvals.ts";
|
|
|
9
9
|
import { chat } from "./chat/index.tsx";
|
|
10
10
|
import { configure } from "./configure/index.tsx";
|
|
11
11
|
import { runAcpStdio } from "./acp/acp-agent.ts";
|
|
12
|
+
import { main as daemon } from "./daemon/index.ts";
|
|
12
13
|
|
|
13
14
|
async function readPackageMeta(): Promise<{
|
|
14
15
|
name: string;
|
|
@@ -122,6 +123,43 @@ program
|
|
|
122
123
|
},
|
|
123
124
|
);
|
|
124
125
|
|
|
126
|
+
program
|
|
127
|
+
.command("daemon")
|
|
128
|
+
.description(
|
|
129
|
+
"Run a background daemon that processes MCP channel notifications as prompts.",
|
|
130
|
+
)
|
|
131
|
+
.option("-s, --session <id>", "Session ID to use.")
|
|
132
|
+
.requiredOption(
|
|
133
|
+
"-c, --channel <name>",
|
|
134
|
+
"MCP notification channel to subscribe to (repeatable).",
|
|
135
|
+
(value: string, previous?: string[]) => [...(previous ?? []), value],
|
|
136
|
+
)
|
|
137
|
+
.addOption(createToolkitOption())
|
|
138
|
+
.action(
|
|
139
|
+
async (options: {
|
|
140
|
+
session?: string;
|
|
141
|
+
toolkit?: Toolkit;
|
|
142
|
+
channel?: string[];
|
|
143
|
+
}) => {
|
|
144
|
+
const sessionId = options.session?.trim() || crypto.randomUUID();
|
|
145
|
+
const channels = options.channel ?? [];
|
|
146
|
+
const {
|
|
147
|
+
agent,
|
|
148
|
+
mcp: { manager },
|
|
149
|
+
} = await bootstrap(
|
|
150
|
+
{ sessionId, toolkit: options.toolkit ?? "full" },
|
|
151
|
+
true,
|
|
152
|
+
);
|
|
153
|
+
try {
|
|
154
|
+
await daemon({ agent, manager, channels });
|
|
155
|
+
} finally {
|
|
156
|
+
try {
|
|
157
|
+
await manager.disconnect();
|
|
158
|
+
} catch {}
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
125
163
|
program
|
|
126
164
|
.command("configure")
|
|
127
165
|
.description("Manage app config, MCP servers, and installed skills.")
|
package/src/core/agent/index.ts
CHANGED
|
@@ -42,13 +42,14 @@ export async function create(
|
|
|
42
42
|
): Promise<Agent> {
|
|
43
43
|
const sessionId = meta.sessionId;
|
|
44
44
|
const userId = meta.userId ?? sessionId;
|
|
45
|
-
const toolkit = meta.toolkit ?? "
|
|
45
|
+
const toolkit = meta.toolkit ?? "full";
|
|
46
46
|
const llm = await modelProviders[config.llm.provider]!();
|
|
47
47
|
const stm = createShortTermMemory(sessionId);
|
|
48
48
|
const ltm = config.ltm.enabled ? createLongTermMemoryStore(config) : null;
|
|
49
49
|
const skills = await createSkillsPrompt(registry);
|
|
50
50
|
const tools = await mcp.manager.listPrefixedTools();
|
|
51
|
-
const
|
|
51
|
+
const append = await mcp.manager.listServerInstructions();
|
|
52
|
+
const prompt = [system.content, meta.systemPrompt, ...append, skills.content]
|
|
52
53
|
.filter((x) => !!x)
|
|
53
54
|
.join(SECTION_BREAK);
|
|
54
55
|
return new Agent({
|
package/src/core/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createMcpManager,
|
|
7
7
|
type Config as McpServersConfig,
|
|
8
8
|
type Manager as McpConnectionManager,
|
|
9
|
+
type NamedMcpTransport,
|
|
9
10
|
} from "./mcp/index.ts";
|
|
10
11
|
import { createSkillsRegistry } from "./skills/index.ts";
|
|
11
12
|
import type { Registry } from "./skills/index.ts";
|
|
@@ -23,6 +24,7 @@ export async function bootstrap(
|
|
|
23
24
|
userId?: string;
|
|
24
25
|
sessionId: string;
|
|
25
26
|
systemPrompt?: string;
|
|
27
|
+
mcpServers?: NamedMcpTransport[];
|
|
26
28
|
toolkit?: Toolkit;
|
|
27
29
|
},
|
|
28
30
|
print: boolean = false,
|
|
@@ -34,7 +36,7 @@ export async function bootstrap(
|
|
|
34
36
|
}> {
|
|
35
37
|
const config = new Config(configJsonPath());
|
|
36
38
|
const mcpConfig = createMcpConfig(mcpJsonPath());
|
|
37
|
-
const mcpManager = createMcpManager(mcpConfig);
|
|
39
|
+
const mcpManager = createMcpManager(mcpConfig, meta.mcpServers ?? []);
|
|
38
40
|
const mcp = { config: mcpConfig, manager: mcpManager };
|
|
39
41
|
const registry = createSkillsRegistry(basePath());
|
|
40
42
|
const toolkit = meta.toolkit ?? "max";
|
package/src/core/mcp/index.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import { Config } from "./config.ts";
|
|
2
|
-
import { Manager } from "./manager.ts";
|
|
1
|
+
import { Config, type NamedMcpTransport } from "./config.ts";
|
|
2
|
+
import { Manager, type ChannelMessage } from "./manager.ts";
|
|
3
3
|
|
|
4
4
|
export { Config, Manager };
|
|
5
|
+
export type { ChannelMessage, NamedMcpTransport };
|
|
5
6
|
export { createMcpTools } from "./tools.ts";
|
|
6
7
|
|
|
7
8
|
export function createMcpConfig(path: string): Config {
|
|
8
9
|
return new Config(path);
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
export function createMcpManager(
|
|
12
|
-
|
|
12
|
+
export function createMcpManager(
|
|
13
|
+
config: Config,
|
|
14
|
+
mcpServers: readonly NamedMcpTransport[] = [],
|
|
15
|
+
): Manager {
|
|
16
|
+
return new Manager(config, mcpServers);
|
|
13
17
|
}
|
package/src/core/mcp/manager.ts
CHANGED
|
@@ -4,9 +4,22 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
|
4
4
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
5
5
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
6
6
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
7
|
-
import {
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { Config, type NamedMcpTransport } from "./config.ts";
|
|
8
9
|
import type { McpTransport } from "./types.ts";
|
|
9
10
|
|
|
11
|
+
export type ChannelMessageMeta = {
|
|
12
|
+
server: string;
|
|
13
|
+
channel: string;
|
|
14
|
+
method: string;
|
|
15
|
+
params: unknown;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ChannelMessage = {
|
|
19
|
+
prompt: string;
|
|
20
|
+
meta: ChannelMessageMeta;
|
|
21
|
+
};
|
|
22
|
+
|
|
10
23
|
function transportFor(spec: McpTransport): Transport {
|
|
11
24
|
switch (spec.type) {
|
|
12
25
|
case "stdio":
|
|
@@ -43,7 +56,10 @@ function transportFor(spec: McpTransport): Transport {
|
|
|
43
56
|
export class Manager {
|
|
44
57
|
private instances: Map<string, McpClient> | null = null;
|
|
45
58
|
|
|
46
|
-
public constructor(
|
|
59
|
+
public constructor(
|
|
60
|
+
private readonly config: Config,
|
|
61
|
+
private readonly mcpServers: readonly NamedMcpTransport[] = [],
|
|
62
|
+
) {}
|
|
47
63
|
|
|
48
64
|
/** Lazily builds clients from the current in-memory config (reloads file first). */
|
|
49
65
|
get clients(): ReadonlyMap<string, McpClient> {
|
|
@@ -61,7 +77,12 @@ export class Manager {
|
|
|
61
77
|
this.config.reload();
|
|
62
78
|
const previous = this.instances;
|
|
63
79
|
const next = new Map<string, McpClient>();
|
|
64
|
-
|
|
80
|
+
const transports = [
|
|
81
|
+
...this.config.list(),
|
|
82
|
+
// Session-scoped ACP servers intentionally override local config names.
|
|
83
|
+
...this.mcpServers,
|
|
84
|
+
];
|
|
85
|
+
for (const { name, transport } of transports) {
|
|
65
86
|
next.set(
|
|
66
87
|
name,
|
|
67
88
|
new McpClient({
|
|
@@ -98,12 +119,118 @@ export class Manager {
|
|
|
98
119
|
}
|
|
99
120
|
const map = this.instances!;
|
|
100
121
|
const batches = await Promise.all(
|
|
101
|
-
[...map.entries()].map(async ([
|
|
122
|
+
[...map.entries()].map(async ([server, client]) =>
|
|
102
123
|
client
|
|
103
124
|
.listTools()
|
|
104
|
-
.then((tools) => tools.map((t) => new PrefixedMcpTool(
|
|
125
|
+
.then((tools) => tools.map((t) => new PrefixedMcpTool(server, t))),
|
|
105
126
|
),
|
|
106
127
|
);
|
|
107
128
|
return batches.flat();
|
|
108
129
|
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Collects optional server-level instructions from each connected MCP server.
|
|
133
|
+
*/
|
|
134
|
+
public async listServerInstructions(): Promise<string[]> {
|
|
135
|
+
if (this.instances === null) {
|
|
136
|
+
this.reload();
|
|
137
|
+
}
|
|
138
|
+
const map = this.instances!;
|
|
139
|
+
const rows = await Promise.all(
|
|
140
|
+
[...map.entries()].map(async ([server, client]) => {
|
|
141
|
+
await client.connect();
|
|
142
|
+
const instructions = client.client.getInstructions()?.trim();
|
|
143
|
+
if (!instructions) {
|
|
144
|
+
return "";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return [`MCP server "${server}" instructions:`, "", instructions].join(
|
|
148
|
+
"\n",
|
|
149
|
+
);
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
return rows.filter(Boolean);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
public async subscribeToChannels(
|
|
156
|
+
channels: readonly string[],
|
|
157
|
+
onMessage: (message: ChannelMessage) => void,
|
|
158
|
+
): Promise<() => void> {
|
|
159
|
+
if (this.instances === null) {
|
|
160
|
+
this.reload();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const map = this.instances!;
|
|
164
|
+
const requested = [
|
|
165
|
+
...new Set(channels.map((c) => c.trim()).filter(Boolean)),
|
|
166
|
+
];
|
|
167
|
+
if (requested.length === 0) {
|
|
168
|
+
return () => {};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const unsubs: Array<() => void> = [];
|
|
172
|
+
for (const [server, client] of map.entries()) {
|
|
173
|
+
await client.connect();
|
|
174
|
+
const experimental =
|
|
175
|
+
client.client.getServerCapabilities()?.experimental ?? {};
|
|
176
|
+
for (const channel of requested) {
|
|
177
|
+
if (!Object.hasOwn(experimental, channel)) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const method = `notifications/${channel}`;
|
|
182
|
+
const schema = z.object({
|
|
183
|
+
method: z.literal(method),
|
|
184
|
+
params: z.unknown().optional(),
|
|
185
|
+
});
|
|
186
|
+
const handler = (notification: {
|
|
187
|
+
method: string;
|
|
188
|
+
params?: unknown;
|
|
189
|
+
}) => {
|
|
190
|
+
const { method, params } = notification;
|
|
191
|
+
const prompt = this.toChannelPrompt(method, params);
|
|
192
|
+
if (!prompt) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
onMessage({
|
|
197
|
+
prompt,
|
|
198
|
+
meta: {
|
|
199
|
+
server,
|
|
200
|
+
channel,
|
|
201
|
+
method,
|
|
202
|
+
params,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
client.client.setNotificationHandler(schema, handler);
|
|
207
|
+
unsubs.push(() => {
|
|
208
|
+
client.client.setNotificationHandler(schema, () => {});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return () => {
|
|
214
|
+
for (const off of unsubs) {
|
|
215
|
+
off();
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private toChannelPrompt(method: string, params?: unknown): string {
|
|
221
|
+
if (
|
|
222
|
+
params &&
|
|
223
|
+
typeof params === "object" &&
|
|
224
|
+
"content" in params &&
|
|
225
|
+
typeof params.content === "string"
|
|
226
|
+
) {
|
|
227
|
+
return params.content.trim();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
return JSON.stringify(params).trim();
|
|
232
|
+
} catch {
|
|
233
|
+
return String(params).trim();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
109
236
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { stderr } from "node:process";
|
|
2
|
+
import { BeforeToolCallEvent, type Agent } from "@strands-agents/sdk";
|
|
3
|
+
import type {
|
|
4
|
+
ChannelMessage,
|
|
5
|
+
Manager as McpManager,
|
|
6
|
+
} from "../core/mcp/index.ts";
|
|
7
|
+
import { createQueue } from "./queue.ts";
|
|
8
|
+
|
|
9
|
+
type RunDaemonOptions = {
|
|
10
|
+
agent: Agent;
|
|
11
|
+
manager: McpManager;
|
|
12
|
+
channels: string[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function debug(text: string): void {
|
|
16
|
+
stderr.write(`[daemon] ${text}\n`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function main(options: RunDaemonOptions): Promise<void> {
|
|
20
|
+
const channels = [
|
|
21
|
+
...new Set(options.channels.map((value) => value.trim()).filter(Boolean)),
|
|
22
|
+
];
|
|
23
|
+
if (channels.length === 0) {
|
|
24
|
+
throw new Error("At least one --channel <name> is required.");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Daemon mode is non-interactive: approve tool calls by default.
|
|
28
|
+
options.agent.addHook(BeforeToolCallEvent, async () => {});
|
|
29
|
+
|
|
30
|
+
let fasterq: Awaited<ReturnType<typeof createQueue>>[0] | null = null;
|
|
31
|
+
|
|
32
|
+
const unsubscribe = await options.manager.subscribeToChannels(
|
|
33
|
+
channels,
|
|
34
|
+
(message) => {
|
|
35
|
+
if (fasterq != null) {
|
|
36
|
+
void fasterq.push(message);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const [queue, stop] = await createQueue(async (message: ChannelMessage) => {
|
|
42
|
+
debug(`notification from ${message.meta.server}:${message.meta.channel}`);
|
|
43
|
+
try {
|
|
44
|
+
await options.agent.invoke(message.prompt);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
47
|
+
debug(
|
|
48
|
+
`turn failed for ${message.meta.server}:${message.meta.channel}: ${text}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}, unsubscribe);
|
|
52
|
+
|
|
53
|
+
fasterq = queue;
|
|
54
|
+
|
|
55
|
+
await stop();
|
|
56
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fastq from "fastq";
|
|
2
|
+
import type { ChannelMessage } from "../core/mcp/index.ts";
|
|
3
|
+
|
|
4
|
+
type MessageQueue = fastq.queueAsPromised<ChannelMessage, void>;
|
|
5
|
+
|
|
6
|
+
export async function createQueue(
|
|
7
|
+
handler: (message: ChannelMessage) => Promise<void>,
|
|
8
|
+
cleanup: () => void,
|
|
9
|
+
): Promise<[MessageQueue, () => Promise<void>]> {
|
|
10
|
+
let stopping = false;
|
|
11
|
+
let resolver: (() => void) | null = null;
|
|
12
|
+
const queue: MessageQueue = fastq.promise(async (message: ChannelMessage) => {
|
|
13
|
+
await handler(message);
|
|
14
|
+
}, 1);
|
|
15
|
+
|
|
16
|
+
const stopper = new Promise<void>((resolve) => {
|
|
17
|
+
resolver = resolve;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const shutdown = () => {
|
|
21
|
+
if (stopping) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
stopping = true;
|
|
25
|
+
queue.kill();
|
|
26
|
+
resolver?.();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const onSigInt = () => shutdown();
|
|
30
|
+
const onSigTerm = () => shutdown();
|
|
31
|
+
|
|
32
|
+
process.on("SIGINT", onSigInt);
|
|
33
|
+
process.on("SIGTERM", onSigTerm);
|
|
34
|
+
|
|
35
|
+
return [
|
|
36
|
+
queue,
|
|
37
|
+
async () => {
|
|
38
|
+
try {
|
|
39
|
+
await stopper;
|
|
40
|
+
} finally {
|
|
41
|
+
cleanup();
|
|
42
|
+
await queue.drained().catch(() => {});
|
|
43
|
+
process.off("SIGINT", onSigInt);
|
|
44
|
+
process.off("SIGTERM", onSigTerm);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
}
|