hoomanjs 1.7.0 → 1.8.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
@@ -27,10 +27,10 @@ It gives you:
27
27
  ## Features
28
28
 
29
29
  - Multiple LLM providers: `ollama`, `openai`, `anthropic`, `google`, `bedrock`, `groq`, `moonshot`, `xai`
30
- - Local configuration under `~/.hooman`
30
+ - Local configuration under `./.hooman` when that folder exists in the current working directory, otherwise `~/.hooman`
31
31
  - MCP server support via `stdio`, `streamable-http`, and `sse`
32
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>`
33
+ - MCP channel notification support through `hooman daemon --channels`
34
34
  - Skill discovery / install / removal through the integrated configure flow
35
35
  - Interactive terminal UI for chat and configuration
36
36
 
@@ -109,6 +109,12 @@ Choose a toolkit size:
109
109
  hooman exec "Summarize this repo" --toolkit lite
110
110
  ```
111
111
 
112
+ Skip interactive tool approval (allows every tool call; use only when you trust the prompt and environment):
113
+
114
+ ```bash
115
+ hooman exec "Summarize this repo" --yolo
116
+ ```
117
+
112
118
  ### `hooman chat`
113
119
 
114
120
  Start an interactive stateful chat session.
@@ -135,30 +141,36 @@ Choose a toolkit size:
135
141
  hooman chat --toolkit max
136
142
  ```
137
143
 
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.
144
+ Skip the in-chat tool approval UI (same semantics as `exec --yolo`):
141
145
 
142
146
  ```bash
143
- hooman daemon --channel hooman/channel
147
+ hooman chat --yolo
144
148
  ```
145
149
 
146
- Subscribe to multiple channels:
150
+ ### `hooman daemon`
151
+
152
+ Run a long-lived daemon that subscribes to MCP servers advertising the fixed `hooman/channel` capability and feeds each received notification into the agent as a queued prompt.
147
153
 
148
154
  ```bash
149
- hooman daemon --channel hooman/channel --channel alerts/channel
155
+ hooman daemon --channels
150
156
  ```
151
157
 
152
158
  Resume or pin a session id:
153
159
 
154
160
  ```bash
155
- hooman daemon --session my-daemon --channel hooman/channel
161
+ hooman daemon --session my-daemon --channels
156
162
  ```
157
163
 
158
164
  Choose a toolkit size:
159
165
 
160
166
  ```bash
161
- hooman daemon --toolkit full --channel hoomanjs/channel
167
+ hooman daemon --toolkit full --channels
168
+ ```
169
+
170
+ Skip remote channel permission relay and allow every tool call from daemon turns (same risk profile as `exec` / `chat` with `--yolo`):
171
+
172
+ ```bash
173
+ hooman daemon --channels --yolo
162
174
  ```
163
175
 
164
176
  ### Toolkit Levels
@@ -202,7 +214,7 @@ hooman acp --toolkit max
202
214
 
203
215
  ACP notes:
204
216
 
205
- - ACP sessions are stored under `~/.hooman/acp-sessions`
217
+ - ACP sessions are stored under the active Hooman data directory in `acp-sessions/`
206
218
  - ACP loads MCP servers passed on `session/new` and `session/load`, in addition to Hooman's local `mcp.json`
207
219
  - ACP `session/new` and `session/load` support `_meta.userId` and `_meta.systemPrompt`
208
220
  - when `_meta.systemPrompt` is provided, it is appended to the agent system prompt with a section break
@@ -212,7 +224,8 @@ ACP notes:
212
224
  Hooman stores its data in:
213
225
 
214
226
  ```text
215
- ~/.hooman/
227
+ ./.hooman/ # when this folder exists in the current working directory
228
+ ~/.hooman/ # otherwise
216
229
  ```
217
230
 
218
231
  Important files and folders:
@@ -440,17 +453,20 @@ Uses the Vercel AI SDK xAI provider (`@ai-sdk/xai`) on top of Strands `VercelMod
440
453
 
