hoomanjs 1.1.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 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
@@ -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.1.0",
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/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.")
@@ -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 ?? "max";
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 prompt = [system.content, meta.systemPrompt, skills.content]
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({
@@ -1,8 +1,8 @@
1
1
  import { Config, type NamedMcpTransport } from "./config.ts";
2
- import { Manager } from "./manager.ts";
2
+ import { Manager, type ChannelMessage } from "./manager.ts";
3
3
 
4
4
  export { Config, Manager };
5
- export type { NamedMcpTransport };
5
+ export type { ChannelMessage, NamedMcpTransport };
6
6
  export { createMcpTools } from "./tools.ts";
7
7
 
8
8
  export function createMcpConfig(path: string): Config {
@@ -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 { z } from "zod";
7
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":
@@ -106,12 +119,118 @@ export class Manager {
106
119
  }
107
120
  const map = this.instances!;
108
121
  const batches = await Promise.all(
109
- [...map.entries()].map(async ([serverKey, client]) =>
122
+ [...map.entries()].map(async ([server, client]) =>
110
123
  client
111
124
  .listTools()
112
- .then((tools) => tools.map((t) => new PrefixedMcpTool(serverKey, t))),
125
+ .then((tools) => tools.map((t) => new PrefixedMcpTool(server, t))),
113
126
  ),
114
127
  );
115
128
  return batches.flat();
116
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
+ }
117
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
+ }