macro-agent 0.1.12 → 0.2.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.
Files changed (116) hide show
  1. package/dist/acp/macro-agent.d.ts.map +1 -1
  2. package/dist/acp/macro-agent.js +18 -40
  3. package/dist/acp/macro-agent.js.map +1 -1
  4. package/dist/agent/agent-manager-v2.d.ts.map +1 -1
  5. package/dist/agent/agent-manager-v2.js +241 -8
  6. package/dist/agent/agent-manager-v2.js.map +1 -1
  7. package/dist/agent/types.d.ts +47 -0
  8. package/dist/agent/types.d.ts.map +1 -1
  9. package/dist/agent/types.js.map +1 -1
  10. package/dist/boot-v2.d.ts +33 -0
  11. package/dist/boot-v2.d.ts.map +1 -1
  12. package/dist/boot-v2.js +144 -11
  13. package/dist/boot-v2.js.map +1 -1
  14. package/dist/cli/acp.js +0 -0
  15. package/dist/cli/inbox-mcp-proxy.d.ts +36 -0
  16. package/dist/cli/inbox-mcp-proxy.d.ts.map +1 -0
  17. package/dist/cli/inbox-mcp-proxy.js +51 -0
  18. package/dist/cli/inbox-mcp-proxy.js.map +1 -0
  19. package/dist/cli/index.js +0 -0
  20. package/dist/cli/mcp.js +0 -0
  21. package/dist/dispatch/loadout-translation.d.ts +100 -0
  22. package/dist/dispatch/loadout-translation.d.ts.map +1 -0
  23. package/dist/dispatch/loadout-translation.js +90 -0
  24. package/dist/dispatch/loadout-translation.js.map +1 -0
  25. package/dist/dispatch/mail-inbound-consumer.d.ts +136 -0
  26. package/dist/dispatch/mail-inbound-consumer.d.ts.map +1 -0
  27. package/dist/dispatch/mail-inbound-consumer.js +360 -0
  28. package/dist/dispatch/mail-inbound-consumer.js.map +1 -0
  29. package/dist/dispatch/mail-inbound-reuse-consumer.d.ts +75 -0
  30. package/dist/dispatch/mail-inbound-reuse-consumer.d.ts.map +1 -0
  31. package/dist/dispatch/mail-inbound-reuse-consumer.js +325 -0
  32. package/dist/dispatch/mail-inbound-reuse-consumer.js.map +1 -0
  33. package/dist/dispatch/permission-evaluator.d.ts +68 -0
  34. package/dist/dispatch/permission-evaluator.d.ts.map +1 -0
  35. package/dist/dispatch/permission-evaluator.js +159 -0
  36. package/dist/dispatch/permission-evaluator.js.map +1 -0
  37. package/dist/dispatch/permission-overlay.d.ts +64 -0
  38. package/dist/dispatch/permission-overlay.d.ts.map +1 -0
  39. package/dist/dispatch/permission-overlay.js +72 -0
  40. package/dist/dispatch/permission-overlay.js.map +1 -0
  41. package/dist/dispatch/permissions-handler.d.ts +71 -0
  42. package/dist/dispatch/permissions-handler.d.ts.map +1 -0
  43. package/dist/dispatch/permissions-handler.js +83 -0
  44. package/dist/dispatch/permissions-handler.js.map +1 -0
  45. package/dist/dispatch/spawn-agent-handler.d.ts +84 -0
  46. package/dist/dispatch/spawn-agent-handler.d.ts.map +1 -0
  47. package/dist/dispatch/spawn-agent-handler.js +85 -0
  48. package/dist/dispatch/spawn-agent-handler.js.map +1 -0
  49. package/dist/lifecycle/handlers-v2.d.ts +7 -0
  50. package/dist/lifecycle/handlers-v2.d.ts.map +1 -1
  51. package/dist/lifecycle/handlers-v2.js +27 -0
  52. package/dist/lifecycle/handlers-v2.js.map +1 -1
  53. package/dist/map/lifecycle-bridge.d.ts +18 -0
  54. package/dist/map/lifecycle-bridge.d.ts.map +1 -1
  55. package/dist/map/lifecycle-bridge.js +23 -1
  56. package/dist/map/lifecycle-bridge.js.map +1 -1
  57. package/dist/map/mail-bridge.d.ts +55 -0
  58. package/dist/map/mail-bridge.d.ts.map +1 -0
  59. package/dist/map/mail-bridge.js +115 -0
  60. package/dist/map/mail-bridge.js.map +1 -0
  61. package/dist/map/repo-workspace.d.ts +46 -0
  62. package/dist/map/repo-workspace.d.ts.map +1 -0
  63. package/dist/map/repo-workspace.js +39 -0
  64. package/dist/map/repo-workspace.js.map +1 -0
  65. package/dist/map/server.d.ts.map +1 -1
  66. package/dist/map/server.js +1 -0
  67. package/dist/map/server.js.map +1 -1
  68. package/dist/map/sidecar.d.ts.map +1 -1
  69. package/dist/map/sidecar.js +308 -1
  70. package/dist/map/sidecar.js.map +1 -1
  71. package/dist/map/types.d.ts +29 -0
  72. package/dist/map/types.d.ts.map +1 -1
  73. package/dist/mcp/tools/done-v2.d.ts.map +1 -1
  74. package/dist/mcp/tools/done-v2.js +1 -0
  75. package/dist/mcp/tools/done-v2.js.map +1 -1
  76. package/dist/teams/team-loader.d.ts.map +1 -1
  77. package/dist/teams/team-loader.js.map +1 -1
  78. package/dist/teams/team-runtime-v2.d.ts.map +1 -1
  79. package/dist/teams/team-runtime-v2.js +2 -0
  80. package/dist/teams/team-runtime-v2.js.map +1 -1
  81. package/package.json +7 -5
  82. package/src/acp/macro-agent.ts +20 -42
  83. package/src/agent/__tests__/agent-manager-v2.permission-interception.test.ts +296 -0
  84. package/src/agent/__tests__/agent-manager-v2.permissions.test.ts +233 -0
  85. package/src/agent/agent-manager-v2.ts +269 -8
  86. package/src/agent/types.ts +51 -0
  87. package/src/boot-v2.ts +192 -12
  88. package/src/cli/inbox-mcp-proxy.ts +56 -0
  89. package/src/dispatch/CLAUDE.md +129 -0
  90. package/src/dispatch/__tests__/loadout-translation.test.ts +141 -0
  91. package/src/dispatch/__tests__/mail-inbound-consumer.integration.test.ts +519 -0
  92. package/src/dispatch/__tests__/mail-inbound-consumer.test.ts +800 -0
  93. package/src/dispatch/__tests__/mail-inbound-reuse-consumer.test.ts +575 -0
  94. package/src/dispatch/__tests__/permission-evaluator.test.ts +196 -0
  95. package/src/dispatch/__tests__/permission-overlay.test.ts +56 -0
  96. package/src/dispatch/__tests__/permissions-handler.test.ts +168 -0
  97. package/src/dispatch/__tests__/spawn-agent-handler.test.ts +282 -0
  98. package/src/dispatch/loadout-translation.ts +138 -0
  99. package/src/dispatch/mail-inbound-consumer.ts +560 -0
  100. package/src/dispatch/mail-inbound-reuse-consumer.ts +479 -0
  101. package/src/dispatch/permission-evaluator.ts +191 -0
  102. package/src/dispatch/permission-overlay.ts +89 -0
  103. package/src/dispatch/permissions-handler.ts +112 -0
  104. package/src/dispatch/spawn-agent-handler.ts +160 -0
  105. package/src/lifecycle/handlers-v2.ts +34 -0
  106. package/src/map/__tests__/lifecycle-bridge.test.ts +64 -0
  107. package/src/map/__tests__/mail-bridge.test.ts +196 -0
  108. package/src/map/lifecycle-bridge.ts +48 -2
  109. package/src/map/mail-bridge.ts +203 -0
  110. package/src/map/repo-workspace.ts +82 -0
  111. package/src/map/server.ts +1 -0
  112. package/src/map/sidecar.ts +431 -1
  113. package/src/map/types.ts +34 -0
  114. package/src/mcp/tools/done-v2.ts +1 -0
  115. package/src/teams/team-loader.ts +3 -1
  116. package/src/teams/team-runtime-v2.ts +2 -0
