macro-agent 0.1.11 → 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/seed-defaults.d.ts.map +1 -1
- package/dist/teams/seed-defaults.js +6 -2
- package/dist/teams/seed-defaults.js.map +1 -1
- package/dist/teams/team-loader.d.ts.map +1 -1
- package/dist/teams/team-loader.js +17 -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 -6
- 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/seed-defaults.ts +6 -2
- package/src/teams/team-loader.ts +21 -2
- package/src/teams/team-runtime-v2.ts +2 -0
- package/src/workspace/__tests__/self-driving-yaml.test.ts +10 -2
- package/templates/teams/self-driving/team.yaml +142 -0
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `handleDispatchSpawnAgent` — the MAP `dispatch/spawn-agent`
|
|
3
|
+
* request handler. All dependencies are mocked.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, vi, type MockedFunction } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
handleDispatchSpawnAgent,
|
|
9
|
+
type SpawnAgentRequest,
|
|
10
|
+
type SpawnAgentHandlerDeps,
|
|
11
|
+
} from "../spawn-agent-handler.js";
|
|
12
|
+
import type { AgentManager } from "../../agent/agent-manager.js";
|
|
13
|
+
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────
|
|
15
|
+
// Mock helpers
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function makeAgentManager(spawnedId = "agent-spawn-1"): {
|
|
19
|
+
manager: Partial<AgentManager>;
|
|
20
|
+
spawnFn: MockedFunction<AgentManager["spawn"]>;
|
|
21
|
+
} {
|
|
22
|
+
const spawnFn = vi.fn().mockResolvedValue({ id: spawnedId }) as unknown as MockedFunction<
|
|
23
|
+
AgentManager["spawn"]
|
|
24
|
+
>;
|
|
25
|
+
return {
|
|
26
|
+
manager: { spawn: spawnFn },
|
|
27
|
+
spawnFn,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeDeps(
|
|
32
|
+
am: ReturnType<typeof makeAgentManager>,
|
|
33
|
+
overrides: Partial<SpawnAgentHandlerDeps> = {},
|
|
34
|
+
): SpawnAgentHandlerDeps & { logs: string[] } {
|
|
35
|
+
const logs: string[] = [];
|
|
36
|
+
return {
|
|
37
|
+
agentManager: am.manager as AgentManager,
|
|
38
|
+
log: (msg: string) => logs.push(msg),
|
|
39
|
+
...overrides,
|
|
40
|
+
logs,
|
|
41
|
+
} as SpawnAgentHandlerDeps & { logs: string[] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────
|
|
45
|
+
// Tests
|
|
46
|
+
// ─────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
describe("handleDispatchSpawnAgent", () => {
|
|
49
|
+
let am: ReturnType<typeof makeAgentManager>;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
am = makeAgentManager("agent-spawn-1");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ── Validation ───────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
it("throws on missing role", async () => {
|
|
58
|
+
const deps = makeDeps(am);
|
|
59
|
+
const params = { cwd: "/tmp/work" } as SpawnAgentRequest;
|
|
60
|
+
await expect(handleDispatchSpawnAgent(params, deps)).rejects.toThrow(
|
|
61
|
+
/missing 'role'/,
|
|
62
|
+
);
|
|
63
|
+
expect(am.spawnFn).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("throws on lifecycle: 'reuse' (handler is fresh-only)", async () => {
|
|
67
|
+
const deps = makeDeps(am);
|
|
68
|
+
const params: SpawnAgentRequest = {
|
|
69
|
+
role: "coordinator",
|
|
70
|
+
cwd: "/tmp/work",
|
|
71
|
+
lifecycle: "reuse",
|
|
72
|
+
};
|
|
73
|
+
await expect(handleDispatchSpawnAgent(params, deps)).rejects.toThrow(
|
|
74
|
+
/lifecycle='reuse'/,
|
|
75
|
+
);
|
|
76
|
+
expect(am.spawnFn).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("proceeds with lifecycle: 'fresh'", async () => {
|
|
80
|
+
const deps = makeDeps(am);
|
|
81
|
+
const params: SpawnAgentRequest = {
|
|
82
|
+
role: "coordinator",
|
|
83
|
+
cwd: "/tmp/work",
|
|
84
|
+
lifecycle: "fresh",
|
|
85
|
+
};
|
|
86
|
+
const result = await handleDispatchSpawnAgent(params, deps);
|
|
87
|
+
expect(result).toEqual({ agentId: "agent-spawn-1" });
|
|
88
|
+
expect(am.spawnFn).toHaveBeenCalledOnce();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("proceeds when lifecycle is omitted (defaults to fresh)", async () => {
|
|
92
|
+
const deps = makeDeps(am);
|
|
93
|
+
const params: SpawnAgentRequest = {
|
|
94
|
+
role: "coordinator",
|
|
95
|
+
cwd: "/tmp/work",
|
|
96
|
+
};
|
|
97
|
+
const result = await handleDispatchSpawnAgent(params, deps);
|
|
98
|
+
expect(result).toEqual({ agentId: "agent-spawn-1" });
|
|
99
|
+
expect(am.spawnFn).toHaveBeenCalledOnce();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── Spawn-options derivation ──────────────────────────────────
|
|
103
|
+
|
|
104
|
+
it("calls agentManager.spawn with options derived via loadoutToSpawnOptions", async () => {
|
|
105
|
+
const deps = makeDeps(am);
|
|
106
|
+
const params: SpawnAgentRequest = {
|
|
107
|
+
role: "coordinator",
|
|
108
|
+
cwd: "/tmp/work",
|
|
109
|
+
lifecycle: "fresh",
|
|
110
|
+
loadout: {
|
|
111
|
+
permissions: { deny: ["Bash(rm -rf:*)"] },
|
|
112
|
+
capabilities: ["editor"],
|
|
113
|
+
},
|
|
114
|
+
fullAutonomous: true,
|
|
115
|
+
};
|
|
116
|
+
await handleDispatchSpawnAgent(params, deps);
|
|
117
|
+
|
|
118
|
+
expect(am.spawnFn).toHaveBeenCalledOnce();
|
|
119
|
+
const spawnArgs = am.spawnFn.mock.calls[0][0];
|
|
120
|
+
expect(spawnArgs.role).toBe("coordinator");
|
|
121
|
+
expect(spawnArgs.cwd).toBe("/tmp/work");
|
|
122
|
+
expect(spawnArgs.parent).toBeNull();
|
|
123
|
+
expect(spawnArgs.isolatedSettings).toBe(true);
|
|
124
|
+
expect(spawnArgs.permissions).toEqual({
|
|
125
|
+
allow: [],
|
|
126
|
+
deny: ["Bash(rm -rf:*)"],
|
|
127
|
+
ask: [],
|
|
128
|
+
});
|
|
129
|
+
expect(spawnArgs.fullAutonomous).toBe(true);
|
|
130
|
+
expect(spawnArgs.capabilities).toEqual(["editor"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("forwards params.cwd when present", async () => {
|
|
134
|
+
const deps = makeDeps(am);
|
|
135
|
+
const params: SpawnAgentRequest = {
|
|
136
|
+
role: "coordinator",
|
|
137
|
+
cwd: "/tmp/abc",
|
|
138
|
+
};
|
|
139
|
+
await handleDispatchSpawnAgent(params, deps);
|
|
140
|
+
const args = am.spawnFn.mock.calls[0][0];
|
|
141
|
+
expect(args.cwd).toBe("/tmp/abc");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("omits cwd from spawn options when absent", async () => {
|
|
145
|
+
const deps = makeDeps(am);
|
|
146
|
+
const params = { role: "coordinator" } as SpawnAgentRequest;
|
|
147
|
+
await handleDispatchSpawnAgent(params, deps);
|
|
148
|
+
const args = am.spawnFn.mock.calls[0][0];
|
|
149
|
+
expect(Object.prototype.hasOwnProperty.call(args, "cwd")).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("returns { agentId } from the spawned result", async () => {
|
|
153
|
+
am = makeAgentManager("custom-agent-id-42");
|
|
154
|
+
const deps = makeDeps(am);
|
|
155
|
+
const result = await handleDispatchSpawnAgent(
|
|
156
|
+
{ role: "worker", cwd: "/tmp/x" },
|
|
157
|
+
deps,
|
|
158
|
+
);
|
|
159
|
+
expect(result).toEqual({ agentId: "custom-agent-id-42" });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── ACP-registration barrier ─────────────────────────────────
|
|
163
|
+
|
|
164
|
+
it("calls waitForAcpRegistration(agentId, 5000) when provided", async () => {
|
|
165
|
+
const waitForAcpRegistration = vi.fn().mockResolvedValue(true);
|
|
166
|
+
const deps = makeDeps(am, { waitForAcpRegistration });
|
|
167
|
+
await handleDispatchSpawnAgent(
|
|
168
|
+
{ role: "coordinator", cwd: "/tmp/x" },
|
|
169
|
+
deps,
|
|
170
|
+
);
|
|
171
|
+
expect(waitForAcpRegistration).toHaveBeenCalledOnce();
|
|
172
|
+
expect(waitForAcpRegistration).toHaveBeenCalledWith("agent-spawn-1", 5_000);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("tolerates waitForAcpRegistration timeout (logs warning, still returns agentId)", async () => {
|
|
176
|
+
const waitForAcpRegistration = vi.fn().mockResolvedValue(false);
|
|
177
|
+
const deps = makeDeps(am, { waitForAcpRegistration });
|
|
178
|
+
const result = await handleDispatchSpawnAgent(
|
|
179
|
+
{ role: "coordinator", cwd: "/tmp/x" },
|
|
180
|
+
deps,
|
|
181
|
+
);
|
|
182
|
+
expect(result).toEqual({ agentId: "agent-spawn-1" });
|
|
183
|
+
expect(deps.logs.some((l) => l.includes("Warning: ACP registration not confirmed"))).toBe(
|
|
184
|
+
true,
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("calls waitForAcpRegistration even if it rejects (caught, no propagation)", async () => {
|
|
189
|
+
const waitForAcpRegistration = vi
|
|
190
|
+
.fn()
|
|
191
|
+
.mockRejectedValue(new Error("registration timeout"));
|
|
192
|
+
const deps = makeDeps(am, { waitForAcpRegistration });
|
|
193
|
+
const result = await handleDispatchSpawnAgent(
|
|
194
|
+
{ role: "coordinator", cwd: "/tmp/x" },
|
|
195
|
+
deps,
|
|
196
|
+
);
|
|
197
|
+
expect(result).toEqual({ agentId: "agent-spawn-1" });
|
|
198
|
+
expect(waitForAcpRegistration).toHaveBeenCalledOnce();
|
|
199
|
+
// The rejection is treated as a non-confirmation → warning logged.
|
|
200
|
+
expect(deps.logs.some((l) => l.includes("Warning: ACP registration not confirmed"))).toBe(
|
|
201
|
+
true,
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("does not call waitForAcpRegistration when not provided", async () => {
|
|
206
|
+
const deps = makeDeps(am); // no waitForAcpRegistration
|
|
207
|
+
const result = await handleDispatchSpawnAgent(
|
|
208
|
+
{ role: "coordinator", cwd: "/tmp/x" },
|
|
209
|
+
deps,
|
|
210
|
+
);
|
|
211
|
+
expect(result).toEqual({ agentId: "agent-spawn-1" });
|
|
212
|
+
// Just ensure we don't crash; nothing else to assert.
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ── fullAutonomous default ───────────────────────────────────
|
|
216
|
+
|
|
217
|
+
it("defaults fullAutonomous to true when params.fullAutonomous is omitted", async () => {
|
|
218
|
+
const deps = makeDeps(am);
|
|
219
|
+
const params: SpawnAgentRequest = {
|
|
220
|
+
role: "coordinator",
|
|
221
|
+
cwd: "/tmp/x",
|
|
222
|
+
loadout: { permissions: { ask: ["Write(*.env)"] } },
|
|
223
|
+
};
|
|
224
|
+
await handleDispatchSpawnAgent(params, deps);
|
|
225
|
+
|
|
226
|
+
const args = am.spawnFn.mock.calls[0][0];
|
|
227
|
+
expect(args.fullAutonomous).toBe(true);
|
|
228
|
+
expect(args.permissions).toEqual({
|
|
229
|
+
allow: [],
|
|
230
|
+
deny: [],
|
|
231
|
+
ask: ["Write(*.env)"],
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("respects explicit fullAutonomous: false from params", async () => {
|
|
236
|
+
const deps = makeDeps(am);
|
|
237
|
+
const params: SpawnAgentRequest = {
|
|
238
|
+
role: "coordinator",
|
|
239
|
+
cwd: "/tmp/x",
|
|
240
|
+
loadout: { permissions: { ask: ["Write(*.env)"] } },
|
|
241
|
+
fullAutonomous: false,
|
|
242
|
+
};
|
|
243
|
+
await handleDispatchSpawnAgent(params, deps);
|
|
244
|
+
|
|
245
|
+
const args = am.spawnFn.mock.calls[0][0];
|
|
246
|
+
expect(args.fullAutonomous).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ── Task placeholder ─────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
it("uses params.task when provided", async () => {
|
|
252
|
+
const deps = makeDeps(am);
|
|
253
|
+
const params: SpawnAgentRequest = {
|
|
254
|
+
role: "coordinator",
|
|
255
|
+
cwd: "/tmp/x",
|
|
256
|
+
task: "Implement the widget",
|
|
257
|
+
};
|
|
258
|
+
await handleDispatchSpawnAgent(params, deps);
|
|
259
|
+
const args = am.spawnFn.mock.calls[0][0];
|
|
260
|
+
expect(args.task).toBe("Implement the widget");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("applies the default task placeholder when params.task is omitted", async () => {
|
|
264
|
+
const deps = makeDeps(am);
|
|
265
|
+
const params: SpawnAgentRequest = { role: "coordinator", cwd: "/tmp/x" };
|
|
266
|
+
await handleDispatchSpawnAgent(params, deps);
|
|
267
|
+
const args = am.spawnFn.mock.calls[0][0];
|
|
268
|
+
expect(args.task).toBe("Awaiting dispatch (created by dispatch/spawn-agent)");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ── isolatedSettings ─────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
it("always passes isolatedSettings: true to spawn", async () => {
|
|
274
|
+
const deps = makeDeps(am);
|
|
275
|
+
await handleDispatchSpawnAgent(
|
|
276
|
+
{ role: "coordinator", cwd: "/tmp/x" },
|
|
277
|
+
deps,
|
|
278
|
+
);
|
|
279
|
+
const args = am.spawnFn.mock.calls[0][0];
|
|
280
|
+
expect(args.isolatedSettings).toBe(true);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loadout → SpawnAgentOptions translator (macro-agent)
|
|
3
|
+
*
|
|
4
|
+
* Single named function that converts the runtime-agnostic
|
|
5
|
+
* `MaterializedLoadout` wire payload into macro-agent-specific
|
|
6
|
+
* `SpawnAgentOptions`. Two callers:
|
|
7
|
+
*
|
|
8
|
+
* - `mail-inbound-consumer` — for mail-routed dispatches (ACP+fresh
|
|
9
|
+
* workers spawned per envelope)
|
|
10
|
+
* - `dispatch/spawn-agent` MAP handler — for ACP-routed dispatches when
|
|
11
|
+
* `lifecycle: 'fresh'`
|
|
12
|
+
*
|
|
13
|
+
* Both call sites pass the same shape; this function is the only place
|
|
14
|
+
* macro-agent-specific spawn vocabulary (`permissions`, `fullAutonomous`,
|
|
15
|
+
* `capabilities`) is derived from loadout fields. A future ACP runtime
|
|
16
|
+
* (codex, etc.) would implement its own translator with the same input
|
|
17
|
+
* shape and a different output shape.
|
|
18
|
+
*
|
|
19
|
+
* The wire shape (`MaterializedLoadout` subset) is documented in
|
|
20
|
+
* openhive-2 `docs/LOADOUTS_DESIGN.md` → "Channel 2 — `loadout` as a
|
|
21
|
+
* first-class wire concept".
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { SpawnAgentOptions } from "../agent/types.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Wire shape — a strict subset of openhive's `MaterializedLoadout` that
|
|
28
|
+
* is meaningful to a runtime's spawn config. Skill content (`rendered`,
|
|
29
|
+
* `items`) and `promptAddendum` ride in the prompt body for both routes,
|
|
30
|
+
* so they aren't included here.
|
|
31
|
+
*
|
|
32
|
+
* Defined locally rather than imported from openhive to avoid a runtime
|
|
33
|
+
* dependency on the hub's source tree. Structural compatibility with
|
|
34
|
+
* `MaterializedLoadout` is what matters at the wire boundary.
|
|
35
|
+
*/
|
|
36
|
+
export interface WireLoadout {
|
|
37
|
+
permissions?: {
|
|
38
|
+
allow?: string[];
|
|
39
|
+
deny?: string[];
|
|
40
|
+
ask?: string[];
|
|
41
|
+
};
|
|
42
|
+
mcpProviders?: Array<{
|
|
43
|
+
name: string;
|
|
44
|
+
command?: string;
|
|
45
|
+
args?: string[];
|
|
46
|
+
env?: Record<string, string>;
|
|
47
|
+
}>;
|
|
48
|
+
mcpScope?: Array<{ server: string; tools?: string[]; exclude?: string[] }>;
|
|
49
|
+
capabilities?: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TranslationContext {
|
|
53
|
+
/**
|
|
54
|
+
* Mail-inbound and other autonomous consumers set this true so `ask`
|
|
55
|
+
* rules collapse to `allow` (proceed without human round-trip).
|
|
56
|
+
* Non-autonomous spawns leave it false, collapsing `ask` → `deny`.
|
|
57
|
+
*/
|
|
58
|
+
fullAutonomous?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Translate a wire-shaped loadout into the subset of `SpawnAgentOptions`
|
|
63
|
+
* that macro-agent's `agentManager.spawn` consumes from loadout content.
|
|
64
|
+
*
|
|
65
|
+
* Returns a partial — the caller merges with required spawn fields
|
|
66
|
+
* (`task`, `task_id`, `role`, `parent`, `cwd`, `isolatedSettings`, etc.).
|
|
67
|
+
*
|
|
68
|
+
* Mappings:
|
|
69
|
+
* loadout.permissions → SpawnAgentOptions.permissions (carried verbatim;
|
|
70
|
+
* `ask` collapse is performed inside agentManager.spawn based on
|
|
71
|
+
* fullAutonomous)
|
|
72
|
+
* loadout.capabilities → SpawnAgentOptions.capabilities (forwarded)
|
|
73
|
+
* loadout.mcpProviders → reserved (Phase 2 — not yet wired)
|
|
74
|
+
* loadout.mcpScope → reserved (Phase 1 — not yet wired)
|
|
75
|
+
*/
|
|
76
|
+
export function loadoutToSpawnOptions(
|
|
77
|
+
loadout: WireLoadout | undefined,
|
|
78
|
+
ctx: TranslationContext = {},
|
|
79
|
+
): Partial<SpawnAgentOptions> {
|
|
80
|
+
if (!loadout) return {};
|
|
81
|
+
|
|
82
|
+
const opts: Partial<SpawnAgentOptions> = {};
|
|
83
|
+
|
|
84
|
+
if (loadout.permissions && hasAnyRule(loadout.permissions)) {
|
|
85
|
+
opts.permissions = {
|
|
86
|
+
allow: loadout.permissions.allow ?? [],
|
|
87
|
+
deny: loadout.permissions.deny ?? [],
|
|
88
|
+
ask: loadout.permissions.ask ?? [],
|
|
89
|
+
};
|
|
90
|
+
opts.fullAutonomous = ctx.fullAutonomous ?? false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (loadout.capabilities?.length) {
|
|
94
|
+
opts.capabilities = [...loadout.capabilities];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return opts;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function hasAnyRule(p: WireLoadout["permissions"]): boolean {
|
|
101
|
+
if (!p) return false;
|
|
102
|
+
return (
|
|
103
|
+
(p.allow?.length ?? 0) +
|
|
104
|
+
(p.deny?.length ?? 0) +
|
|
105
|
+
(p.ask?.length ?? 0) >
|
|
106
|
+
0
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Collapse a permissions block's `ask` rules based on the autonomous
|
|
112
|
+
* setting, returning a fully-resolved {allow, deny} pair suitable for
|
|
113
|
+
* a permission-overlay registry entry (no `ask` left).
|
|
114
|
+
*
|
|
115
|
+
* fullAutonomous: true → ask collapses to allow (autonomous worker
|
|
116
|
+
* proceeds without human round-trip)
|
|
117
|
+
* fullAutonomous: false → ask collapses to deny (safe default;
|
|
118
|
+
* autonomous workers shouldn't make
|
|
119
|
+
* judgment calls)
|
|
120
|
+
*
|
|
121
|
+
* Mirrors the inline logic in `agent-manager-v2.ts`'s
|
|
122
|
+
* `claudeCodeOptions.settings.permissions` build path so the runtime
|
|
123
|
+
* overlay applies the same `ask`-resolution semantics as the
|
|
124
|
+
* spawn-time settings.
|
|
125
|
+
*
|
|
126
|
+
* Returns `undefined` when the input has no rules at all (caller can
|
|
127
|
+
* skip overlay registration entirely).
|
|
128
|
+
*/
|
|
129
|
+
export function collapsePermissionsForAutonomous(
|
|
130
|
+
perms: WireLoadout["permissions"] | undefined,
|
|
131
|
+
fullAutonomous: boolean,
|
|
132
|
+
): { allow: string[]; deny: string[] } | undefined {
|
|
133
|
+
if (!perms || !hasAnyRule(perms)) return undefined;
|
|
134
|
+
const { allow = [], deny = [], ask = [] } = perms;
|
|
135
|
+
return fullAutonomous
|
|
136
|
+
? { allow: [...allow, ...ask], deny: [...deny] }
|
|
137
|
+
: { allow: [...allow], deny: [...deny, ...ask] };
|
|
138
|
+
}
|