441
454
  - MCP server `instructions` from the protocol `initialize` response are appended to Hooman's system prompt, after local `instructions.md` and session-specific prompt overrides.
442
455
  - Hooman reads these instructions automatically from connected MCP servers when building the agent.
443
- - `hooman daemon` can subscribe to server-published notification channels such as `hoomanjs/channel`.
444
- - Only MCP servers that advertise the requested channel capability are subscribed.
456
+ - `hooman daemon --channels` subscribes to MCP servers that advertise the experimental `hooman/channel` capability.
457
+ - Hooman also reads `hooman/user`, `hooman/session`, and `hooman/thread` capability paths so daemon turns preserve origin metadata from the source channel.
445
458
  - 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.
446
- - Daemon mode processes notifications sequentially, reuses the same agent session over time, and **auto-approves tool calls**.
459
+ - Daemon mode processes notifications sequentially and reuses the same agent session over time.
460
+ - Tool calls from daemon turns are no longer blanket auto-approved: if the originating MCP server supports `hooman/channel/permission`, Hooman relays a remote approval request back to that source; otherwise the tool call is denied.
461
+ - `exec`, `chat`, and `daemon` accept `--yolo` to bypass those approval paths and allow all tools without prompting or relay.
447
462
 
448
463
  ## Skills
449
464
 
450
465
  Skills are installed under:
451
466
 
452
467
  ```text
453
- ~/.hooman/skills
468
+ ./.hooman/skills # when ./.hooman exists
469
+ ~/.hooman/skills # otherwise
454
470
  ```
455
471
 
456
472
  The configure workflow can:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hoomanjs",
3
- "version": "1.7.0",
3
+ "version": "1.8.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",
package/src/chat/app.tsx CHANGED
@@ -31,6 +31,7 @@ type ChatAppProps = {
31
31
  manager: McpManager;
32
32
  registry: Registry;
33
33
  initialPrompt?: string;
34
+ yolo?: boolean;
34
35
  onExit: () => void;
35
36
  };
36
37
 
@@ -73,6 +74,7 @@ export function ChatApp({
73
74
  manager,
74
75
  registry,
75
76
  initialPrompt,
77
+ yolo,
76
78
  onExit,
77
79
  }: ChatAppProps): React.JSX.Element {
78
80
  const { exit } = useApp();
@@ -141,13 +143,13 @@ export function ChatApp({
141
143
  });
142
144
  const cleanupHook = agent.addHook(
143
145
  BeforeToolCallEvent,
144
- createChatApprovalHandler(config, controller),
146
+ createChatApprovalHandler(config, controller, { yolo }),
145
147
  );
146
148
  return () => {
147
149
  cleanupListener();
148
150
  cleanupHook();
149
151
  };
150
- }, [agent, config]);
152
+ }, [agent, config, yolo]);
151
153
 