@@ -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) {
@@ -527,6 +577,7 @@ export function createAgentManagerV2(
527
577
  parent,
528
578
  cwd = defaultCwd,
529
579
  permissionMode = defaultPermissionMode,
580
+ askForAllTools = false,
530
581
  subscribeParent = true,
531
582
  topics = [],
532
583
  config: agentConfig,
@@ -624,7 +675,12 @@ export function createAgentManagerV2(
624
675
  created_at: now,
625
676
  started_at: now,
626
677
  config: agentConfig as Record<string, unknown>,
627
- metadata: options.taskRef ? { task_ref: options.taskRef } : {},
678
+ metadata: {
679
+ ...(options.taskRef ? { task_ref: options.taskRef } : {}),
680
+ // Persist isolatedSettings so resume() applies the same agentMeta
681
+ // policy without needing the original SpawnAgentOptions.
682
+ ...(options.isolatedSettings ? { isolatedSettings: true } : {}),
683
+ },
628
684
  };
629
685
  agentStore.putAgent(agentRecord);
630
686
 
@@ -714,6 +770,57 @@ export function createAgentManagerV2(
714
770
  })) ?? []),
715
771
  ];
716
772
 
773
+ // Always-on subsystem MCP servers (the "trinity"). The macro-agent
774
+ // architecture docs describe agent-inbox + opentasks as separate MCP
775
+ // servers available to spawned workers, but until this entry block
776
+ // existed they were only reachable when host-level Claude plugins
777
+ // happened to have wired them. That left mail-inbound workers
778
+ // (`parent: null` + `isolatedSettings: true`) without inbox/tasks
779
+ // tools — see openhive-2 docs/LOADOUTS_DESIGN.md "Loadout-provided
780
+ // MCP servers" live finding 2026-05-03.
781
+ //
782
+ // Registering them here makes them per-spawn defaults independent
783
+ // of host configuration. Caller-supplied `agentConfig.mcpServers`
784
+ // remains additive (Option C / "hybrid"): the trinity is always
785
+ // there, callers can layer more on top.
786
+
787
+ // agent-inbox — exposes send_message, check_inbox, read_thread,
788
+ // list_agents via the InboxMcpProxy stdio bridge.
789
+ if (inboxAdapter.socketPath) {
790
+ const inboxProxyEntry = new URL(
791
+ "../../dist/cli/inbox-mcp-proxy.js",
792
+ import.meta.url,
793
+ ).pathname;
794
+ mcpServers.push({
795
+ name: "agent-inbox",
796
+ command: "node",
797
+ args: [inboxProxyEntry],
798
+ env: [
799
+ { name: "INBOX_SOCKET_PATH", value: inboxAdapter.socketPath },
800
+ { name: "MACRO_AGENT_ID", value: agentId },
801
+ ],
802
+ } as any);
803
+ }
804
+
805
+ // opentasks — exposes task, link, annotate, query via the
806
+ // `opentasks mcp` CLI subcommand. The package's dist/mcp/stdio.js
807
+ // is an exports-only module (no auto-start); the CLI's `mcp`
808
+ // subcommand is what actually wires StdioServerTransport. Conditional
809
+ // on tasksAdapter.connected — when the daemon isn't running, skip
810
+ // rather than mount a server that would fail at every tool call.
811
+ if (tasksAdapter.connected) {
812
+ const opentasksCliEntry = new URL(
813
+ "opentasks/dist/cli.js",
814
+ import.meta.url,
815
+ ).pathname;
816
+ mcpServers.push({
817
+ name: "opentasks",
818
+ command: "node",
819
+ args: [opentasksCliEntry, "mcp"],
820
+ env: [],
821
+ } as any);
822
+ }
823
+
717
824
  // Register minimem MCP server (agent-type independent — works for any MCP-capable agent)
