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.
Files changed (104) hide show
  1. package/dist/agent/agent-manager-v2.d.ts.map +1 -1
  2. package/dist/agent/agent-manager-v2.js +240 -7
  3. package/dist/agent/agent-manager-v2.js.map +1 -1
  4. package/dist/agent/types.d.ts +47 -0
  5. package/dist/agent/types.d.ts.map +1 -1
  6. package/dist/agent/types.js.map +1 -1
  7. package/dist/boot-v2.d.ts +33 -0
  8. package/dist/boot-v2.d.ts.map +1 -1
  9. package/dist/boot-v2.js +142 -11
  10. package/dist/boot-v2.js.map +1 -1
  11. package/dist/cli/inbox-mcp-proxy.d.ts +36 -0
  12. package/dist/cli/inbox-mcp-proxy.d.ts.map +1 -0
  13. package/dist/cli/inbox-mcp-proxy.js +51 -0
  14. package/dist/cli/inbox-mcp-proxy.js.map +1 -0
  15. package/dist/dispatch/loadout-translation.d.ts +100 -0
  16. package/dist/dispatch/loadout-translation.d.ts.map +1 -0
  17. package/dist/dispatch/loadout-translation.js +90 -0
  18. package/dist/dispatch/loadout-translation.js.map +1 -0
  19. package/dist/dispatch/mail-inbound-consumer.d.ts +89 -0
  20. package/dist/dispatch/mail-inbound-consumer.d.ts.map +1 -0
  21. package/dist/dispatch/mail-inbound-consumer.js +261 -0
  22. package/dist/dispatch/mail-inbound-consumer.js.map +1 -0
  23. package/dist/dispatch/mail-inbound-reuse-consumer.d.ts +75 -0
  24. package/dist/dispatch/mail-inbound-reuse-consumer.d.ts.map +1 -0
  25. package/dist/dispatch/mail-inbound-reuse-consumer.js +325 -0
  26. package/dist/dispatch/mail-inbound-reuse-consumer.js.map +1 -0
  27. package/dist/dispatch/permission-evaluator.d.ts +68 -0
  28. package/dist/dispatch/permission-evaluator.d.ts.map +1 -0
  29. package/dist/dispatch/permission-evaluator.js +159 -0
  30. package/dist/dispatch/permission-evaluator.js.map +1 -0
  31. package/dist/dispatch/permission-overlay.d.ts +64 -0
  32. package/dist/dispatch/permission-overlay.d.ts.map +1 -0
  33. package/dist/dispatch/permission-overlay.js +72 -0
  34. package/dist/dispatch/permission-overlay.js.map +1 -0
  35. package/dist/dispatch/permissions-handler.d.ts +71 -0
  36. package/dist/dispatch/permissions-handler.d.ts.map +1 -0
  37. package/dist/dispatch/permissions-handler.js +83 -0
  38. package/dist/dispatch/permissions-handler.js.map +1 -0
  39. package/dist/dispatch/spawn-agent-handler.d.ts +84 -0
  40. package/dist/dispatch/spawn-agent-handler.d.ts.map +1 -0
  41. package/dist/dispatch/spawn-agent-handler.js +85 -0
  42. package/dist/dispatch/spawn-agent-handler.js.map +1 -0
  43. package/dist/lifecycle/handlers-v2.d.ts +7 -0
  44. package/dist/lifecycle/handlers-v2.d.ts.map +1 -1
  45. package/dist/lifecycle/handlers-v2.js +27 -0
  46. package/dist/lifecycle/handlers-v2.js.map +1 -1
  47. package/dist/map/lifecycle-bridge.d.ts +18 -0
  48. package/dist/map/lifecycle-bridge.d.ts.map +1 -1
  49. package/dist/map/lifecycle-bridge.js +23 -1
  50. package/dist/map/lifecycle-bridge.js.map +1 -1
  51. package/dist/map/mail-bridge.d.ts +55 -0
  52. package/dist/map/mail-bridge.d.ts.map +1 -0
  53. package/dist/map/mail-bridge.js +115 -0
  54. package/dist/map/mail-bridge.js.map +1 -0
  55. package/dist/map/sidecar.d.ts.map +1 -1
  56. package/dist/map/sidecar.js +245 -1
  57. package/dist/map/sidecar.js.map +1 -1
  58. package/dist/map/types.d.ts +15 -0
  59. package/dist/map/types.d.ts.map +1 -1
  60. package/dist/mcp/tools/done-v2.d.ts.map +1 -1
  61. package/dist/mcp/tools/done-v2.js +1 -0
  62. package/dist/mcp/tools/done-v2.js.map +1 -1
  63. package/dist/teams/team-loader.d.ts.map +1 -1
  64. package/dist/teams/team-loader.js.map +1 -1
  65. package/dist/teams/team-runtime-v2.d.ts.map +1 -1
  66. package/dist/teams/team-runtime-v2.js +2 -0
  67. package/dist/teams/team-runtime-v2.js.map +1 -1
  68. package/package.json +6 -5
  69. package/src/agent/__tests__/agent-manager-v2.permission-interception.test.ts +296 -0
  70. package/src/agent/__tests__/agent-manager-v2.permissions.test.ts +233 -0
  71. package/src/agent/agent-manager-v2.ts +268 -8
  72. package/src/agent/types.ts +51 -0
  73. package/src/boot-v2.ts +190 -12
  74. package/src/cli/inbox-mcp-proxy.ts +56 -0
  75. package/src/dispatch/CLAUDE.md +129 -0
  76. package/src/dispatch/__tests__/loadout-translation.test.ts +141 -0
  77. package/src/dispatch/__tests__/mail-inbound-consumer.integration.test.ts +519 -0
  78. package/src/dispatch/__tests__/mail-inbound-consumer.test.ts +589 -0
  79. package/src/dispatch/__tests__/mail-inbound-reuse-consumer.test.ts +575 -0
  80. package/src/dispatch/__tests__/permission-evaluator.test.ts +196 -0
  81. package/src/dispatch/__tests__/permission-overlay.test.ts +56 -0
  82. package/src/dispatch/__tests__/permissions-handler.test.ts +168 -0
  83. package/src/dispatch/__tests__/spawn-agent-handler.test.ts +282 -0
  84. package/src/dispatch/loadout-translation.ts +138 -0
  85. package/src/dispatch/mail-inbound-consumer.ts +397 -0
  86. package/src/dispatch/mail-inbound-reuse-consumer.ts +479 -0
  87. package/src/dispatch/permission-evaluator.ts +191 -0
  88. package/src/dispatch/permission-overlay.ts +89 -0
  89. package/src/dispatch/permissions-handler.ts +112 -0
  90. package/src/dispatch/spawn-agent-handler.ts +160 -0
  91. package/src/lifecycle/handlers-v2.ts +34 -0
  92. package/src/map/__tests__/lifecycle-bridge.test.ts +64 -0
  93. package/src/map/__tests__/mail-bridge.test.ts +196 -0
  94. package/src/map/lifecycle-bridge.ts +48 -2
  95. package/src/map/mail-bridge.ts +203 -0
  96. package/src/map/sidecar.ts +346 -1
  97. package/src/map/types.ts +21 -0
  98. package/src/mcp/tools/done-v2.ts +1 -0
  99. package/src/teams/team-loader.ts +3 -1
  100. package/src/teams/team-runtime-v2.ts +2 -0
  101. package/dist/workspace/dataplane-adapter.d.ts +0 -260
  102. package/dist/workspace/dataplane-adapter.d.ts.map +0 -1
  103. package/dist/workspace/dataplane-adapter.js +0 -416
  104. 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: options.taskRef ? { task_ref: options.taskRef } : {},
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
- let agentMeta: Record<string, any> | undefined;
734
-
735
- if (permissionMode === "interactive") {
736
- agentMeta = { claudeCode: { options: { settingSources: [] } } };
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
- yield* activeSession.session.prompt(message);
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, {
@@ -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.