macro-agent 0.1.12 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent-manager-v2.d.ts.map +1 -1
- package/dist/agent/agent-manager-v2.js +240 -7
- package/dist/agent/agent-manager-v2.js.map +1 -1
- package/dist/agent/types.d.ts +47 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/boot-v2.d.ts +33 -0
- package/dist/boot-v2.d.ts.map +1 -1
- package/dist/boot-v2.js +142 -11
- package/dist/boot-v2.js.map +1 -1
- package/dist/cli/inbox-mcp-proxy.d.ts +36 -0
- package/dist/cli/inbox-mcp-proxy.d.ts.map +1 -0
- package/dist/cli/inbox-mcp-proxy.js +51 -0
- package/dist/cli/inbox-mcp-proxy.js.map +1 -0
- package/dist/dispatch/loadout-translation.d.ts +100 -0
- package/dist/dispatch/loadout-translation.d.ts.map +1 -0
- package/dist/dispatch/loadout-translation.js +90 -0
- package/dist/dispatch/loadout-translation.js.map +1 -0
- package/dist/dispatch/mail-inbound-consumer.d.ts +89 -0
- package/dist/dispatch/mail-inbound-consumer.d.ts.map +1 -0
- package/dist/dispatch/mail-inbound-consumer.js +261 -0
- package/dist/dispatch/mail-inbound-consumer.js.map +1 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.d.ts +75 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.d.ts.map +1 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.js +325 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.js.map +1 -0
- package/dist/dispatch/permission-evaluator.d.ts +68 -0
- package/dist/dispatch/permission-evaluator.d.ts.map +1 -0
- package/dist/dispatch/permission-evaluator.js +159 -0
- package/dist/dispatch/permission-evaluator.js.map +1 -0
- package/dist/dispatch/permission-overlay.d.ts +64 -0
- package/dist/dispatch/permission-overlay.d.ts.map +1 -0
- package/dist/dispatch/permission-overlay.js +72 -0
- package/dist/dispatch/permission-overlay.js.map +1 -0
- package/dist/dispatch/permissions-handler.d.ts +71 -0
- package/dist/dispatch/permissions-handler.d.ts.map +1 -0
- package/dist/dispatch/permissions-handler.js +83 -0
- package/dist/dispatch/permissions-handler.js.map +1 -0
- package/dist/dispatch/spawn-agent-handler.d.ts +84 -0
- package/dist/dispatch/spawn-agent-handler.d.ts.map +1 -0
- package/dist/dispatch/spawn-agent-handler.js +85 -0
- package/dist/dispatch/spawn-agent-handler.js.map +1 -0
- package/dist/lifecycle/handlers-v2.d.ts +7 -0
- package/dist/lifecycle/handlers-v2.d.ts.map +1 -1
- package/dist/lifecycle/handlers-v2.js +27 -0
- package/dist/lifecycle/handlers-v2.js.map +1 -1
- package/dist/map/lifecycle-bridge.d.ts +18 -0
- package/dist/map/lifecycle-bridge.d.ts.map +1 -1
- package/dist/map/lifecycle-bridge.js +23 -1
- package/dist/map/lifecycle-bridge.js.map +1 -1
- package/dist/map/mail-bridge.d.ts +55 -0
- package/dist/map/mail-bridge.d.ts.map +1 -0
- package/dist/map/mail-bridge.js +115 -0
- package/dist/map/mail-bridge.js.map +1 -0
- package/dist/map/sidecar.d.ts.map +1 -1
- package/dist/map/sidecar.js +245 -1
- package/dist/map/sidecar.js.map +1 -1
- package/dist/map/types.d.ts +15 -0
- package/dist/map/types.d.ts.map +1 -1
- package/dist/mcp/tools/done-v2.d.ts.map +1 -1
- package/dist/mcp/tools/done-v2.js +1 -0
- package/dist/mcp/tools/done-v2.js.map +1 -1
- package/dist/teams/team-loader.d.ts.map +1 -1
- package/dist/teams/team-loader.js.map +1 -1
- package/dist/teams/team-runtime-v2.d.ts.map +1 -1
- package/dist/teams/team-runtime-v2.js +2 -0
- package/dist/teams/team-runtime-v2.js.map +1 -1
- package/package.json +6 -5
- package/src/agent/__tests__/agent-manager-v2.permission-interception.test.ts +296 -0
- package/src/agent/__tests__/agent-manager-v2.permissions.test.ts +233 -0
- package/src/agent/agent-manager-v2.ts +268 -8
- package/src/agent/types.ts +51 -0
- package/src/boot-v2.ts +190 -12
- package/src/cli/inbox-mcp-proxy.ts +56 -0
- package/src/dispatch/CLAUDE.md +129 -0
- package/src/dispatch/__tests__/loadout-translation.test.ts +141 -0
- package/src/dispatch/__tests__/mail-inbound-consumer.integration.test.ts +519 -0
- package/src/dispatch/__tests__/mail-inbound-consumer.test.ts +589 -0
- package/src/dispatch/__tests__/mail-inbound-reuse-consumer.test.ts +575 -0
- package/src/dispatch/__tests__/permission-evaluator.test.ts +196 -0
- package/src/dispatch/__tests__/permission-overlay.test.ts +56 -0
- package/src/dispatch/__tests__/permissions-handler.test.ts +168 -0
- package/src/dispatch/__tests__/spawn-agent-handler.test.ts +282 -0
- package/src/dispatch/loadout-translation.ts +138 -0
- package/src/dispatch/mail-inbound-consumer.ts +397 -0
- package/src/dispatch/mail-inbound-reuse-consumer.ts +479 -0
- package/src/dispatch/permission-evaluator.ts +191 -0
- package/src/dispatch/permission-overlay.ts +89 -0
- package/src/dispatch/permissions-handler.ts +112 -0
- package/src/dispatch/spawn-agent-handler.ts +160 -0
- package/src/lifecycle/handlers-v2.ts +34 -0
- package/src/map/__tests__/lifecycle-bridge.test.ts +64 -0
- package/src/map/__tests__/mail-bridge.test.ts +196 -0
- package/src/map/lifecycle-bridge.ts +48 -2
- package/src/map/mail-bridge.ts +203 -0
- package/src/map/sidecar.ts +346 -1
- package/src/map/types.ts +21 -0
- package/src/mcp/tools/done-v2.ts +1 -0
- package/src/teams/team-loader.ts +3 -1
- package/src/teams/team-runtime-v2.ts +2 -0
- package/dist/workspace/dataplane-adapter.d.ts +0 -260
- package/dist/workspace/dataplane-adapter.d.ts.map +0 -1
- package/dist/workspace/dataplane-adapter.js +0 -416
- package/dist/workspace/dataplane-adapter.js.map +0 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for AgentManagerV2's permission + fullAutonomous spawn-config
|
|
3
|
+
* wiring. Pins the contract that `SpawnAgentOptions.permissions` is forwarded
|
|
4
|
+
* to `agentMeta.claudeCode.options.settings.permissions` on the Claude Code
|
|
5
|
+
* session, and that `ask` rules collapse based on `fullAutonomous`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
9
|
+
|
|
10
|
+
// IMPORTANT: vi.mock must run before agent-manager-v2 imports acp-factory.
|
|
11
|
+
//
|
|
12
|
+
// We intercept handle.createSession with a spy so each test can read back the
|
|
13
|
+
// `agentMeta` argument and assert against it. Re-using a mock fn means the
|
|
14
|
+
// first .mock.calls[0] always points to the most recent spawn() call.
|
|
15
|
+
const createSessionSpy = vi.fn();
|
|
16
|
+
|
|
17
|
+
vi.mock("acp-factory", () => ({
|
|
18
|
+
AgentFactory: {
|
|
19
|
+
spawn: vi.fn().mockResolvedValue({
|
|
20
|
+
createSession: (...args: unknown[]) => {
|
|
21
|
+
createSessionSpy(...args);
|
|
22
|
+
return Promise.resolve({
|
|
23
|
+
id: "provider-session-1",
|
|
24
|
+
prompt: vi.fn().mockReturnValue({
|
|
25
|
+
[Symbol.asyncIterator]: () => ({
|
|
26
|
+
next: () => Promise.resolve({ done: true, value: undefined }),
|
|
27
|
+
}),
|
|
28
|
+
}),
|
|
29
|
+
forkWithFlush: vi.fn().mockResolvedValue({ id: "forked-session-1" }),
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
loadSession: vi.fn().mockResolvedValue({
|
|
33
|
+
id: "loaded-session-1",
|
|
34
|
+
prompt: vi.fn().mockReturnValue({
|
|
35
|
+
[Symbol.asyncIterator]: () => ({
|
|
36
|
+
next: () => Promise.resolve({ done: true, value: undefined }),
|
|
37
|
+
}),
|
|
38
|
+
}),
|
|
39
|
+
}),
|
|
40
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
41
|
+
isRunning: vi.fn().mockReturnValue(true),
|
|
42
|
+
}),
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
import { createAgentManagerV2 } from "../agent-manager-v2.js";
|
|
47
|
+
import { AgentStore } from "../agent-store.js";
|
|
48
|
+
import type { AgentManager } from "../agent-manager.js";
|
|
49
|
+
import type { InboxAdapter, TasksAdapter } from "../../adapters/types.js";
|
|
50
|
+
|
|
51
|
+
function createMockInboxAdapter(): InboxAdapter {
|
|
52
|
+
return {
|
|
53
|
+
registerAgent: vi.fn().mockResolvedValue(undefined),
|
|
54
|
+
deregisterAgent: vi.fn().mockResolvedValue(undefined),
|
|
55
|
+
send: vi.fn().mockResolvedValue("msg-1"),
|
|
56
|
+
onDelivery: vi.fn(),
|
|
57
|
+
offDelivery: vi.fn(),
|
|
58
|
+
checkInbox: vi.fn().mockResolvedValue([]),
|
|
59
|
+
readThread: vi.fn().mockResolvedValue([]),
|
|
60
|
+
setSignalFilter: vi.fn(),
|
|
61
|
+
setEmissionValidator: vi.fn(),
|
|
62
|
+
socketPath: "/tmp/test-inbox.sock",
|
|
63
|
+
stop: vi.fn().mockResolvedValue(undefined),
|
|
64
|
+
} as unknown as InboxAdapter;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createMockTasksAdapter(): TasksAdapter {
|
|
68
|
+
return {
|
|
69
|
+
createTask: vi.fn().mockResolvedValue("ot-task-1"),
|
|
70
|
+
assignTask: vi.fn().mockResolvedValue(undefined),
|
|
71
|
+
transitionTask: vi.fn().mockResolvedValue(undefined),
|
|
72
|
+
getTask: vi.fn().mockResolvedValue({ id: "t-1", title: "test", status: "open" }),
|
|
73
|
+
queryReady: vi.fn().mockResolvedValue([]),
|
|
74
|
+
listTasks: vi.fn().mockResolvedValue([]),
|
|
75
|
+
addBlocker: vi.fn().mockResolvedValue(undefined),
|
|
76
|
+
removeBlocker: vi.fn().mockResolvedValue(undefined),
|
|
77
|
+
claimTask: vi.fn().mockResolvedValue(null),
|
|
78
|
+
unclaimTask: vi.fn().mockResolvedValue(undefined),
|
|
79
|
+
listClaimable: vi.fn().mockResolvedValue([]),
|
|
80
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
81
|
+
disconnect: vi.fn(),
|
|
82
|
+
connected: true,
|
|
83
|
+
} as unknown as TasksAdapter;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read back the second argument to handle.createSession from the most recent
|
|
88
|
+
* spawn() call. Returns the `agentMeta` field if present.
|
|
89
|
+
*/
|
|
90
|
+
function readLastSpawnAgentMeta(): Record<string, unknown> | undefined {
|
|
91
|
+
const calls = createSessionSpy.mock.calls;
|
|
92
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
93
|
+
const lastCall = calls[calls.length - 1];
|
|
94
|
+
// createSession(cwd, options)
|
|
95
|
+
const sessionOpts = lastCall[1] as Record<string, unknown> | undefined;
|
|
96
|
+
return sessionOpts?.agentMeta as Record<string, unknown> | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("AgentManagerV2 permissions + fullAutonomous spawn config", () => {
|
|
100
|
+
let agentStore: AgentStore;
|
|
101
|
+
let inboxAdapter: InboxAdapter;
|
|
102
|
+
let tasksAdapter: TasksAdapter;
|
|
103
|
+
let manager: AgentManager;
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
createSessionSpy.mockClear();
|
|
107
|
+
agentStore = new AgentStore(":memory:");
|
|
108
|
+
inboxAdapter = createMockInboxAdapter();
|
|
109
|
+
tasksAdapter = createMockTasksAdapter();
|
|
110
|
+
manager = createAgentManagerV2(agentStore, inboxAdapter, tasksAdapter, {
|
|
111
|
+
defaultCwd: "/tmp/test",
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(async () => {
|
|
116
|
+
await manager.close();
|
|
117
|
+
agentStore.close();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("permissions present + fullAutonomous: true → ask rules collapse into allow", async () => {
|
|
121
|
+
await manager.spawn({
|
|
122
|
+
task: "Test",
|
|
123
|
+
role: "worker",
|
|
124
|
+
permissions: {
|
|
125
|
+
deny: ["Bash(rm -rf:*)"],
|
|
126
|
+
ask: ["Write(*.env)"],
|
|
127
|
+
},
|
|
128
|
+
fullAutonomous: true,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const agentMeta = readLastSpawnAgentMeta();
|
|
132
|
+
expect(agentMeta).toBeDefined();
|
|
133
|
+
const cc = agentMeta!.claudeCode as {
|
|
134
|
+
options: { settings?: { permissions?: Record<string, string[]> } };
|
|
135
|
+
};
|
|
136
|
+
const perms = cc.options.settings?.permissions;
|
|
137
|
+
expect(perms).toBeDefined();
|
|
138
|
+
expect(perms!.deny).toContain("Bash(rm -rf:*)");
|
|
139
|
+
// fullAutonomous: true → ask collapses into allow
|
|
140
|
+
expect(perms!.allow).toContain("Write(*.env)");
|
|
141
|
+
// ask should NOT also be a literal field
|
|
142
|
+
expect(perms!.ask).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("permissions present + fullAutonomous: false → ask rules collapse into deny", async () => {
|
|
146
|
+
await manager.spawn({
|
|
147
|
+
task: "Test",
|
|
148
|
+
role: "worker",
|
|
149
|
+
permissions: {
|
|
150
|
+
deny: ["Bash(rm -rf:*)"],
|
|
151
|
+
ask: ["Write(*.env)"],
|
|
152
|
+
},
|
|
153
|
+
fullAutonomous: false,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const agentMeta = readLastSpawnAgentMeta();
|
|
157
|
+
expect(agentMeta).toBeDefined();
|
|
158
|
+
const cc = agentMeta!.claudeCode as {
|
|
159
|
+
options: { settings?: { permissions?: Record<string, string[]> } };
|
|
160
|
+
};
|
|
161
|
+
const perms = cc.options.settings?.permissions;
|
|
162
|
+
expect(perms).toBeDefined();
|
|
163
|
+
// Both original deny AND collapsed ask end up in deny
|
|
164
|
+
expect(perms!.deny).toContain("Bash(rm -rf:*)");
|
|
165
|
+
expect(perms!.deny).toContain("Write(*.env)");
|
|
166
|
+
expect(perms!.allow).toBeUndefined();
|
|
167
|
+
expect(perms!.ask).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("no permissions → no agentMeta.claudeCode.options.settings.permissions", async () => {
|
|
171
|
+
await manager.spawn({
|
|
172
|
+
task: "Test",
|
|
173
|
+
role: "worker",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const agentMeta = readLastSpawnAgentMeta();
|
|
177
|
+
// agentMeta should be entirely absent (no settingSources, no settings)
|
|
178
|
+
if (agentMeta) {
|
|
179
|
+
const cc = agentMeta.claudeCode as
|
|
180
|
+
| { options: { settings?: { permissions?: unknown } } }
|
|
181
|
+
| undefined;
|
|
182
|
+
expect(cc?.options?.settings?.permissions).toBeUndefined();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("isolatedSettings: true + permissions → both settingSources=[] AND settings.permissions present", async () => {
|
|
187
|
+
await manager.spawn({
|
|
188
|
+
task: "Test",
|
|
189
|
+
role: "worker",
|
|
190
|
+
isolatedSettings: true,
|
|
191
|
+
permissions: { deny: ["Bash(rm -rf:*)"] },
|
|
192
|
+
fullAutonomous: true,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const agentMeta = readLastSpawnAgentMeta();
|
|
196
|
+
expect(agentMeta).toBeDefined();
|
|
197
|
+
const cc = agentMeta!.claudeCode as {
|
|
198
|
+
options: {
|
|
199
|
+
settingSources?: unknown[];
|
|
200
|
+
settings?: { permissions?: Record<string, string[]> };
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
expect(cc.options.settingSources).toEqual([]);
|
|
204
|
+
expect(cc.options.settings?.permissions?.deny).toContain("Bash(rm -rf:*)");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("permissions with all empty arrays → no settings.permissions key written", async () => {
|
|
208
|
+
await manager.spawn({
|
|
209
|
+
task: "Test",
|
|
210
|
+
role: "worker",
|
|
211
|
+
permissions: { allow: [], deny: [], ask: [] },
|
|
212
|
+
fullAutonomous: true,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const agentMeta = readLastSpawnAgentMeta();
|
|
216
|
+
// The spawn handler always writes a `settings` object when permissions are
|
|
217
|
+
// truthy, but with empty allow/deny it should NOT include the rule keys —
|
|
218
|
+
// i.e. settings.permissions has no `allow` and no `deny`.
|
|
219
|
+
if (agentMeta) {
|
|
220
|
+
const cc = agentMeta.claudeCode as {
|
|
221
|
+
options: { settings?: { permissions?: Record<string, string[]> } };
|
|
222
|
+
};
|
|
223
|
+
const perms = cc.options.settings?.permissions;
|
|
224
|
+
// perms is either undefined or an empty object — both acceptable.
|
|
225
|
+
// Critically: no allow/deny/ask arrays leak through with empty content.
|
|
226
|
+
if (perms) {
|
|
227
|
+
expect(perms.allow).toBeUndefined();
|
|
228
|
+
expect(perms.deny).toBeUndefined();
|
|
229
|
+
expect(perms.ask).toBeUndefined();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -65,11 +65,61 @@ import { AgentTokenManager } from "../auth/token.js";
|
|
|
65
65
|
import type { InboxAdapter } from "../adapters/types.js";
|
|
66
66
|
import type { TasksAdapter } from "../adapters/types.js";
|
|
67
67
|
import type { AgentManager, SpawnInterceptor } from "./agent-manager.js";
|
|
68
|
+
import { getPermissionOverlay } from "../dispatch/permission-overlay.js";
|
|
69
|
+
import { evaluatePermission } from "../dispatch/permission-evaluator.js";
|
|
68
70
|
|
|
69
71
|
// ─────────────────────────────────────────────────────────────────
|
|
70
72
|
// Helper
|
|
71
73
|
// ─────────────────────────────────────────────────────────────────
|
|
72
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Derive the canonical Claude Code tool name from a `permission_request`
|
|
77
|
+
* `toolCall` object.
|
|
78
|
+
*
|
|
79
|
+
* Why this is needed: claude-agent-acp's `toolInfoFromToolUse` mangles
|
|
80
|
+
* built-in tool names into display titles (e.g., `Read /tmp/x` instead of
|
|
81
|
+
* `Read`). MCP tools use their fully-qualified `mcp__server__tool` name as
|
|
82
|
+
* the title. The `kind` field (Claude SDK's tool category) is the
|
|
83
|
+
* cleanest signal for built-ins, with the input shape disambiguating
|
|
84
|
+
* within a category (e.g., `edit` covers Write/Edit/MultiEdit — we look
|
|
85
|
+
* at `old_string`/`edits` to pick which).
|
|
86
|
+
*
|
|
87
|
+
* Pure: no side effects; safe to call from the prompt iterator.
|
|
88
|
+
*/
|
|
89
|
+
function deriveToolName(toolCall: {
|
|
90
|
+
title?: string;
|
|
91
|
+
kind?: string;
|
|
92
|
+
rawInput?: unknown;
|
|
93
|
+
} | undefined): string {
|
|
94
|
+
const title = toolCall?.title ?? "";
|
|
95
|
+
const kind = toolCall?.kind ?? "";
|
|
96
|
+
// MCP tools — title is the canonical name.
|
|
97
|
+
if (title.startsWith("mcp__")) return title.split(/\s/)[0] ?? "";
|
|
98
|
+
const input =
|
|
99
|
+
toolCall?.rawInput && typeof toolCall.rawInput === "object"
|
|
100
|
+
? (toolCall.rawInput as Record<string, unknown>)
|
|
101
|
+
: {};
|
|
102
|
+
switch (kind) {
|
|
103
|
+
case "read":
|
|
104
|
+
return "Read";
|
|
105
|
+
case "execute":
|
|
106
|
+
return "Bash";
|
|
107
|
+
case "edit":
|
|
108
|
+
if ("edits" in input) return "MultiEdit";
|
|
109
|
+
if ("old_string" in input) return "Edit";
|
|
110
|
+
return "Write";
|
|
111
|
+
case "search":
|
|
112
|
+
return "path" in input ? "Grep" : "Glob";
|
|
113
|
+
case "think":
|
|
114
|
+
return "Task";
|
|
115
|
+
case "switch_mode":
|
|
116
|
+
return "ExitPlanMode";
|
|
117
|
+
}
|
|
118
|
+
// Fallback: first whitespace-delimited token of the title (Read/Write/
|
|
119
|
+
// Edit cases not caught above; built-ins newer than this matrix).
|
|
120
|
+
return title.split(/\s/)[0] ?? "";
|
|
121
|
+
}
|
|
122
|
+
|
|
73
123
|
function getSpawnCapability(childRole: string): Capability {
|
|
74
124
|
const baseRole = childRole.split(".")[0];
|
|
75
125
|
switch (baseRole) {
|
|
@@ -624,7 +674,12 @@ export function createAgentManagerV2(
|
|
|
624
674
|
created_at: now,
|
|
625
675
|
started_at: now,
|
|
626
676
|
config: agentConfig as Record<string, unknown>,
|
|
627
|
-
metadata:
|
|
677
|
+
metadata: {
|
|
678
|
+
...(options.taskRef ? { task_ref: options.taskRef } : {}),
|
|
679
|
+
// Persist isolatedSettings so resume() applies the same agentMeta
|
|
680
|
+
// policy without needing the original SpawnAgentOptions.
|
|
681
|
+
...(options.isolatedSettings ? { isolatedSettings: true } : {}),
|
|
682
|
+
},
|
|
628
683
|
};
|
|
629
684
|
agentStore.putAgent(agentRecord);
|
|
630
685
|
|
|
@@ -714,6 +769,57 @@ export function createAgentManagerV2(
|
|
|
714
769
|
})) ?? []),
|
|
715
770
|
];
|
|
716
771
|
|
|
772
|
+
// Always-on subsystem MCP servers (the "trinity"). The macro-agent
|
|
773
|
+
// architecture docs describe agent-inbox + opentasks as separate MCP
|
|
774
|
+
// servers available to spawned workers, but until this entry block
|
|
775
|
+
// existed they were only reachable when host-level Claude plugins
|
|
776
|
+
// happened to have wired them. That left mail-inbound workers
|
|
777
|
+
// (`parent: null` + `isolatedSettings: true`) without inbox/tasks
|
|
778
|
+
// tools — see openhive-2 docs/LOADOUTS_DESIGN.md "Loadout-provided
|
|
779
|
+
// MCP servers" live finding 2026-05-03.
|
|
780
|
+
//
|
|
781
|
+
// Registering them here makes them per-spawn defaults independent
|
|
782
|
+
// of host configuration. Caller-supplied `agentConfig.mcpServers`
|
|
783
|
+
// remains additive (Option C / "hybrid"): the trinity is always
|
|
784
|
+
// there, callers can layer more on top.
|
|
785
|
+
|
|
786
|
+
// agent-inbox — exposes send_message, check_inbox, read_thread,
|
|
787
|
+
// list_agents via the InboxMcpProxy stdio bridge.
|
|
788
|
+
if (inboxAdapter.socketPath) {
|
|
789
|
+
const inboxProxyEntry = new URL(
|
|
790
|
+
"../../dist/cli/inbox-mcp-proxy.js",
|
|
791
|
+
import.meta.url,
|
|
792
|
+
).pathname;
|
|
793
|
+
mcpServers.push({
|
|
794
|
+
name: "agent-inbox",
|
|
795
|
+
command: "node",
|
|
796
|
+
args: [inboxProxyEntry],
|
|
797
|
+
env: [
|
|
798
|
+
{ name: "INBOX_SOCKET_PATH", value: inboxAdapter.socketPath },
|
|
799
|
+
{ name: "MACRO_AGENT_ID", value: agentId },
|
|
800
|
+
],
|
|
801
|
+
} as any);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// opentasks — exposes task, link, annotate, query via the
|
|
805
|
+
// `opentasks mcp` CLI subcommand. The package's dist/mcp/stdio.js
|
|
806
|
+
// is an exports-only module (no auto-start); the CLI's `mcp`
|
|
807
|
+
// subcommand is what actually wires StdioServerTransport. Conditional
|
|
808
|
+
// on tasksAdapter.connected — when the daemon isn't running, skip
|
|
809
|
+
// rather than mount a server that would fail at every tool call.
|
|
810
|
+
if (tasksAdapter.connected) {
|
|
811
|
+
const opentasksCliEntry = new URL(
|
|
812
|
+
"opentasks/dist/cli.js",
|
|
813
|
+
import.meta.url,
|
|
814
|
+
).pathname;
|
|
815
|
+
mcpServers.push({
|
|
816
|
+
name: "opentasks",
|
|
817
|
+
command: "node",
|
|
818
|
+
args: [opentasksCliEntry, "mcp"],
|
|
819
|
+
env: [],
|
|
820
|
+
} as any);
|
|
821
|
+
}
|
|
822
|
+
|
|
717
823
|
// Register minimem MCP server (agent-type independent — works for any MCP-capable agent)
|
|
718
824
|
if (minimemConfig?.enabled) {
|
|
719
825
|
mcpServers.push({
|
|
@@ -729,12 +835,98 @@ export function createAgentManagerV2(
|
|
|
729
835
|
} as any);
|
|
730
836
|
}
|
|
731
837
|
|
|
732
|
-
// Build agentMeta
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
838
|
+
// Build agentMeta. Two layers:
|
|
839
|
+
//
|
|
840
|
+
// 1. `settingSources: []` — when the caller requests isolated
|
|
841
|
+
// settings (mail-inbound dispatch workers via
|
|
842
|
+
// SpawnAgentOptions.isolatedSettings), strip user/project/local
|
|
843
|
+
// setting sources so the worker doesn't load the host's
|
|
844
|
+
// claude-code-swarm / oh-my-claudecode / etc plugin MCP servers
|
|
845
|
+
// — those plugins assume host-shaped environment (sockets,
|
|
846
|
+
// daemons) and hang at session/new MCP-init when missing.
|
|
847
|
+
// Interactive `multiagent` callers leave this false so their
|
|
848
|
+
// installed plugins load normally.
|
|
849
|
+
//
|
|
850
|
+
// 2. `settings.permissions` — when the caller passes
|
|
851
|
+
// SpawnAgentOptions.permissions (e.g., from a materialized
|
|
852
|
+
// loadout), wire the rules inline via the Claude Agent SDK's
|
|
853
|
+
// session-level settings pass-through. Verified live: `deny`
|
|
854
|
+
// wins even over `permissionMode: "auto-approve"`. Inline
|
|
855
|
+
// wiring avoids file collisions when concurrent workers share
|
|
856
|
+
// a CWD (no `.claude/settings.json` written to disk).
|
|
857
|
+
//
|
|
858
|
+
// SDK contract pinned by the boundary test in
|
|
859
|
+
// `src/agent/__tests__/agent-manager-v2.permissions.test.ts` —
|
|
860
|
+
// it captures the literal `agentMeta` argument passed to
|
|
861
|
+
// `handle.createSession` and asserts both `settingSources: []`
|
|
862
|
+
// AND `settings.permissions` are present together (the
|
|
863
|
+
// interaction between filesystem-stripping and inline
|
|
864
|
+
// reconciliation that the SDK's docs don't fully spell out).
|
|
865
|
+
// If a future SDK version changes how `settings` reconciles
|
|
866
|
+
// with empty `settingSources`, that test will catch it.
|
|
867
|
+
//
|
|
868
|
+
// `ask` rules collapse based on `fullAutonomous`:
|
|
869
|
+
// - fullAutonomous: true → ask → allow (autonomous worker
|
|
870
|
+
// opts to proceed when there's no human to answer)
|
|
871
|
+
// - fullAutonomous: false → ask → deny (safe default;
|
|
872
|
+
// autonomous workers shouldn't make judgment calls)
|
|
873
|
+
const claudeCodeOptions: Record<string, any> = {};
|
|
874
|
+
if (options.isolatedSettings || permissionMode === "interactive") {
|
|
875
|
+
claudeCodeOptions.settingSources = [];
|
|
737
876
|
}
|
|
877
|
+
if (options.permissions) {
|
|
878
|
+
const { allow = [], deny = [], ask = [] } = options.permissions;
|
|
879
|
+
const finalAllow = options.fullAutonomous ? [...allow, ...ask] : [...allow];
|
|
880
|
+
const finalDeny = options.fullAutonomous ? [...deny] : [...deny, ...ask];
|
|
881
|
+
claudeCodeOptions.settings = {
|
|
882
|
+
permissions: {
|
|
883
|
+
...(finalAllow.length ? { allow: finalAllow } : {}),
|
|
884
|
+
...(finalDeny.length ? { deny: finalDeny } : {}),
|
|
885
|
+
},
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
// P3 spike: when an agent should funnel every tool call through the host
|
|
889
|
+
// (so the prompt-iterator handler can apply runtime overlays), set
|
|
890
|
+
// `ask: ['*']` on settings.permissions. The SDK then consults canUseTool
|
|
891
|
+
// for every tool, which emits `permission_request` session updates.
|
|
892
|
+
// Used for dispatch-target agents (mail+reuse, ACP+reuse) that need
|
|
893
|
+
// dynamic enforcement; chat agents and parented children stay on
|
|
894
|
+
// their session's static rules.
|
|
895
|
+
if (options.askForAllTools) {
|
|
896
|
+
const existingPerms =
|
|
897
|
+
claudeCodeOptions.settings?.permissions ?? {};
|
|
898
|
+
claudeCodeOptions.settings = {
|
|
899
|
+
...claudeCodeOptions.settings,
|
|
900
|
+
permissions: {
|
|
901
|
+
...existingPerms,
|
|
902
|
+
ask: ["*"],
|
|
903
|
+
},
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// 3. Runtime permission overlay enforcement lives in the prompt
|
|
908
|
+
// iterator (see `prompt()` below), NOT here at spawn time.
|
|
909
|
+
//
|
|
910
|
+
// Background: an earlier design installed a Claude SDK PreToolUse
|
|
911
|
+
// hook here that closed over the per-process permission-overlay
|
|
912
|
+
// registry. That mechanism was verified broken: function callbacks
|
|
913
|
+
// inside arrays don't survive JSON.stringify across the
|
|
914
|
+
// macro-agent → claude-agent-acp stdio JSON-RPC boundary, so the
|
|
915
|
+
// hook arrived as `null` at the SDK and silently no-op'd.
|
|
916
|
+
//
|
|
917
|
+
// The current design uses ACP's `permission_request` session
|
|
918
|
+
// update path instead. When an agent is spawned with
|
|
919
|
+
// `askForAllTools: true` + `permissionMode: 'interactive'`, the
|
|
920
|
+
// SDK consults `canUseTool` on every tool call, claude-agent-acp
|
|
921
|
+
// converts that into a `client.requestPermission` call, and
|
|
922
|
+
// acp-factory emits it as a `permission_request` session update.
|
|
923
|
+
// The prompt iterator below intercepts those updates, evaluates
|
|
924
|
+
// against the overlay, and responds via `respondPermission`.
|
|
925
|
+
// See `docs/PERMISSION_OVERLAY_ACP_DESIGN.md` for the full design.
|
|
926
|
+
|
|
927
|
+
const agentMeta = Object.keys(claudeCodeOptions).length > 0
|
|
928
|
+
? { claudeCode: { options: claudeCodeOptions } }
|
|
929
|
+
: undefined;
|
|
738
930
|
|
|
739
931
|
// Build capabilities context + skill-tree loadout for system prompt
|
|
740
932
|
// Matches cc-swarm's context injection pattern (role-aware, tool-specific)
|
|
@@ -1094,8 +1286,12 @@ export function createAgentManagerV2(
|
|
|
1094
1286
|
},
|
|
1095
1287
|
];
|
|
1096
1288
|
|
|
1289
|
+
// Strip user/project/local setting sources for isolated workers (the
|
|
1290
|
+
// metadata flag is set at spawn time when SpawnAgentOptions.isolatedSettings
|
|
1291
|
+
// was true) or interactive mode. See spawn() for the rationale.
|
|
1292
|
+
const isIsolated = (record.metadata as Record<string, unknown> | undefined)?.isolatedSettings === true;
|
|
1097
1293
|
const agentMeta =
|
|
1098
|
-
permMode === "interactive"
|
|
1294
|
+
isIsolated || permMode === "interactive"
|
|
1099
1295
|
? { claudeCode: { options: { settingSources: [] } } }
|
|
1100
1296
|
: undefined;
|
|
1101
1297
|
|
|
@@ -1477,7 +1673,71 @@ export function createAgentManagerV2(
|
|
|
1477
1673
|
|
|
1478
1674
|
activeSession.isPrompting = true;
|
|
1479
1675
|
try {
|
|
1480
|
-
|
|
1676
|
+
// Permission overlay enforcement — for agents in dispatch context
|
|
1677
|
+
// (mail-inbound + ACP reuse targets), the dispatch consumer sets a
|
|
1678
|
+
// per-agent overlay before driving prompt(). When set, `permission_request`
|
|
1679
|
+
// session updates are intercepted here, evaluated against the overlay,
|
|
1680
|
+
// and answered via `respondToPermission`. The update is NOT yielded
|
|
1681
|
+
// to the consumer in that case — dispatch enforcement is internal.
|
|
1682
|
+
//
|
|
1683
|
+
// When no overlay is set (the common case — chat agents, sub-agents
|
|
1684
|
+
// spawned by parents, etc.), permission_request updates are yielded
|
|
1685
|
+
// through unchanged so chat surfaces' UI permission dialogs (the
|
|
1686
|
+
// swarmcraft PermissionDialog rendered via the openhive-acp-service
|
|
1687
|
+
// WS subscription) keep working.
|
|
1688
|
+
//
|
|
1689
|
+
// See `docs/PERMISSION_OVERLAY_ACP_DESIGN.md` for the rationale and
|
|
1690
|
+
// a diagram of the four-process flow.
|
|
1691
|
+
for await (const update of activeSession.session.prompt(message)) {
|
|
1692
|
+
const u = update as {
|
|
1693
|
+
sessionUpdate?: string;
|
|
1694
|
+
requestId?: string;
|
|
1695
|
+
toolCall?: { title?: string; kind?: string; rawInput?: unknown };
|
|
1696
|
+
options?: Array<{ kind?: string; optionId?: string }>;
|
|
1697
|
+
};
|
|
1698
|
+
if (u?.sessionUpdate === "permission_request") {
|
|
1699
|
+
const overlay = getPermissionOverlay(agentId);
|
|
1700
|
+
// No overlay → pass through to the consumer (chat UI, etc.).
|
|
1701
|
+
if (!overlay) {
|
|
1702
|
+
yield update;
|
|
1703
|
+
continue;
|
|
1704
|
+
}
|
|
1705
|
+
let optionId: string | undefined;
|
|
1706
|
+
try {
|
|
1707
|
+
const toolName = deriveToolName(u.toolCall);
|
|
1708
|
+
const toolInput = u.toolCall?.rawInput ?? {};
|
|
1709
|
+
const decision = evaluatePermission(toolName, toolInput, overlay)
|
|
1710
|
+
.decision;
|
|
1711
|
+
const wantedKind =
|
|
1712
|
+
decision === "deny" ? "reject_once" : "allow_once";
|
|
1713
|
+
const opt =
|
|
1714
|
+
u.options?.find((o) => o.kind === wantedKind) ??
|
|
1715
|
+
u.options?.find(
|
|
1716
|
+
(o) =>
|
|
1717
|
+
o.kind === (decision === "deny" ? "reject_always" : "allow_always"),
|
|
1718
|
+
);
|
|
1719
|
+
optionId = opt?.optionId;
|
|
1720
|
+
} catch {
|
|
1721
|
+
// Fail closed: on registry/evaluator error, deny.
|
|
1722
|
+
optionId = u.options?.find((o) => o.kind === "reject_once")?.optionId;
|
|
1723
|
+
}
|
|
1724
|
+
if (u.requestId && optionId) {
|
|
1725
|
+
try {
|
|
1726
|
+
(activeSession.session as any).respondToPermission?.(
|
|
1727
|
+
u.requestId,
|
|
1728
|
+
optionId,
|
|
1729
|
+
);
|
|
1730
|
+
} catch (err) {
|
|
1731
|
+
console.warn(
|
|
1732
|
+
`[perm-overlay] respondToPermission failed agent=${agentId} req=${u.requestId}: ${(err as Error).message}`,
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
// Don't yield permission_request to the consumer — dispatch-internal.
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
yield update;
|
|
1740
|
+
}
|
|
1481
1741
|
} finally {
|
|
1482
1742
|
activeSession.isPrompting = false;
|
|
1483
1743
|
agentStore.updateAgent(agentId, {
|
package/src/agent/types.ts
CHANGED
|
@@ -78,6 +78,57 @@ export interface SpawnAgentOptions {
|
|
|
78
78
|
/** Team instance ID this agent belongs to (set by TeamManager interceptor) */
|
|
79
79
|
team_instance?: string;
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* When true, spawn the worker with `claudeCode.options.settingSources = []`
|
|
83
|
+
* so it does NOT inherit the host machine's user/project/local Claude
|
|
84
|
+
* settings. This prevents host-level plugin MCP servers (e.g.
|
|
85
|
+
* claude-code-swarm, oh-my-claudecode) from auto-mounting and hanging
|
|
86
|
+
* the worker's session/new at MCP-init time. Mail-inbound dispatch
|
|
87
|
+
* workers always set this; interactive `multiagent` users typically
|
|
88
|
+
* don't (they want their plugins).
|
|
89
|
+
*/
|
|
90
|
+
isolatedSettings?: boolean;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Per-spawn permission rules — Claude Code permission patterns
|
|
94
|
+
* (e.g., `Read(**)`, `Bash(rm -rf:*)`).
|
|
95
|
+
*
|
|
96
|
+
* Wired through `agentMeta.claudeCode.options.settings.permissions` so
|
|
97
|
+
* Claude's permission engine consults them on every tool call. `deny`
|
|
98
|
+
* always wins, even over `permissionMode: "auto-approve"` (verified live).
|
|
99
|
+
*
|
|
100
|
+
* `ask` resolution depends on `fullAutonomous`:
|
|
101
|
+
* - fullAutonomous: true → `ask` rules collapse to `allow` (no human
|
|
102
|
+
* to answer; opt-in by callers like the mail-inbound consumer)
|
|
103
|
+
* - fullAutonomous: false → `ask` rules collapse to `deny` (safe
|
|
104
|
+
* default — autonomous workers shouldn't make judgment calls)
|
|
105
|
+
*
|
|
106
|
+
* No file I/O — passed inline via SDK options, so concurrent spawns
|
|
107
|
+
* sharing a CWD never collide on `.claude/settings.json`.
|
|
108
|
+
*/
|
|
109
|
+
permissions?: {
|
|
110
|
+
allow?: string[];
|
|
111
|
+
deny?: string[];
|
|
112
|
+
ask?: string[];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* When true, treat `permissions.ask` rules as auto-approve. Use only for
|
|
117
|
+
* autonomous workers with no user-interactive permission round-trip
|
|
118
|
+
* available (e.g., mail-inbound dispatch). Default: false.
|
|
119
|
+
*/
|
|
120
|
+
fullAutonomous?: boolean;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* When true, set `settings.permissions.ask: ['*']` so the SDK consults
|
|
124
|
+
* `canUseTool` for every tool call, emitting `permission_request` session
|
|
125
|
+
* updates the host's prompt iterator can intercept. Used for dispatch-
|
|
126
|
+
* target agents (mail+reuse, ACP+reuse) where the dispatch consumer
|
|
127
|
+
* applies per-call deny rules via the permission overlay registry.
|
|
128
|
+
* Default: false (chat agents and parented children stay on static rules).
|
|
129
|
+
*/
|
|
130
|
+
askForAllTools?: boolean;
|
|
131
|
+
|
|
81
132
|
/**
|
|
82
133
|
* Stream ID to join (for workers and integrators).
|
|
83
134
|
* Required for workers and integrators when using workspace isolation.
|