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,479 @@
1
+ /**
2
+ * Mail-Inbound Reuse Consumer
3
+ *
4
+ * Receives hub-driven `x-dispatch/work` envelopes addressed to **non-sidecar**
5
+ * agents — long-lived team workers, coordinators, etc. — and drives them
6
+ * through the dispatch turn using their existing session, then posts the
7
+ * summary back as a mail turn.
8
+ *
9
+ * Mirrors `mail-inbound-consumer.ts` but with three semantic differences:
10
+ *
11
+ * 1. Filters envelopes addressed to ANY non-sidecar agent (the existing
12
+ * consumer filters for the dispatcher recipient).
13
+ * 2. Does **not** spawn — it drives the existing agent's session via
14
+ * `agentManager.prompt(agentId, prompt)` and watches for `done()` in
15
+ * the update stream.
16
+ * 3. Tracks `inflightDispatches` per agentId. A second envelope arriving
17
+ * while the same agent is already processing a dispatch is rejected
18
+ * with `recipient_busy` so the orchestrator can retry against another
19
+ * agent (or fall back to fresh-spawn). Reject is **dispatch-scoped**
20
+ * — non-dispatch work on the agent (peer messages, user chat) does
21
+ * NOT trigger the busy reject; that work stacks naturally.
22
+ *
23
+ * Reply path: captures `args.summary` from the done() tool call's rawInput
24
+ * directly off the update stream, so it works for both parented and
25
+ * parentless target agents (the parented branch in `handlers-v2` does NOT
26
+ * stash `_lastSummary` — only parentless agents do — but we don't need
27
+ * that path because we observe done() in-stream).
28
+ *
29
+ * @module dispatch/mail-inbound-reuse-consumer
30
+ */
31
+
32
+ import type { AgentManager } from "../agent/agent-manager.js";
33
+ import type { AgentStore } from "../agent/agent-store.js";
34
+ import type { ExtendedSessionUpdate } from "acp-factory";
35
+ import type {
36
+ InboxEvents,
37
+ InboxMessageEvent,
38
+ MailInboundSidecar,
39
+ } from "./mail-inbound-consumer.js";
40
+ import {
41
+ collapsePermissionsForAutonomous,
42
+ type WireLoadout,
43
+ } from "./loadout-translation.js";
44
+ import {
45
+ setPermissionOverlay,
46
+ clearPermissionOverlay,
47
+ } from "./permission-overlay.js";
48
+
49
+ export interface MailInboundReuseConsumerOptions {
50
+ /**
51
+ * The sidecar agent ID. Envelopes addressed to this id are handled by
52
+ * the original `mail-inbound-consumer` (fresh-spawn path); the reuse
53
+ * consumer ignores them so the two consumers' filters don't overlap.
54
+ */
55
+ dispatcherAgentId: string;
56
+
57
+ /** Raw inbox event emitter (from inboxAdapter.getInbox().events). */
58
+ inboxEvents: InboxEvents;
59
+
60
+ /** Agent lifecycle manager — used to drive the existing session. */
61
+ agentManager: AgentManager;
62
+
63
+ /** Agent store — used to confirm the target agent is running. */
64
+ agentStore: AgentStore;
65
+
66
+ /**
67
+ * Optional sidecar reference. Populated after step 13 in boot-v2 via
68
+ * the shared systemRef. The consumer accesses it lazily at reply time.
69
+ */
70
+ getSidecar: () => MailInboundSidecar | null | undefined;
71
+
72
+ /** Optional logger (default: console.log). */
73
+ log?: (msg: string) => void;
74
+ }
75
+
76
+ export interface MailInboundReuseConsumerStats {
77
+ /** Count of envelopes dropped because they lacked a taskId. */
78
+ droppedMalformed: number;
79
+ /** Number of distinct taskIds currently tracked for dedup. */
80
+ seenTaskIds: number;
81
+ /** Number of rejects emitted because the target agent was already busy with a dispatch. */
82
+ busyRejects: number;
83
+ /** Currently in-flight dispatches keyed by agentId. */
84
+ inflightCount: number;
85
+ }
86
+
87
+ export interface MailInboundReuseConsumer {
88
+ stop(): void;
89
+ stats(): MailInboundReuseConsumerStats;
90
+ }
91
+
92
+ interface InflightDispatch {
93
+ dispatchId: string;
94
+ conversationId: string | null;
95
+ startedAt: number;
96
+ }
97
+
98
+ const SEEN_TASK_TTL_MS = 60 * 60 * 1000;
99
+
100
+ /**
101
+ * Wire the mail-inbound reuse consumer.
102
+ *
103
+ * Returns a `stop()` handle that detaches the inbox listener.
104
+ */
105
+ export function createMailInboundReuseConsumer(
106
+ opts: MailInboundReuseConsumerOptions,
107
+ ): MailInboundReuseConsumer {
108
+ const {
109
+ dispatcherAgentId,
110
+ inboxEvents,
111
+ agentManager,
112
+ agentStore,
113
+ getSidecar,
114
+ log = (msg: string) => console.log(msg),
115
+ } = opts;
116
+
117
+ // agentId → inflight dispatch state. Used both to gate concurrent
118
+ // dispatches against the same agent and to look up the conversation
119
+ // when posting the reply.
120
+ const inflightDispatches = new Map<string, InflightDispatch>();
121
+
122
+ // taskId → expiresAt: idempotency guard mirroring mail-inbound-consumer.
123
+ const seenTaskIds = new Map<string, number>();
124
+ function pruneSeenTaskIds(): void {
125
+ const now = Date.now();
126
+ for (const [id, expiresAt] of seenTaskIds) {
127
+ if (expiresAt <= now) seenTaskIds.delete(id);
128
+ }
129
+ }
130
+
131
+ let droppedMalformedCount = 0;
132
+ let busyRejectCount = 0;
133
+
134
+ log(
135
+ `[mail-inbound-reuse] Consumer ready — listening for x-dispatch/work envelopes ` +
136
+ `addressed to non-sidecar agents (sidecar=${dispatcherAgentId})`,
137
+ );
138
+
139
+ const onMessage = (event: InboxMessageEvent): void => {
140
+ // Only handle envelopes addressed to NON-sidecar agents. Sidecar
141
+ // envelopes are owned by mail-inbound-consumer (fresh-spawn).
142
+ if (event.agentId === dispatcherAgentId) return;
143
+
144
+ const content = event.message?.content as
145
+ | {
146
+ schema?: string;
147
+ data?: {
148
+ taskId?: string;
149
+ prompt?: string;
150
+ content?: string;
151
+ role?: string;
152
+ tags?: string[];
153
+ loadout?: WireLoadout;
154
+ metadata?: Record<string, unknown>;
155
+ };
156
+ _conversationId?: string;
157
+ }
158
+ | undefined;
159
+
160
+ if (content?.schema !== "x-dispatch/work") return;
161
+
162
+ const data = content.data;
163
+ if (!data?.taskId) {
164
+ droppedMalformedCount++;
165
+ log(
166
+ `[mail-inbound-reuse] Dropping malformed envelope (no taskId, total=${droppedMalformedCount})`,
167
+ );
168
+ return;
169
+ }
170
+
171
+ const taskId = data.taskId;
172
+ pruneSeenTaskIds();
173
+ const seenExpiresAt = seenTaskIds.get(taskId);
174
+ if (seenExpiresAt !== undefined && seenExpiresAt > Date.now()) {
175
+ // Re-delivery within dedup window — silently drop.
176
+ return;
177
+ }
178
+ seenTaskIds.set(taskId, Date.now() + SEEN_TASK_TTL_MS);
179
+
180
+ const targetAgentId = event.agentId;
181
+ const conversationId = content._conversationId ?? null;
182
+ const prompt = data.prompt ?? data.content ?? "";
183
+
184
+ // Resolve target — must be a known, non-stopped agent.
185
+ const targetRecord = agentStore.getAgent(targetAgentId);
186
+ if (!targetRecord) {
187
+ log(
188
+ `[mail-inbound-reuse] Unknown target agent ${targetAgentId} for taskId=${taskId} — dropping`,
189
+ );
190
+ void postReplyTurn(conversationId, targetAgentId, {
191
+ status: "agent_unavailable",
192
+ reason: `Agent ${targetAgentId} not registered on this swarm`,
193
+ });
194
+ return;
195
+ }
196
+ if (targetRecord.state === "stopped" || targetRecord.state === "failed") {
197
+ log(
198
+ `[mail-inbound-reuse] Target agent ${targetAgentId} state=${targetRecord.state} — dropping taskId=${taskId}`,
199
+ );
200
+ void postReplyTurn(conversationId, targetAgentId, {
201
+ status: "agent_unavailable",
202
+ reason: `Agent ${targetAgentId} state=${targetRecord.state}`,
203
+ });
204
+ return;
205
+ }
206
+
207
+ // In-flight check — only reject when this same agent is already
208
+ // processing another tracked dispatch. Non-dispatch work (peer chat,
209
+ // user prompts) does not block; promptUntilDone-style serial stacking
210
+ // handles that.
211
+ const existing = inflightDispatches.get(targetAgentId);
212
+ if (existing) {
213
+ busyRejectCount++;
214
+ log(
215
+ `[mail-inbound-reuse] recipient_busy — agent=${targetAgentId} already processing ` +
216
+ `dispatch=${existing.dispatchId}; rejecting taskId=${taskId}`,
217
+ );
218
+ void postReplyTurn(conversationId, targetAgentId, {
219
+ status: "recipient_busy",
220
+ reason: `Agent ${targetAgentId} is processing dispatch ${existing.dispatchId}`,
221
+ });
222
+ return;
223
+ }
224
+
225
+ log(
226
+ `[mail-inbound-reuse] Driving dispatch taskId=${taskId} on existing agent=${targetAgentId} ` +
227
+ `conv=${conversationId ?? "(none)"}`,
228
+ );
229
+
230
+ inflightDispatches.set(targetAgentId, {
231
+ dispatchId: taskId,
232
+ conversationId,
233
+ startedAt: Date.now(),
234
+ });
235
+
236
+ // Drive the agent's existing session via raw `prompt()` rather than
237
+ // `promptUntilDone` because the latter auto-terminates the agent on
238
+ // done() — fatal for long-lived workers we want to reuse. We watch
239
+ // the update stream ourselves for the done() tool call and capture
240
+ // the summary inline.
241
+ void driveDispatch(
242
+ targetAgentId,
243
+ taskId,
244
+ prompt,
245
+ conversationId,
246
+ data.loadout,
247
+ ).finally(() => {
248
+ inflightDispatches.delete(targetAgentId);
249
+ // Always clear the permission overlay, even if driveDispatch
250
+ // didn't set one — keeps the registry tidy and defends against
251
+ // a future code path that sets one but skips its own cleanup.
252
+ clearPermissionOverlay(targetAgentId);
253
+ });
254
+ };
255
+
256
+ async function driveDispatch(
257
+ targetAgentId: string,
258
+ taskId: string,
259
+ prompt: string,
260
+ conversationId: string | null,
261
+ loadout: WireLoadout | undefined,
262
+ ): Promise<void> {
263
+ // Apply the dispatch's loadout permissions as a runtime overlay
264
+ // for the duration of this prompt drive. The PreToolUse hook
265
+ // installed at spawn-time consults the overlay registry per tool
266
+ // call and denies calls that match the loadout's deny rules.
267
+ // `fullAutonomous: true` because mail-inbound workers have no
268
+ // human in the loop — `ask` rules collapse to `allow`. Cleared
269
+ // unconditionally in `finally` so a crash mid-prompt doesn't
270
+ // leave a stale overlay on the agent.
271
+ const overlay = collapsePermissionsForAutonomous(
272
+ loadout?.permissions,
273
+ /* fullAutonomous */ true,
274
+ );
275
+ if (overlay) {
276
+ setPermissionOverlay(targetAgentId, overlay);
277
+ log(
278
+ `[mail-inbound-reuse] Applied permission overlay for agent=${targetAgentId} ` +
279
+ `taskId=${taskId} (deny=${overlay.deny.length} allow=${overlay.allow.length})`,
280
+ );
281
+ }
282
+
283
+ let summary: string | undefined;
284
+ let status: string | undefined;
285
+ let doneSeen = false;
286
+ let promptError: Error | undefined;
287
+
288
+ try {
289
+ for await (const update of agentManager.prompt(targetAgentId, prompt)) {
290
+ const captured = captureDoneCall(update);
291
+ if (captured) {
292
+ doneSeen = true;
293
+ if (captured.summary) summary = captured.summary;
294
+ if (captured.status) status = captured.status;
295
+ }
296
+ }
297
+ } catch (err) {
298
+ promptError = err as Error;
299
+ log(
300
+ `[mail-inbound-reuse] prompt() threw for agent=${targetAgentId} taskId=${taskId}: ` +
301
+ `${promptError.message ?? String(promptError)}`,
302
+ );
303
+ }
304
+
305
+ // Fallback: read `_lastSummary` from agentStore. The done() handler
306
+ // persists this for in-flight agents (Phase 2C) so the reply path
307
+ // is reliable even when the prompt iterator's update stream raced
308
+ // the ACP connection close. Covers:
309
+ // - prompt() threw before yielding the done() update (catch above)
310
+ // - inline capture saw done() but `args.summary` was empty
311
+ // - iterator yielded but our captureDoneCall missed (shape drift)
312
+ if (!summary) {
313
+ try {
314
+ const record = agentStore.getAgent(targetAgentId);
315
+ const fallback = record?.metadata?._lastSummary;
316
+ if (typeof fallback === "string" && fallback.length > 0) {
317
+ summary = fallback;
318
+ doneSeen = true;
319
+ log(
320
+ `[mail-inbound-reuse] Recovered summary from _lastSummary fallback for agent=${targetAgentId} taskId=${taskId}`,
321
+ );
322
+ }
323
+ } catch {
324
+ /* best effort — store may be closing during shutdown */
325
+ }
326
+ }
327
+
328
+ // Post reply: prefer real summary, fall back to status notes when
329
+ // we genuinely have nothing.
330
+ if (summary) {
331
+ void postReplyTurn(conversationId, targetAgentId, summary).then(() => {
332
+ // Clear the persisted summary so it doesn't replay if the same
333
+ // agentId is dispatched again. Best-effort.
334
+ try {
335
+ const existing = agentStore.getAgent(targetAgentId)?.metadata ?? {};
336
+ const { _lastSummary: _drop, ...rest } = existing as Record<string, unknown>;
337
+ void _drop;
338
+ agentStore.updateAgent(targetAgentId, { metadata: rest });
339
+ } catch {
340
+ /* best effort */
341
+ }
342
+ });
343
+ return;
344
+ }
345
+
346
+ if (promptError) {
347
+ void postReplyTurn(conversationId, targetAgentId, {
348
+ status: "failed",
349
+ reason: `Prompt failed: ${promptError.message ?? String(promptError)}`,
350
+ });
351
+ return;
352
+ }
353
+
354
+ if (!doneSeen) {
355
+ log(
356
+ `[mail-inbound-reuse] Agent ${targetAgentId} finished prompt without calling done() ` +
357
+ `for taskId=${taskId} — posting "incomplete" reply`,
358
+ );
359
+ void postReplyTurn(conversationId, targetAgentId, {
360
+ status: "incomplete",
361
+ reason: "Agent did not call done() within the prompt cycle",
362
+ });
363
+ return;
364
+ }
365
+
366
+ void postReplyTurn(
367
+ conversationId,
368
+ targetAgentId,
369
+ `Dispatch ${taskId} ${status ?? "completed"} (no summary)`,
370
+ );
371
+ }
372
+
373
+ /**
374
+ * Detect a `done()` tool-call update and extract `{ status, summary }`
375
+ * from rawInput. Mirrors `promptUntilDone`'s detection logic but also
376
+ * captures `summary` (which the AgentManager's loop discards).
377
+ */
378
+ function captureDoneCall(
379
+ update: ExtendedSessionUpdate,
380
+ ): { status?: string; summary?: string } | null {
381
+ const u = update as unknown as Record<string, unknown>;
382
+ const sessionUpdate = u.sessionUpdate;
383
+ const title = u.title;
384
+
385
+ const isDoneToolCall =
386
+ (sessionUpdate === "tool_call" || sessionUpdate === "tool_call_update") &&
387
+ typeof title === "string" &&
388
+ title.endsWith("__done");
389
+
390
+ if (!isDoneToolCall) {
391
+ // Older fallback shape.
392
+ if (
393
+ u.type === "result" &&
394
+ u.subtype === "tool_result" &&
395
+ u.toolName === "done"
396
+ ) {
397
+ const result = u.result as { status?: string; summary?: string } | undefined;
398
+ if (result) {
399
+ return { status: result.status, summary: result.summary };
400
+ }
401
+ }
402
+ return null;
403
+ }
404
+
405
+ let input: { status?: string; summary?: string } | undefined;
406
+ try {
407
+ const raw = u.rawInput;
408
+ if (typeof raw === "string") {
409
+ input = JSON.parse(raw) as { status?: string; summary?: string };
410
+ } else if (raw && typeof raw === "object") {
411
+ input = raw as { status?: string; summary?: string };
412
+ } else if (u.input && typeof u.input === "object") {
413
+ input = u.input as { status?: string; summary?: string };
414
+ }
415
+ } catch {
416
+ // rawInput not yet parseable (multi-update tool call); ignore.
417
+ }
418
+
419
+ if (!input) return null;
420
+ return { status: input.status, summary: input.summary };
421
+ }
422
+
423
+ async function postReplyTurn(
424
+ conversationId: string | null,
425
+ fromAgentId: string,
426
+ content: string | { status: string; reason: string },
427
+ ): Promise<void> {
428
+ if (!conversationId) {
429
+ log(
430
+ `[mail-inbound-reuse] No conversationId — reply for ${fromAgentId} dropped: ` +
431
+ `${typeof content === "string" ? content.slice(0, 80) : content.status}`,
432
+ );
433
+ return;
434
+ }
435
+ const sidecar = getSidecar();
436
+ if (!sidecar?.postMailTurn) {
437
+ log(`[mail-inbound-reuse] No sidecar/postMailTurn — reply turn dropped`);
438
+ return;
439
+ }
440
+ const body = typeof content === "string" ? content : JSON.stringify(content);
441
+ try {
442
+ await sidecar.postMailTurn(conversationId, fromAgentId, body);
443
+ } catch (err) {
444
+ log(
445
+ `[mail-inbound-reuse] postMailTurn failed for ${fromAgentId}: ` +
446
+ `${(err as Error).message ?? String(err)}`,
447
+ );
448
+ }
449
+ }
450
+
451
+ inboxEvents.on("inbox.message", onMessage);
452
+
453
+ let stopped = false;
454
+ return {
455
+ stop() {
456
+ if (stopped) return;
457
+ stopped = true;
458
+ try {
459
+ if (inboxEvents.off) {
460
+ inboxEvents.off("inbox.message", onMessage);
461
+ } else if (inboxEvents.removeListener) {
462
+ inboxEvents.removeListener("inbox.message", onMessage);
463
+ }
464
+ } catch {
465
+ // best effort
466
+ }
467
+ log(`[mail-inbound-reuse] Consumer stopped`);
468
+ },
469
+ stats() {
470
+ pruneSeenTaskIds();
471
+ return {
472
+ droppedMalformed: droppedMalformedCount,
473
+ seenTaskIds: seenTaskIds.size,
474
+ busyRejects: busyRejectCount,
475
+ inflightCount: inflightDispatches.size,
476
+ };
477
+ },
478
+ };
479
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Permission Evaluator
3
+ *
4
+ * Pure function: given a tool call (name + input) and an overlay's
5
+ * permission rules, decide whether to deny, allow, or pass-through.
6
+ * Used by the `PreToolUse` hook installed at spawn time to enforce
7
+ * dispatch-supplied loadout permissions on a running session.
8
+ *
9
+ * Rule format (matches Claude Agent SDK convention):
10
+ *
11
+ * <ToolName> — match any call to this tool
12
+ * <ToolName>(<glob-pattern>) — match calls whose primary input
13
+ * field matches the glob pattern
14
+ *
15
+ * The "primary input field" is tool-specific:
16
+ *
17
+ * Bash → input.command
18
+ * Read → input.file_path
19
+ * Write → input.file_path
20
+ * Edit → input.file_path
21
+ * Grep → input.pattern
22
+ * <other> → no field-level match; rule must be bare `<ToolName>`
23
+ *
24
+ * Glob: `*` matches any sequence of characters (no path-segment
25
+ * distinction). Other regex specials are escaped.
26
+ *
27
+ * Decision precedence:
28
+ *
29
+ * 1. If any rule in `deny` matches → 'deny'
30
+ * 2. Else if any rule in `allow` matches → 'allow'
31
+ * 3. Else → 'pass-through' (let the session's static rules decide)
32
+ *
33
+ * `ask` rules are not evaluated here — the consumer is expected to
34
+ * collapse `ask` to either `allow` or `deny` before setting the
35
+ * overlay (via `collapsePermissionsForAutonomous` based on the
36
+ * spawn's `fullAutonomous` flag). The evaluator sees only collapsed
37
+ * `allow` and `deny` lists.
38
+ *
39
+ * @module dispatch/permission-evaluator
40
+ */
41
+
42
+ import type { OverlayPermissions } from "./permission-overlay.js";
43
+
44
+ export interface PermissionDecision {
45
+ decision: "allow" | "deny" | "pass-through";
46
+ matchedRule?: string;
47
+ matchedField?: string;
48
+ }
49
+
50
+ /**
51
+ * Tool-name → primary input field name. Add entries as needed for
52
+ * additional tools. Tools not listed have no field-level matching;
53
+ * only bare `<ToolName>` rules apply.
54
+ */
55
+ const PRIMARY_INPUT_FIELD: Record<string, string> = {
56
+ Bash: "command",
57
+ Read: "file_path",
58
+ Write: "file_path",
59
+ Edit: "file_path",
60
+ MultiEdit: "file_path",
61
+ Grep: "pattern",
62
+ Glob: "pattern",
63
+ NotebookRead: "notebook_path",
64
+ NotebookEdit: "notebook_path",
65
+ };
66
+
67
+ /**
68
+ * Evaluate a single tool call against an overlay's permission rules.
69
+ *
70
+ * Returns `'pass-through'` (the default) when no rule matches — the
71
+ * caller should fall back to the session's static permission rules
72
+ * for the final decision.
73
+ */
74
+ export function evaluatePermission(
75
+ toolName: string,
76
+ toolInput: unknown,
77
+ overlay: OverlayPermissions,
78
+ ): PermissionDecision {
79
+ // Deny rules win over allow.
80
+ for (const rule of overlay.deny ?? []) {
81
+ const match = matchRule(rule, toolName, toolInput);
82
+ if (match.matched) {
83
+ return {
84
+ decision: "deny",
85
+ matchedRule: rule,
86
+ ...(match.field ? { matchedField: match.field } : {}),
87
+ };
88
+ }
89
+ }
90
+
91
+ for (const rule of overlay.allow ?? []) {
92
+ const match = matchRule(rule, toolName, toolInput);
93
+ if (match.matched) {
94
+ return {
95
+ decision: "allow",
96
+ matchedRule: rule,
97
+ ...(match.field ? { matchedField: match.field } : {}),
98
+ };
99
+ }
100
+ }
101
+
102
+ return { decision: "pass-through" };
103
+ }
104
+
105
+ interface RuleMatch {
106
+ matched: boolean;
107
+ field?: string;
108
+ }
109
+
110
+ /**
111
+ * Test a single rule against a tool call. Returns whether the rule
112
+ * matched and which input field (if any) was tested.
113
+ *
114
+ * Exported only for testability; production callers should use
115
+ * `evaluatePermission`.
116
+ */
117
+ export function matchRule(
118
+ rule: string,
119
+ toolName: string,
120
+ toolInput: unknown,
121
+ ): RuleMatch {
122
+ const parsed = parseRule(rule);
123
+ if (!parsed) return { matched: false };
124
+ if (parsed.toolName !== toolName) return { matched: false };
125
+
126
+ // No pattern → any call to this tool matches.
127
+ if (parsed.pattern === undefined) return { matched: true };
128
+
129
+ // Empty pattern (`Bash()`) — also any call to this tool. Conservative.
130
+ if (parsed.pattern === "") return { matched: true };
131
+
132
+ const fieldName = PRIMARY_INPUT_FIELD[toolName];
133
+ if (!fieldName) {
134
+ // No primary field defined for this tool → can't match a pattern.
135
+ // Pattern-bearing rules for unknown tools never match (skip).
136
+ return { matched: false };
137
+ }
138
+
139
+ const fieldValue = readField(toolInput, fieldName);
140
+ if (typeof fieldValue !== "string") {
141
+ return { matched: false };
142
+ }
143
+
144
+ const re = globToRegex(parsed.pattern);
145
+ return {
146
+ matched: re.test(fieldValue),
147
+ field: fieldName,
148
+ };
149
+ }
150
+
151
+ interface ParsedRule {
152
+ toolName: string;
153
+ /** undefined → bare `<ToolName>` rule (no parentheses) */
154
+ pattern?: string;
155
+ }
156
+
157
+ function parseRule(rule: string): ParsedRule | null {
158
+ // Tool names allow letters/digits/underscores AND hyphens — MCP tools use
159
+ // hyphens in their server prefix (e.g., `mcp__agent-inbox__list_agents`).
160
+ const m = rule.match(/^([A-Za-z_][A-Za-z0-9_-]*)(?:\((.*)\))?$/);
161
+ if (!m) return null;
162
+ const [, toolName, pattern] = m;
163
+ if (pattern === undefined) {
164
+ return { toolName: toolName as string };
165
+ }
166
+ return { toolName: toolName as string, pattern };
167
+ }
168
+
169
+ function readField(input: unknown, field: string): unknown {
170
+ if (!input || typeof input !== "object") return undefined;
171
+ return (input as Record<string, unknown>)[field];
172
+ }
173
+
174
+ /**
175
+ * Convert a Claude permission glob into a regex. Only `*` is special;
176
+ * everything else is treated as a literal. The result is anchored
177
+ * (`^...$`) for whole-string matching.
178
+ */
179
+ function globToRegex(pattern: string): RegExp {
180
+ let out = "^";
181
+ for (const ch of pattern) {
182
+ if (ch === "*") {
183
+ out += ".*";
184
+ } else {
185
+ // Escape regex specials.
186
+ out += ch.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
187
+ }
188
+ }
189
+ out += "$";
190
+ return new RegExp(out);
191
+ }