152
154
  const appendLine = useCallback((line: ChatLine) => {
153
155
  setLines((prev) => [...prev, line]);
@@ -69,9 +69,13 @@ export class ChatApprovalController {
69
69
  export function createChatApprovalHandler(
70
70
  config: Config,
71
71
  controller: ChatApprovalController,
72
+ options?: { yolo?: boolean },
72
73
  ): (event: BeforeToolCallEvent) => Promise<void> {
73
74
  return async (event: BeforeToolCallEvent) => {
74
75
  const toolName = event.toolUse.name;
76
+ if (options?.yolo) {
77
+ return;
78
+ }
75
79
  if (
76
80
  INTERNAL_ALWAYS_ALLOWED.has(toolName) ||
77
81
  config.tools.allowed.includes(toolName)
@@ -13,6 +13,7 @@ type LaunchChatOptions = {
13
13
  registry: Registry;
14
14
  sessionId: string;
15
15
  initialPrompt?: string;
16
+ yolo?: boolean;
16
17
  };
17
18
 
18
19
  export async function chat(options: LaunchChatOptions): Promise<void> {
@@ -25,6 +26,7 @@ export async function chat(options: LaunchChatOptions): Promise<void> {
25
26
  registry={options.registry}
26
27
  sessionId={options.sessionId}
27
28
  initialPrompt={options.initialPrompt}
29
+ yolo={options.yolo}
28
30
  onExit={() => {
29
31
  done = true;
30
32
  }}
package/src/cli.ts CHANGED
@@ -10,6 +10,7 @@ import { chat } from "./chat/index.tsx";
10
10
  import { configure } from "./configure/index.tsx";
11
11
  import { runAcpStdio } from "./acp/acp-agent.ts";
12
12
  import { main as daemon } from "./daemon/index.ts";
13
+ import { createDaemonApprovalHandler } from "./daemon/approvals.ts";
13
14
 
14
15
  async function readPackageMeta(): Promise<{
15
16
  name: string;
@@ -58,11 +59,12 @@ program
58
59
  .description("Bootstrap an agent and run a single prompt.")
59
60
  .argument("<prompt>", "Prompt to run once.")
60
61
  .option("-s, --session <id>", "Session ID to use.")
62
+ .option("--yolo", "Allow all tools without prompting for approval.")
61
63
  .addOption(createToolkitOption())
62
64
  .action(
63
65
  async (
64
66
  prompt: string,
65
- options: { session?: string; toolkit?: Toolkit },
67
+ options: { session?: string; toolkit?: Toolkit; yolo?: boolean },
66
68
  ) => {
67
69
  const sessionId = options.session?.trim() || crypto.randomUUID();
68
70
  const {
@@ -73,7 +75,10 @@ program
73
75
  { sessionId, toolkit: options.toolkit ?? "full" },
74
76
  true,
75
77
  );
76
- agent.addHook(BeforeToolCallEvent, createToolApprovalHandler(config));
78
+ agent.addHook(
79
+ BeforeToolCallEvent,
80
+ createToolApprovalHandler(config, { yolo: Boolean(options.yolo) }),
81
+ );
77
82
  try {
78
83
  await agent.invoke(prompt);
79
84
  } finally {
@@ -89,11 +94,12 @@ program
89
94
  .description("Start an interactive, stateful CLI chat session.")
90
95
  .argument("[prompt]", "Optional initial prompt to run after startup.")
91
96
  .option("-s, --session <id>", "Session ID to use.")
97
+ .option("--yolo", "Allow all tools without prompting for approval.")
92
98
  .addOption(createToolkitOption())
93
99
  .action(
94
100
  async (
95
101
  prompt: string | undefined,
96
- options: { session?: string; toolkit?: Toolkit },
102
+ options: { session?: string; toolkit?: Toolkit; yolo?: boolean },
97
103
  ) => {
98
104
  const sessionId = options.session?.trim() || crypto.randomUUID();
99
105
  const {
@@ -114,6 +120,7 @@ program
114
120
  registry,
115
121
  sessionId,
116
122
  initialPrompt: prompt?.trim() || undefined,
123
+ yolo: Boolean(options.yolo),
117
124
  });
118
125
  } finally {
119
126
  try {
@@ -129,26 +136,24 @@ program
129
136
  "Run a background daemon that processes MCP channel notifications as prompts.",
130
137
  )
131
138
  .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
- )
139
+ .option("--channels", "Subscribe to MCP servers advertising hooman/channel.")
137
140
  .option(
138
141
  "--debug",
139
142
  "Log each MCP channel notification payload to the console.",
140
143
  )
144
+ .option("--yolo", "Allow all tools without remote approval or prompts.")
141
145
  .addOption(createToolkitOption())
142
146
  .action(
143
147
  async (options: {
144
148
  session?: string;
145
149
  toolkit?: Toolkit;
146
- channel?: string[];
150
+ channels?: boolean;
147
151
  debug?: boolean;
152
+ yolo?: boolean;
148
153
  }) => {
149
154
  const session = options.session?.trim();
150
- const channels = options.channel ?? [];
151
155
  const {
156
+ config,
152
157
  agent,
153
158
  mcp: { manager },
154
159
  } = await bootstrap(
@@ -159,13 +164,17 @@ program
159
164
  },
160
165
  true,
161
166
  );
162
- // Daemon mode is non-interactive: approve tool calls by default.
163
- agent.addHook(BeforeToolCallEvent, async () => {});
167
+ agent.addHook(
168
+ BeforeToolCallEvent,
169
+ createDaemonApprovalHandler(config, manager, agent, {
170
+ yolo: Boolean(options.yolo),
171
+ }),
172
+ );
164
173
  try {
165
174
  await daemon({
166
175
  agent,
167
176
  manager,
168
- channels,
177
+ channels: Boolean(options.channels),
169
178
  session,
170
179
  debug: Boolean(options.debug),
171
180
  });
@@ -1,8 +1,15 @@
1
1
  import { Config, type NamedMcpTransport } from "./config.ts";
2
- import { Manager, type ChannelMessage } from "./manager.ts";
2
+ import {
3
+ Manager,
4
+ HOOMAN_CHANNEL,
5
+ HOOMAN_CHANNEL_PERMISSION,
6
+ type ChannelMessage,
7
+ type ChannelPermissionBehavior,
8
+ } from "./manager.ts";
3
9
 
4
10
  export { Config, Manager };
5
- export type { ChannelMessage, NamedMcpTransport };
11
+ export { HOOMAN_CHANNEL, HOOMAN_CHANNEL_PERMISSION };
12
+ export type { ChannelMessage, ChannelPermissionBehavior, NamedMcpTransport };
6
13
  export { createMcpTools } from "./tools.ts";
7
14
 
8
15
  export function createMcpConfig(path: string): Config {
@@ -9,14 +9,20 @@ import { z } from "zod";
9
9
  import { Config, type NamedMcpTransport } from "./config.ts";
10
10
  import type { McpTransport } from "./types.ts";
11
11
 
12
+ export const HOOMAN_CHANNEL = "hooman/channel";
13
+ export const HOOMAN_CHANNEL_PERMISSION = "hooman/channel/permission";
14
+ const HOOMAN_CHANNEL_PERMISSION_METHOD = `notifications/${HOOMAN_CHANNEL_PERMISSION}`;
15
+
12
16
  export type ChannelMessageMeta = {
13
17
  server: string;
14
18
  channel: string;
15
19
  method: string;
16
20
  params: unknown;
21
+ source?: string;
17
22
  identity: {
18
23
  user?: string;
19
24
  session?: string;
25
+ thread?: string;
20
26
  };
21
27
  };
22
28
 
@@ -25,6 +31,19 @@ export type ChannelMessage = {
25
31
  meta: ChannelMessageMeta;
26
32
  };
27
33
 
34
+ export type ChannelPermissionBehavior = "allow_once" | "allow_always" | "deny";
35
+
36
+ type ChannelPermissionRequest = {
37
+ requestId: string;
38
+ tool: string;
39
+ description: string;
40
+ preview: string;
41
+ source?: string;
42
+ user?: string;
43
+ session?: string;
44
+ thread?: string;
45
+ };
46
+
28
47
  function transportFor(spec: McpTransport): Transport {
29
48
  switch (spec.type) {
30
49
  case "stdio":
@@ -74,7 +93,7 @@ function readPathValue(
74
93
 
75
94
  function readIdentityPath(
76
95
  experimental: unknown,
77
- key: "identity/user" | "identity/session",
96
+ key: "hooman/user" | "hooman/session" | "hooman/thread",
78
97
  ): string | undefined {
79
98
  const path = get(experimental, [key, "path"]);
80
99
  return typeof path === "string" && path.trim().length > 0
@@ -82,12 +101,24 @@ function readIdentityPath(
82
101
  : undefined;
83
102
  }
84
103
 
104
+ function readSourceValue(value: unknown): string | undefined {
105
+ return readPathValue(value, "meta.source");
106
+ }
107
+
85
108
  /**
86
109
  * Holds one {@link McpClient} per named entry in {@link Config}. Call {@link reload}
87
110
  * after changing the file on disk (or construct and then {@link reload} once).
88
111
  */
89
112
  export class Manager {
90
113
  private instances: Map<string, McpClient> | null = null;
114
+ private readonly pendingPermissions = new Map<
115
+ string,
116
+ {
117
+ resolve: (behavior: ChannelPermissionBehavior) => void;
118
+ reject: (reason: Error) => void;
119
+ timer: ReturnType<typeof setTimeout>;
120
+ }
121
+ >();
91
122
 
92
123
  public constructor(
93
124
  private readonly config: Config,
@@ -132,6 +163,11 @@ export class Manager {
132
163
  }
133
164
 
134
165
  public async disconnect(): Promise<void> {
166
+ for (const [key, pending] of this.pendingPermissions.entries()) {
167
+ clearTimeout(pending.timer);
168
+ pending.reject(new Error(`Pending permission "${key}" cancelled.`));
169
+ }
170
+ this.pendingPermissions.clear();
135
171
  const toClose = this.instances;
136
172
  this.instances = null;
137
173
  if (!toClose?.size) {
@@ -206,8 +242,48 @@ export class Manager {
206
242
  await client.connect();
207
243
  const experimental =
208
244
  client.client.getServerCapabilities()?.experimental ?? {};
209
- const user = readIdentityPath(experimental, "identity/user");
210
- const session = readIdentityPath(experimental, "identity/session");
245
+ const user = readIdentityPath(experimental, "hooman/user");
246
+ const session = readIdentityPath(experimental, "hooman/session");
247
+ const thread = readIdentityPath(experimental, "hooman/thread");
248
+ const supportsPermission =
249
+ Boolean(get(experimental, [HOOMAN_CHANNEL_PERMISSION])) &&
250
+ typeof (client.client as { setNotificationHandler?: unknown })
251
+ .setNotificationHandler === "function";
252
+
253
+ if (supportsPermission) {
254
+ const schema = z.object({
255
+ method: z.literal(HOOMAN_CHANNEL_PERMISSION_METHOD),
256
+ params: z.object({
257
+ request_id: z.string().min(1),
258
+ behavior: z.enum(["allow_once", "allow_always", "deny"]),
259
+ }),
260
+ });
261
+ const handler = (notification: {
262
+ params?: {
263
+ request_id?: string;
264
+ behavior?: ChannelPermissionBehavior;
265
+ };
266
+ }) => {
267
+ const requestId = notification.params?.request_id?.trim();
268
+ const behavior = notification.params?.behavior;
269
+ if (!requestId || !behavior) {
270
+ return;
271
+ }
272
+ const key = `${server}:${requestId}`;
273
+ const pending = this.pendingPermissions.get(key);
274
+ if (!pending) {
275
+ return;
276
+ }
277
+ this.pendingPermissions.delete(key);
278
+ clearTimeout(pending.timer);
279
+ pending.resolve(behavior);
280
+ };
281
+ client.client.setNotificationHandler(schema, handler);
282
+ unsubs.push(() => {
283
+ client.client.setNotificationHandler(schema, () => {});
284
+ });
285
+ }
286
+
211
287
  for (const channel of requested) {
212
288
  if (!Object.hasOwn(experimental, channel)) {
213
289
  continue;
@@ -235,9 +311,11 @@ export class Manager {
235
311
  channel,
236
312
  method,
237
313
  params,
314
+ source: readSourceValue(params),
238
315
  identity: {
239
316
  user: readPathValue(params, user),
240
317
  session: readPathValue(params, session),
318
+ thread: readPathValue(params, thread),
241
319
  },
242
320
  },
243
321
  });
@@ -256,6 +334,104 @@ export class Manager {
256
334
  };
257
335
  }
258
336
 
337
+ public async supportsChannelPermission(server: string): Promise<boolean> {
338
+ if (this.instances === null) {
339
+ this.reload();
340
+ }
341
+ const client = this.instances!.get(server);
342
+ if (!client) {
343
+ return false;
344
+ }
345
+ await client.connect();
346
+ const experimental =
347
+ client.client.getServerCapabilities()?.experimental ?? {};
348
+ return Boolean(get(experimental, [HOOMAN_CHANNEL_PERMISSION]));
349
+ }
350
+
351
+ public async requestChannelPermission(
352
+ server: string,
353
+ request: ChannelPermissionRequest,
354
+ timeoutMs = 120_000,
355
+ ): Promise<ChannelPermissionBehavior> {
356
+ if (this.instances === null) {
357
+ this.reload();
358
+ }
359
+ const client = this.instances!.get(server);
360
+ if (!client) {
361
+ throw new Error(`MCP server "${server}" is not connected.`);
362
+ }
363
+ await client.connect();
364
+ const experimental =
365
+ client.client.getServerCapabilities()?.experimental ?? {};
366
+ if (!Object.hasOwn(experimental, HOOMAN_CHANNEL_PERMISSION)) {
367
+ throw new Error(
368
+ `MCP server "${server}" does not support ${HOOMAN_CHANNEL_PERMISSION}.`,
369
+ );
370
+ }
371
+
372
+ const requestId = request.requestId.trim();
373
+ if (!requestId) {
374
+ throw new Error("requestId is required.");
375
+ }
376
+ const key = `${server}:${requestId}`;
377
+ if (this.pendingPermissions.has(key)) {
378
+ throw new Error(`Permission request "${requestId}" is already pending.`);
379
+ }
380
+
381
+ const response = new Promise<ChannelPermissionBehavior>(
382
+ (resolve, reject) => {
383
+ const timer = setTimeout(() => {
384
+ this.pendingPermissions.delete(key);
385
+ reject(
386
+ new Error(
387
+ `Permission request "${requestId}" timed out after ${timeoutMs}ms.`,
388
+ ),
389
+ );
390
+ }, timeoutMs);
391
+ this.pendingPermissions.set(key, { resolve, reject, timer });
392
+ },
393
+ );
394
+
395
+ try {
396
+ const sender = client.client as {
397
+ notification?: (payload: unknown) => Promise<void>;
398
+ };
399
+ if (typeof sender.notification !== "function") {
400
+ throw new Error(
401
+ `MCP client for "${server}" cannot send notifications.`,
402
+ );
403
+ }
404
+ await sender.notification({
405
+ method: "notifications/hooman/channel/permission_request",
406
+ params: {
407
+ request_id: requestId,
408
+ tool_name: request.tool,
409
+ description: request.description,
410
+ input_preview: request.preview,
411
+ options: [
412
+ { id: "allow_once", label: "Allow once" },
413
+ { id: "allow_always", label: "Always allow" },
414
+ { id: "deny", label: "Deny" },
415
+ ],
416
+ meta: {
417
+ ...(request.source ? { source: request.source } : {}),
418
+ ...(request.user ? { user: request.user } : {}),
419
+ ...(request.session ? { session: request.session } : {}),
420
+ ...(request.thread ? { thread: request.thread } : {}),
421
+ },
422
+ },
423
+ });
424
+ return await response;
425
+ } catch (error) {
426
+ const pending = this.pendingPermissions.get(key);
427
+ if (pending) {
428
+ clearTimeout(pending.timer);
429
+ this.pendingPermissions.delete(key);
430
+ }
431
+ throw error;
432
+ }
433
+ }
434
+
259
435
  private toChannelPrompt(method: string, params?: unknown): string {
260
436
  if (
261
437
  params &&
@@ -1,10 +1,15 @@
1
+ import { existsSync } from "fs";
1
2
  import { homedir } from "os";
2
3
  import { join } from "path";
3
4
 
4
- const HOME_FOLDER_NAME = ".hooman";
5
+ const APP_FOLDER = ".hooman";
5
6
 
6
7
  export const basePath = () => {
7
- return join(homedir(), HOME_FOLDER_NAME);
8
+ const local = join(process.cwd(), APP_FOLDER);
9
+ if (existsSync(local)) {
10
+ return local;
11
+ }
12
+ return join(homedir(), APP_FOLDER);
8
13
  };
9
14
 
10
15
  export const configJsonPath = () => {
@@ -0,0 +1,114 @@
1
+ import type { Agent, BeforeToolCallEvent } from "@strands-agents/sdk";
2
+ import type { Config } from "../core/config.ts";
3
+ import type { Manager as McpManager } from "../core/mcp/index.ts";
4
+ import { INTERNAL_ALWAYS_ALLOWED } from "../acp/utils/tool-kind.ts";
5
+
6
+ const INPUT_PREVIEW_LIMIT = 1_024;
7
+
8
+ type ChannelOrigin = {
9
+ server?: string;
10
+ source?: string;
11
+ user?: string;
12
+ session?: string;
13
+ thread?: string;
14
+ };
15
+
16
+ function randomRequestId(): string {
17
+ return crypto.randomUUID();
18
+ }
19
+
20
+ function inputPreview(input: unknown): string {
21
+ try {
22
+ const text = JSON.stringify(input, null, 2) ?? "null";
23
+ return text.length > INPUT_PREVIEW_LIMIT
24
+ ? `${text.slice(0, INPUT_PREVIEW_LIMIT)}\n... (truncated)`
25
+ : text;
26
+ } catch {
27
+ return String(input);
28
+ }
29
+ }
30
+
31
+ function readOrigin(agent: Agent): ChannelOrigin | null {
32
+ const raw = agent.appState.get("origin");
33
+ if (!raw || typeof raw !== "object") {
34
+ return null;
35
+ }
36
+ const entry = raw as Record<string, unknown>;
37
+ const text = (value: unknown): string | undefined => {
38
+ if (typeof value !== "string") {
39
+ return undefined;
40
+ }
41
+ const trimmed = value.trim();
42
+ return trimmed.length > 0 ? trimmed : undefined;
43
+ };
44
+ return {
45
+ server: text(entry.server),
46
+ source: text(entry.source),
47
+ user: text(entry.user),
48
+ session: text(entry.session),
49
+ thread: text(entry.thread),
50
+ };
51
+ }
52
+
53
+ export function createDaemonApprovalHandler(
54
+ config: Config,
55
+ manager: McpManager,
56
+ agent: Agent,
57
+ options?: { yolo?: boolean },
58
+ ): (event: BeforeToolCallEvent) => Promise<void> {
59
+ return async (event: BeforeToolCallEvent) => {
60
+ const name = event.toolUse.name;
61
+ if (options?.yolo) {
62
+ return;
63
+ }
64
+ if (
65
+ INTERNAL_ALWAYS_ALLOWED.has(name) ||
66
+ config.tools.allowed.includes(name)
67
+ ) {
68
+ return;
69
+ }
70
+
71
+ const origin = readOrigin(agent);
72
+ if (!origin?.server) {
73
+ event.cancel = `Tool "${name}" was denied: missing daemon origin context.`;
74
+ return;
75
+ }
76
+
77
+ const supported = await manager.supportsChannelPermission(origin.server);
78
+ if (!supported) {
79
+ event.cancel = `Tool "${name}" was denied: MCP server "${origin.server}" does not support hooman/channel/permission.`;
80
+ return;
81
+ }
82
+
83
+ let behavior: "allow_once" | "allow_always" | "deny";
84
+ try {
85
+ behavior = await manager.requestChannelPermission(origin.server, {
86
+ requestId: randomRequestId(),
87
+ tool: name,
88
+ description:
89
+ event.tool?.description?.trim() ??
90
+ `Run tool "${name}" in daemon mode.`,
91
+ preview: inputPreview(event.toolUse.input),
92
+ source: origin.source,
93
+ user: origin.user,
94
+ session: origin.session,
95
+ thread: origin.thread,
96
+ });
97
+ } catch (error) {
98
+ event.cancel = `Tool "${name}" was denied: failed to request permission (${error instanceof Error ? error.message : String(error)}).`;
99
+ return;
100
+ }
101
+
102
+ if (behavior === "allow_once") {
103
+ return;
104
+ }
105
+ if (behavior === "allow_always") {
106
+ if (!config.tools.allowed.includes(name)) {
107
+ config.update({ tools: { allowed: [...config.tools.allowed, name] } });
108
+ }
109
+ return;
110
+ }
111
+
112
+ event.cancel = `Tool "${name}" was rejected by remote approval.`;
113
+ };
114
+ }
@@ -1,5 +1,6 @@
1
1
  import { stderr } from "node:process";
2
2
  import type { Agent } from "@strands-agents/sdk";
3
+ import { HOOMAN_CHANNEL } from "../core/mcp/index.ts";
3
4
  import type {
4
5
  ChannelMessage,
5
6
  Manager as McpManager,
@@ -10,7 +11,7 @@ type RunDaemonOptions = {
10
11
  agent: Agent;
11
12
  manager: McpManager;
12
13
  session?: string;
13
- channels: string[];
14
+ channels: boolean;
14
15
  debug?: boolean;
15
16
  };
16
17
 
@@ -42,13 +43,11 @@ function resolveUserId(
42
43
  }
43
44
 
44
45
  export async function main(options: RunDaemonOptions): Promise<void> {
45
- const channels = [
46
- ...new Set(options.channels.map((value) => value.trim()).filter(Boolean)),
47
- ];
48
- if (channels.length === 0) {
49
- throw new Error("At least one --channel <name> is required.");
46
+ if (!options.channels) {
47
+ throw new Error("No daemon inputs enabled. Pass --channels.");
50
48
  }
51
- debug(`starting daemon for channels: ${channels.join(", ")}`);
49
+ const channels = [HOOMAN_CHANNEL];
50
+ debug(`starting daemon for channel(s): ${channels.join(", ")}`);
52
51
 
53
52
  let unsubscribe = () => {};
54
53
 
@@ -65,6 +64,14 @@ export async function main(options: RunDaemonOptions): Promise<void> {
65
64
 
66
65
  options.agent.appState.set("userId", user);
67
66
  options.agent.appState.set("sessionId", session);
67
+ options.agent.appState.set("origin", {
68
+ server: message.meta.server,
69
+ channel: message.meta.channel,
70
+ source: message.meta.source,
71
+ user: message.meta.identity.user,
72
+ session: message.meta.identity.session,
73
+ thread: message.meta.identity.thread,
74
+ });
68
75
 
69
76
  try {
70
77
  await options.agent.invoke(message.prompt);
@@ -59,9 +59,13 @@ type BeforeToolCallEventHandler = (event: BeforeToolCallEvent) => Promise<void>;
59
59
 
60
60
  export function createToolApprovalHandler(
61
61
  config: Config,
62
+ options?: { yolo?: boolean },
62
63
  ): BeforeToolCallEventHandler {
63
64
  return async function onBeforeToolCallEvent(event: BeforeToolCallEvent) {
64
65
  const name = event.toolUse.name;
66
+ if (options?.yolo) {
67
+ return;
68
+ }
65
69
  if (
66
70
  INTERNAL_ALWAYS_ALLOWED.has(name) ||
67
71
  config.tools.allowed.includes(name)