hoomanjs 1.7.0 → 1.8.1
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 +32 -16
- package/package.json +1 -1
- package/src/chat/app.tsx +4 -2
- package/src/chat/approvals.ts +4 -0
- package/src/chat/index.tsx +2 -0
- package/src/cli.ts +22 -13
- package/src/configure/app.tsx +1 -1
- package/src/core/mcp/index.ts +9 -2
- package/src/core/mcp/manager.ts +179 -3
- package/src/core/skills/registry.ts +2 -2
- package/src/core/utils/paths.ts +7 -2
- package/src/daemon/approvals.ts +114 -0
- package/src/daemon/index.ts +14 -7
- package/src/exec/approvals.ts +4 -0
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 --
|
|
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
|
-
|
|
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
|
|
147
|
+
hooman chat --yolo
|
|
144
148
|
```
|
|
145
149
|
|
|
146
|
-
|
|
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 --
|
|
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 --
|
|
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 --
|
|
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
|
|
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
|
-
|
|
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`
|
|
444
|
-
-
|
|
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
|
|
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
|
-
|
|
468
|
+
./.hooman/skills # when ./.hooman exists
|
|
469
|
+
~/.hooman/skills # otherwise
|
|
454
470
|
```
|
|
455
471
|
|
|
456
472
|
The configure workflow can:
|
package/package.json
CHANGED
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]);
|
package/src/chat/approvals.ts
CHANGED
|
@@ -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)
|
package/src/chat/index.tsx
CHANGED
|
@@ -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(
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
});
|
package/src/configure/app.tsx
CHANGED
|
@@ -838,7 +838,7 @@ export function ConfigureApp({
|
|
|
838
838
|
),
|
|
839
839
|
boldSubstring: result.name,
|
|
840
840
|
value: () => {
|
|
841
|
-
const source = result.
|
|
841
|
+
const source = result.slug || result.source;
|
|
842
842
|
void runTask(`Installing ${result.name}...`, async () => {
|
|
843
843
|
await skills.install(source);
|
|
844
844
|
await refreshSkills("Refreshing installed skills...");
|
package/src/core/mcp/index.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { Config, type NamedMcpTransport } from "./config.ts";
|
|
2
|
-
import {
|
|
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
|
|
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 {
|
package/src/core/mcp/manager.ts
CHANGED
|
@@ -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: "
|
|
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, "
|
|
210
|
-
const session = readIdentityPath(experimental, "
|
|
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 &&
|
|
@@ -209,7 +209,7 @@ export class Registry {
|
|
|
209
209
|
}
|
|
210
210
|
const data = (await res.json()) as {
|
|
211
211
|
skills: Array<{
|
|
212
|
-
|
|
212
|
+
skillId: string;
|
|
213
213
|
name: string;
|
|
214
214
|
installs: number;
|
|
215
215
|
source: string;
|
|
@@ -223,7 +223,7 @@ export class Registry {
|
|
|
223
223
|
return data.skills
|
|
224
224
|
.map((skill) => ({
|
|
225
225
|
name: skill.name,
|
|
226
|
-
slug: skill.
|
|
226
|
+
slug: `${skill.source.trim()}@${skill.skillId.trim()}`,
|
|
227
227
|
source: skill.source || "",
|
|
228
228
|
installs: skill.installs,
|
|
229
229
|
}))
|
package/src/core/utils/paths.ts
CHANGED
|
@@ -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
|
|
5
|
+
const APP_FOLDER = ".hooman";
|
|
5
6
|
|
|
6
7
|
export const basePath = () => {
|
|
7
|
-
|
|
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
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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);
|
package/src/exec/approvals.ts
CHANGED
|
@@ -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)
|