718
825
  if (minimemConfig?.enabled) {
719
826
  mcpServers.push({
@@ -729,12 +836,98 @@ export function createAgentManagerV2(
729
836
  } as any);
730
837
  }
731
838
 
732
- // Build agentMeta
733
- let agentMeta: Record<string, any> | undefined;
734
-
735
- if (permissionMode === "interactive") {
736
- agentMeta = { claudeCode: { options: { settingSources: [] } } };
839
+ // Build agentMeta. Two layers:
840
+ //
841
+ // 1. `settingSources: []` — when the caller requests isolated
842
+ // settings (mail-inbound dispatch workers via
843
+ // SpawnAgentOptions.isolatedSettings), strip user/project/local
844
+ // setting sources so the worker doesn't load the host's
845
+ // claude-code-swarm / oh-my-claudecode / etc plugin MCP servers
846
+ // — those plugins assume host-shaped environment (sockets,
847
+ // daemons) and hang at session/new MCP-init when missing.
848
+ // Interactive `multiagent` callers leave this false so their
849
+ // installed plugins load normally.
850
+ //
851
+ // 2. `settings.permissions` — when the caller passes
852
+ // SpawnAgentOptions.permissions (e.g., from a materialized
853
+ // loadout), wire the rules inline via the Claude Agent SDK's
854
+ // session-level settings pass-through. Verified live: `deny`
855
+ // wins even over `permissionMode: "auto-approve"`. Inline
856
+ // wiring avoids file collisions when concurrent workers share
857
+ // a CWD (no `.claude/settings.json` written to disk).
858
+ //
859
+ // SDK contract pinned by the boundary test in
860
+ // `src/agent/__tests__/agent-manager-v2.permissions.test.ts` —
861
+ // it captures the literal `agentMeta` argument passed to
862
+ // `handle.createSession` and asserts both `settingSources: []`
863
+ // AND `settings.permissions` are present together (the
864
+ // interaction between filesystem-stripping and inline
865
+ // reconciliation that the SDK's docs don't fully spell out).
866
+ // If a future SDK version changes how `settings` reconciles
867
+ // with empty `settingSources`, that test will catch it.
868
+ //
869
+ // `ask` rules collapse based on `fullAutonomous`:
870
+ // - fullAutonomous: true → ask → allow (autonomous worker
871
+ // opts to proceed when there's no human to answer)
872
+ // - fullAutonomous: false → ask → deny (safe default;
873
+ // autonomous workers shouldn't make judgment calls)
874
+ const claudeCodeOptions: Record<string, any> = {};
875
+ if (options.isolatedSettings || permissionMode === "interactive") {
876
+ claudeCodeOptions.settingSources = [];
737
877
  }
878
+ if (options.permissions) {
879
+ const { allow = [], deny = [], ask = [] } = options.permissions;
880
+ const finalAllow = options.fullAutonomous ? [...allow, ...ask] : [...allow];
881
+ const finalDeny = options.fullAutonomous ? [...deny] : [...deny, ...ask];
882
+ claudeCodeOptions.settings = {
883
+ permissions: {
884
+ ...(finalAllow.length ? { allow: finalAllow } : {}),
885
+ ...(finalDeny.length ? { deny: finalDeny } : {}),
886
+ },
887
+ };
888
+ }
889
+ // P3 spike: when an agent should funnel every tool call through the host
890
+ // (so the prompt-iterator handler can apply runtime overlays), set
891
+ // `ask: ['*']` on settings.permissions. The SDK then consults canUseTool
892
+ // for every tool, which emits `permission_request` session updates.
893
+ // Used for dispatch-target agents (mail+reuse, ACP+reuse) that need
894
+ // dynamic enforcement; chat agents and parented children stay on
895
+ // their session's static rules.
896
+ if (options.askForAllTools) {
897
+ const existingPerms =
898
+ claudeCodeOptions.settings?.permissions ?? {};
899
+ claudeCodeOptions.settings = {
900
+ ...claudeCodeOptions.settings,
901
+ permissions: {
902
+ ...existingPerms,
903
+ ask: ["*"],
904
+ },
905
+ };
906
+ }
907
+
908
+ // 3. Runtime permission overlay enforcement lives in the prompt
909
+ // iterator (see `prompt()` below), NOT here at spawn time.
910
+ //
911
+ // Background: an earlier design installed a Claude SDK PreToolUse
912
+ // hook here that closed over the per-process permission-overlay
913
+ // registry. That mechanism was verified broken: function callbacks
914
+ // inside arrays don't survive JSON.stringify across the
915
+ // macro-agent → claude-agent-acp stdio JSON-RPC boundary, so the
916
+ // hook arrived as `null` at the SDK and silently no-op'd.
917
+ //
918
+ // The current design uses ACP's `permission_request` session
919
+ // update path instead. When an agent is spawned with
920
+ // `askForAllTools: true` + `permissionMode: 'interactive'`, the
921
+ // SDK consults `canUseTool` on every tool call, claude-agent-acp
922
+ // converts that into a `client.requestPermission` call, and
923
+ // acp-factory emits it as a `permission_request` session update.
924
+ // The prompt iterator below intercepts those updates, evaluates
925
+ // against the overlay, and responds via `respondPermission`.
926
+ // See `docs/PERMISSION_OVERLAY_ACP_DESIGN.md` for the full design.
927
+
928
+ const agentMeta = Object.keys(claudeCodeOptions).length > 0
929
+ ? { claudeCode: { options: claudeCodeOptions } }
930
+ : undefined;
738
931
 
739
932
  // Build capabilities context + skill-tree loadout for system prompt
740
933
  // Matches cc-swarm's context injection pattern (role-aware, tool-specific)
@@ -1094,8 +1287,12 @@ export function createAgentManagerV2(
1094
1287
  },
1095
1288
  ];
1096
1289
 
1290
+ // Strip user/project/local setting sources for isolated workers (the
1291
+ // metadata flag is set at spawn time when SpawnAgentOptions.isolatedSettings
1292
+ // was true) or interactive mode. See spawn() for the rationale.
1293
+ const isIsolated = (record.metadata as Record<string, unknown> | undefined)?.isolatedSettings === true;
1097
1294
  const agentMeta =
1098
- permMode === "interactive"
1295
+ isIsolated || permMode === "interactive"
1099
1296
  ? { claudeCode: { options: { settingSources: [] } } }
1100
1297
  : undefined;
1101
1298
 
@@ -1477,7 +1674,71 @@ export function createAgentManagerV2(
1477
1674
 
1478
1675
  activeSession.isPrompting = true;
1479
1676
  try {
1480
- yield* activeSession.session.prompt(message);
1677
+ // Permission overlay enforcement — for agents in dispatch context
1678
+ // (mail-inbound + ACP reuse targets), the dispatch consumer sets a
1679
+ // per-agent overlay before driving prompt(). When set, `permission_request`
1680
+ // session updates are intercepted here, evaluated against the overlay,
1681
+ // and answered via `respondToPermission`. The update is NOT yielded
1682
+ // to the consumer in that case — dispatch enforcement is internal.
1683
+ //
1684
+ // When no overlay is set (the common case — chat agents, sub-agents
1685
+ // spawned by parents, etc.), permission_request updates are yielded
1686
+ // through unchanged so chat surfaces' UI permission dialogs (the
1687
+ // swarmcraft PermissionDialog rendered via the openhive-acp-service
1688
+ // WS subscription) keep working.
1689
+ //
1690
+ // See `docs/PERMISSION_OVERLAY_ACP_DESIGN.md` for the rationale and
1691
+ // a diagram of the four-process flow.
1692
+ for await (const update of activeSession.session.prompt(message)) {
1693
+ const u = update as {
1694
+ sessionUpdate?: string;
1695
+ requestId?: string;
1696
+ toolCall?: { title?: string; kind?: string; rawInput?: unknown };
1697
+ options?: Array<{ kind?: string; optionId?: string }>;
1698
+ };
1699
+ if (u?.sessionUpdate === "permission_request") {
1700
+ const overlay = getPermissionOverlay(agentId);
1701
+ // No overlay → pass through to the consumer (chat UI, etc.).
1702
+ if (!overlay) {
1703
+ yield update;
1704
+ continue;
1705
+ }
1706
+ let optionId: string | undefined;
1707
+ try {
1708
+ const toolName = deriveToolName(u.toolCall);
1709
+ const toolInput = u.toolCall?.rawInput ?? {};
1710
+ const decision = evaluatePermission(toolName, toolInput, overlay)
1711
+ .decision;
1712
+ const wantedKind =
1713
+ decision === "deny" ? "reject_once" : "allow_once";
1714
+ const opt =
1715
+ u.options?.find((o) => o.kind === wantedKind) ??
1716
+ u.options?.find(
1717
+ (o) =>
1718
+ o.kind === (decision === "deny" ? "reject_always" : "allow_always"),
1719
+ );
1720
+ optionId = opt?.optionId;
1721
+ } catch {
1722
+ // Fail closed: on registry/evaluator error, deny.
1723
+ optionId = u.options?.find((o) => o.kind === "reject_once")?.optionId;
1724
+ }
1725
+ if (u.requestId && optionId) {
1726
+ try {
1727
+ (activeSession.session as any).respondToPermission?.(
1728
+ u.requestId,
1729
+ optionId,
1730
+ );
1731
+ } catch (err) {
1732
+ console.warn(
1733
+ `[perm-overlay] respondToPermission failed agent=${agentId} req=${u.requestId}: ${(err as Error).message}`,
1734
+ );
1735
+ }
1736
+ }
1737
+ // Don't yield permission_request to the consumer — dispatch-internal.
1738
+ continue;
1739
+ }
1740
+ yield update;
1741
+ }
1481
1742
  } finally {
1482
1743
  activeSession.isPrompting = false;
1483
1744
  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.