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
@@ -37,7 +37,28 @@ export function createLifecycleBridge(
37
37
  scope: string,
38
38
  taskBridge?: TaskBridge,
39
39
  getLocalMapId?: (localAgentId: string) => string | undefined,
40
- ): { callback: AgentLifecycleCallback; cleanup: () => Promise<void> } {
40
+ ): {
41
+ callback: AgentLifecycleCallback;
42
+ cleanup: () => Promise<void>;
43
+ /**
44
+ * Resolve true once the named agent has completed `map/agents/register`
45
+ * with the hub (its entry.mapId is populated). Used by the dispatch
46
+ * spawn-agent handler to barrier-wait for hub-side registration before
47
+ * returning, so the orchestrator's subsequent `findAcpAgentInfo` lookup
48
+ * doesn't race the registration.
49
+ *
50
+ * Returns false if the timeout elapses before registration completes.
51
+ */
52
+ awaitRegistration: (agentId: string, timeoutMs?: number) => Promise<boolean>;
53
+ /**
54
+ * Reverse-lookup: hub-assigned MAP ULID → local agent id. Used by the
55
+ * `map/dispatch/message` handler in the sidecar to translate envelope
56
+ * recipients (which the hub addresses by MAP ULID) into local agent ids
57
+ * (which the inbox addresses messages by). Returns undefined when no
58
+ * registered agent matches.
59
+ */
60
+ findLocalAgentByMapId: (mapId: string) => string | undefined;
61
+ } {
41
62
  const registered = new Map<string, RegisteredAgent>();
42
63
 
43
64
  /**
@@ -186,5 +207,30 @@ export function createLifecycleBridge(
186
207
  registered.clear();
187
208
  };
188
209
 
189
- return { callback, cleanup };
210
+ /**
211
+ * Block until the named agent's hub-side registration completes (entry
212
+ * has been assigned a mapId by the `map/agents/register` response) or
213
+ * the timeout elapses. Polls the local `registered` map.
214
+ */
215
+ const awaitRegistration = async (
216
+ agentId: string,
217
+ timeoutMs = 5000,
218
+ ): Promise<boolean> => {
219
+ const deadline = Date.now() + timeoutMs;
220
+ while (Date.now() < deadline) {
221
+ const entry = registered.get(agentId);
222
+ if (entry?.mapId) return true;
223
+ await new Promise((r) => setTimeout(r, 50));
224
+ }
225
+ return Boolean(registered.get(agentId)?.mapId);
226
+ };
227
+
228
+ const findLocalAgentByMapId = (mapId: string): string | undefined => {
229
+ for (const [localId, entry] of registered) {
230
+ if (entry.mapId === mapId) return localId;
231
+ }
232
+ return undefined;
233
+ };
234
+
235
+ return { callback, cleanup, awaitRegistration, findLocalAgentByMapId };
190
236
  }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Mail Bridge — connects OpenHive's hub-side mail to macro-agent's local inbox.
3
+ *
4
+ * OpenHive's mail module (src/mail/index.ts) calls forwardTurnToSwarms() when
5
+ * a new mail turn lands in a conversation a swarm participates in. The hub
6
+ * sends a `mail/turn.received` JSON-RPC notification to the connected swarm
7
+ * sidecar via MAP. macro-agent's sidecar (this module) receives the
8
+ * notification, parses the turn content, and forwards it into the local
9
+ * agent-inbox so:
10
+ *
11
+ * 1. swarm-dispatch's MessagePort (boot-v2 wires `createAgentInboxPort`)
12
+ * sees the inbox delivery event and runs its classifier on the message.
13
+ * 2. The classifier recognizes `x-dispatch/work` schema and routes the
14
+ * prompt to a worker agent (existing or freshly spawned).
15
+ *
16
+ * Without this bridge, hub-side mail turns never reach macro-agent's
17
+ * dispatcher. The MessagePort is wired to local inbox events only.
18
+ */
19
+ import type { InboxAdapter } from "../adapters/types.js";
20
+
21
+ /**
22
+ * Subset of MAPClient surface this module needs. Mirrors the shape used by
23
+ * trajectory-reporter / coordination-handler — keeps the bridge testable
24
+ * without dragging in the full SDK type.
25
+ */
26
+ export interface MailBridgeConnection {
27
+ onNotification(
28
+ method: string,
29
+ handler: (params: unknown) => void | Promise<void>,
30
+ ): void;
31
+ offNotification(
32
+ method: string,
33
+ handler: (params: unknown) => void | Promise<void>,
34
+ ): void;
35
+ }
36
+
37
+ /**
38
+ * Synthetic recipient registered on the local inbox to receive bridged
39
+ * turns. Choosing a stable name lets the macro-agent dispatcher (or any
40
+ * other inbox observer) filter on `to: BRIDGE_RECIPIENT_ID` if it cares
41
+ * about the source — but normally it doesn't, since the dispatcher's
42
+ * MessagePort matches on the `x-dispatch/work` schema in the payload, not
43
+ * on the recipient id.
44
+ */
45
+ const BRIDGE_RECIPIENT_ID = "openhive-mail-bridge";
46
+
47
+ interface MailTurnReceivedParams {
48
+ conversation_id?: string;
49
+ turn_id?: string;
50
+ participant_id?: string;
51
+ content_type?: string;
52
+ content?: unknown;
53
+ thread_id?: string;
54
+ created_at?: string;
55
+ }
56
+
57
+ /**
58
+ * Parse the turn content into a JS object suitable for inbox routing.
59
+ * Returns null when the content is not parseable as JSON or doesn't look
60
+ * like an object payload (e.g., plain text mail turns).
61
+ */
62
+ function parseTurnContent(
63
+ content: unknown,
64
+ contentType: string | undefined,
65
+ ): Record<string, unknown> | null {
66
+ if (typeof content === "object" && content !== null) {
67
+ return content as Record<string, unknown>;
68
+ }
69
+ if (typeof content !== "string") return null;
70
+ // The hub serializes envelope objects via JSON.stringify and tags them
71
+ // application/json. We accept either explicit content type or a leading
72
+ // `{` to handle clients that omit the header.
73
+ if (
74
+ contentType === "application/json" ||
75
+ content.startsWith("{") ||
76
+ content.startsWith("[")
77
+ ) {
78
+ try {
79
+ const parsed = JSON.parse(content);
80
+ return typeof parsed === "object" && parsed !== null
81
+ ? (parsed as Record<string, unknown>)
82
+ : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ export interface MailBridgeOptions {
91
+ connection: MailBridgeConnection;
92
+ inboxAdapter: InboxAdapter;
93
+ /**
94
+ * The dispatcher agent ID used by swarm-dispatch's createAgentInboxPort.
95
+ * Messages must be delivered TO this ID so the MessagePort's onIncoming
96
+ * filter (event.agentId === dispatcherAgentId) fires correctly.
97
+ * When omitted the bridge falls back to BRIDGE_RECIPIENT_ID (inert —
98
+ * the MessagePort will never see the message).
99
+ */
100
+ dispatcherAgentId?: string;
101
+ /**
102
+ * Optional logger for diagnostic output. The sidecar currently logs via
103
+ * console; swap if you want structured output.
104
+ */
105
+ log?: (msg: string) => void;
106
+ }
107
+
108
+ /**
109
+ * Wire the mail bridge. Returns a cleanup function that detaches the
110
+ * notification handler.
111
+ *
112
+ * Idempotent registration: register is called exactly once; the handler
113
+ * itself is best-effort (errors are caught and logged so a malformed turn
114
+ * doesn't break subsequent ones).
115
+ */
116
+ export async function setupMailBridge(
117
+ opts: MailBridgeOptions,
118
+ ): Promise<() => void> {
119
+ const { connection, inboxAdapter, dispatcherAgentId, log = () => {} } = opts;
120
+
121
+ // Determine the inbox recipient. When a dispatcherAgentId is provided we
122
+ // deliver directly to the dispatcher so createAgentInboxPort.onIncoming
123
+ // (which filters event.agentId === dispatcherAgentId) picks it up.
124
+ const recipientId = dispatcherAgentId ?? BRIDGE_RECIPIENT_ID;
125
+
126
+ // Register the recipient on the local inbox so routeMessage accepts the
127
+ // forwarded turn. Idempotent — registerAgent putAgent is an upsert.
128
+ await inboxAdapter.registerAgent(recipientId, {
129
+ name: dispatcherAgentId ? "OpenHive Dispatcher (bridged)" : "OpenHive Mail Bridge",
130
+ role: dispatcherAgentId ? "dispatcher" : "mail-bridge",
131
+ scope: "default",
132
+ metadata: { source: "openhive-mail-forward" },
133
+ });
134
+
135
+ const handler = async (params: unknown): Promise<void> => {
136
+ const turn = (params ?? {}) as MailTurnReceivedParams;
137
+ const raw = parseTurnContent(turn.content, turn.content_type);
138
+ if (!raw) {
139
+ log(
140
+ `[mail-bridge] Dropping non-JSON turn (conv=${turn.conversation_id ?? "?"} ` +
141
+ `participant=${turn.participant_id ?? "?"})`,
142
+ );
143
+ return;
144
+ }
145
+
146
+ // Translate hub envelope shape { type, body } → canonical inbox shape
147
+ // { schema, data } that classifyMessage in boot-v2 expects.
148
+ // Hub sends: { type: "x-dispatch/work", body: { prompt, taskId, role } }
149
+ // Classifier expects: { schema: "x-dispatch/work", data: { prompt, taskId, role } }
150
+ const hubType = (raw as { type?: string }).type;
151
+ const hubBody = (raw as { body?: Record<string, unknown> }).body;
152
+ const content: Record<string, unknown> =
153
+ hubType && hubBody
154
+ ? { schema: hubType, data: hubBody }
155
+ : raw;
156
+
157
+ // Attach conversation_id as a top-level field in the payload so
158
+ // the mail-inbound consumer can thread it into the worker spawn.
159
+ // This lets the reply bridge look up which hub conversation to post
160
+ // the worker's output back to.
161
+ //
162
+ // IMPORTANT: include `type: "data"` so agent-inbox's normalizeContent()
163
+ // passes the object through unchanged. Without it, an object lacking
164
+ // a `type` string is re-wrapped as `{ type:"data", data: original }`,
165
+ // burying `schema` one level deeper and breaking the consumer's filter.
166
+ const contentWithConvId: Record<string, unknown> = {
167
+ type: "data",
168
+ ...content,
169
+ ...(turn.conversation_id ? { _conversationId: turn.conversation_id } : {}),
170
+ };
171
+
172
+ try {
173
+ await inboxAdapter.send(
174
+ turn.participant_id ?? "openhive-hub",
175
+ recipientId,
176
+ contentWithConvId as never,
177
+ {
178
+ threadTag: turn.thread_id,
179
+ importance: "normal",
180
+ },
181
+ );
182
+ log(
183
+ `[mail-bridge] Forwarded turn ${turn.turn_id ?? "?"} into local inbox ` +
184
+ `(schema=${(content as { schema?: string }).schema ?? "n/a"})`,
185
+ );
186
+ } catch (err) {
187
+ log(
188
+ `[mail-bridge] Forward failed for turn ${turn.turn_id ?? "?"}: ` +
189
+ `${(err as Error).message}`,
190
+ );
191
+ }
192
+ };
193
+
194
+ connection.onNotification("mail/turn.received", handler);
195
+
196
+ return () => {
197
+ try {
198
+ connection.offNotification("mail/turn.received", handler);
199
+ } catch {
200
+ // best effort
201
+ }
202
+ };
203
+ }
@@ -35,7 +35,7 @@ export function createMAPSidecar(
35
35
  deps: MAPSidecarDeps,
36
36
  config: MAPSidecarConfig,
37
37
  ): MAPSidecar {
38
- const { agentManager, agentStore, inboxAdapter, tasksAdapter, getLocalMapId, gitCascadeAdapter } = deps;
38
+ const { agentManager, agentStore, inboxAdapter, tasksAdapter, getLocalMapId, gitCascadeAdapter, dispatcherAgentId } = deps;
39
39
  const scope = config.scope ?? "swarm:macro-agent";
40
40
  const agentName = config.agentName ?? "macro-agent-sidecar";
41
41
 
@@ -51,6 +51,10 @@ export function createMAPSidecar(
51
51
  let taskBridge: TaskBridge | null = null;
52
52
  let coordinationCleanup: (() => void) | null = null;
53
53
  let cascadeBridgeCleanup: (() => void) | null = null;
54
+ let mailBridgeCleanup: (() => void) | null = null;
55
+ let dispatchSpawnHandlerCleanup: (() => void) | null = null;
56
+ let dispatchMessageHandlerCleanup: (() => void) | null = null;
57
+ let dispatchPermissionsHandlerCleanup: (() => void) | null = null;
54
58
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
55
59
 
56
60
  /**
@@ -83,10 +87,26 @@ export function createMAPSidecar(
83
87
  coordinationCleanup();
84
88
  coordinationCleanup = null;
85
89
  }
90
+ if (mailBridgeCleanup) {
91
+ try { mailBridgeCleanup(); } catch { /* non-critical */ }
92
+ mailBridgeCleanup = null;
93
+ }
86
94
  if (cascadeBridgeCleanup) {
87
95
  try { cascadeBridgeCleanup(); } catch { /* non-critical */ }
88
96
  cascadeBridgeCleanup = null;
89
97
  }
98
+ if (dispatchMessageHandlerCleanup) {
99
+ try { dispatchMessageHandlerCleanup(); } catch { /* non-critical */ }
100
+ dispatchMessageHandlerCleanup = null;
101
+ }
102
+ if (dispatchSpawnHandlerCleanup) {
103
+ try { dispatchSpawnHandlerCleanup(); } catch { /* non-critical */ }
104
+ dispatchSpawnHandlerCleanup = null;
105
+ }
106
+ if (dispatchPermissionsHandlerCleanup) {
107
+ try { dispatchPermissionsHandlerCleanup(); } catch { /* non-critical */ }
108
+ dispatchPermissionsHandlerCleanup = null;
109
+ }
90
110
  if (trajectoryReporter) {
91
111
  trajectoryReporter.stop();
92
112
  trajectoryReporter = null;
@@ -278,6 +298,8 @@ export function createMAPSidecar(
278
298
  );
279
299
  lifecycleCallback = bridge.callback;
280
300
  lifecycleCleanup = bridge.cleanup;
301
+ const awaitAcpRegistration = bridge.awaitRegistration;
302
+ const findLocalAgentByMapId = bridge.findLocalAgentByMapId;
281
303
  lifecycleUnsubscribe = agentManager.onLifecycleEvent(lifecycleCallback);
282
304
 
283
305
  // 3. Trajectory Reporter
@@ -294,6 +316,300 @@ export function createMAPSidecar(
294
316
  trajectoryReporter,
295
317
  });
296
318
 
319
+ // 4b. Mail Bridge — forwards `mail/turn.received` notifications from the
320
+ // hub into the local agent-inbox so swarm-dispatch's MessagePort can
321
+ // pick them up via its `inbox.events` subscription. Without this,
322
+ // hub-side mail never reaches the dispatcher.
323
+ const { setupMailBridge } = await import("./mail-bridge.js");
324
+ mailBridgeCleanup = await setupMailBridge({
325
+ connection,
326
+ inboxAdapter,
327
+ dispatcherAgentId,
328
+ log: (msg) => console.log(msg),
329
+ });
330
+
331
+ // 4c. x-dispatch/spawn-agent handler — notification-pair pattern.
332
+ //
333
+ // The MAP SDK's AgentConnection doesn't expose setRequestHandler, so
334
+ // the hub→swarm spawn-agent "request" is sent as a notification
335
+ // with a correlation_id. We process and reply with a `.response`
336
+ // notification carrying the same correlation_id (or an error).
337
+ //
338
+ // Dual-listen: subscribe to BOTH the canonical
339
+ // `x-dispatch/spawn-agent.request` (Tier 2+, owned by swarm-dispatch)
340
+ // AND the legacy `dispatch/spawn-agent.request` for one release
341
+ // window. Reply on the matching channel — the hub's response
342
+ // dispatcher accepts both.
343
+ const { handleDispatchSpawnAgent } = await import(
344
+ "../dispatch/spawn-agent-handler.js"
345
+ );
346
+ const {
347
+ handleSpawnAgentRequest,
348
+ X_DISPATCH_METHODS: SPAWN_METHODS,
349
+ LEGACY_DISPATCH_SPAWN_AGENT_REQUEST,
350
+ LEGACY_DISPATCH_SPAWN_AGENT_RESPONSE,
351
+ } = await import("swarm-dispatch/client");
352
+
353
+ const makeSpawnHandler = (
354
+ responseMethod: string,
355
+ ): ((params: unknown) => Promise<void>) =>
356
+ async (params) => {
357
+ await handleSpawnAgentRequest({
358
+ params,
359
+ runtime: {
360
+ async spawn(req) {
361
+ return handleDispatchSpawnAgent(
362
+ req as unknown as Parameters<typeof handleDispatchSpawnAgent>[0],
363
+ {
364
+ agentManager,
365
+ // Wait barrier: lifecycle-bridge resolves once
366
+ // `map/agents/register` completes, so the orchestrator's
367
+ // subsequent `findAcpAgentInfo` lookup doesn't race.
368
+ waitForAcpRegistration: awaitAcpRegistration,
369
+ log: (msg) => console.log(msg),
370
+ },
371
+ );
372
+ },
373
+ },
374
+ sendResponse: async (responseParams) => {
375
+ await connection.sendNotification(responseMethod, responseParams);
376
+ },
377
+ log: (msg) => console.log(msg),
378
+ });
379
+ };
380
+
381
+ const canonicalSpawnHandler = makeSpawnHandler(
382
+ SPAWN_METHODS.SPAWN_AGENT_RESPONSE,
383
+ );
384
+ const legacySpawnHandler = makeSpawnHandler(
385
+ LEGACY_DISPATCH_SPAWN_AGENT_RESPONSE,
386
+ );
387
+
388
+ connection.onNotification(
389
+ SPAWN_METHODS.SPAWN_AGENT_REQUEST,
390
+ canonicalSpawnHandler,
391
+ );
392
+ connection.onNotification(
393
+ LEGACY_DISPATCH_SPAWN_AGENT_REQUEST,
394
+ legacySpawnHandler,
395
+ );
396
+ dispatchSpawnHandlerCleanup = () => {
397
+ try {
398
+ if (typeof connection.offNotification === "function") {
399
+ connection.offNotification(
400
+ SPAWN_METHODS.SPAWN_AGENT_REQUEST,
401
+ canonicalSpawnHandler,
402
+ );
403
+ connection.offNotification(
404
+ LEGACY_DISPATCH_SPAWN_AGENT_REQUEST,
405
+ legacySpawnHandler,
406
+ );
407
+ }
408
+ } catch {
409
+ /* connection already torn down */
410
+ }
411
+ };
412
+
413
+ // 4c'. x-dispatch/permissions.{set,clear} handlers — notification-pair
414
+ // pattern, mirrors spawn-agent's shape. Used by hubs (e.g. OpenHive's
415
+ // ACP+reuse dispatch path) to apply per-dispatch loadout deny/allow
416
+ // rules to a long-lived agent's session at runtime via the
417
+ // permission-overlay registry. The prompt iterator in
418
+ // `agent-manager-v2.ts` enforces the overlay against
419
+ // `permission_request` ACP session updates. Pairs with the mail+reuse
420
+ // path's overlay set/clear in `mail-inbound-reuse-consumer.ts` —
421
+ // same registry, different transport.
422
+ const {
423
+ handlePermissionsSet,
424
+ handlePermissionsClear,
425
+ X_DISPATCH_PERMISSIONS_METHODS,
426
+ } = await import("../dispatch/permissions-handler.js");
427
+
428
+ // Response shape per swarm-dispatch's notification-rpc registry:
429
+ // { correlation_id, result } → resolve(result)
430
+ // { correlation_id, error: { code?, message? } } → reject
431
+ // The handler's `{ ok, error?: string }` is wrapped accordingly.
432
+ const sendPermissionsResponse = async (
433
+ method: string,
434
+ correlationId: string | undefined,
435
+ result: { ok: true } | { ok: false; error: string },
436
+ ): Promise<void> => {
437
+ const body: Record<string, unknown> = {};
438
+ if (correlationId) body.correlation_id = correlationId;
439
+ if (result.ok) {
440
+ body.result = result;
441
+ } else {
442
+ body.error = { message: result.error };
443
+ }
444
+ try {
445
+ await connection.sendNotification(method, body);
446
+ } catch (err) {
447
+ console.warn(
448
+ `[${method}] response send failed: ${(err as Error).message}`,
449
+ );
450
+ }
451
+ };
452
+
453
+ const permissionsSetHandler = async (params: unknown): Promise<void> => {
454
+ const correlationId =
455
+ (params as { correlation_id?: string })?.correlation_id;
456
+ const result = handlePermissionsSet(
457
+ params as Parameters<typeof handlePermissionsSet>[0],
458
+ (msg) => console.log(msg),
459
+ );
460
+ await sendPermissionsResponse(
461
+ X_DISPATCH_PERMISSIONS_METHODS.SET_RESPONSE,
462
+ correlationId,
463
+ result,
464
+ );
465
+ };
466
+
467
+ const permissionsClearHandler = async (params: unknown): Promise<void> => {
468
+ const correlationId =
469
+ (params as { correlation_id?: string })?.correlation_id;
470
+ const result = handlePermissionsClear(
471
+ params as Parameters<typeof handlePermissionsClear>[0],
472
+ (msg) => console.log(msg),
473
+ );
474
+ await sendPermissionsResponse(
475
+ X_DISPATCH_PERMISSIONS_METHODS.CLEAR_RESPONSE,
476
+ correlationId,
477
+ result,
478
+ );
479
+ };
480
+
481
+ connection.onNotification(
482
+ X_DISPATCH_PERMISSIONS_METHODS.SET_REQUEST,
483
+ permissionsSetHandler,
484
+ );
485
+ connection.onNotification(
486
+ X_DISPATCH_PERMISSIONS_METHODS.CLEAR_REQUEST,
487
+ permissionsClearHandler,
488
+ );
489
+ dispatchPermissionsHandlerCleanup = () => {
490
+ try {
491
+ if (typeof connection.offNotification === "function") {
492
+ connection.offNotification(
493
+ X_DISPATCH_PERMISSIONS_METHODS.SET_REQUEST,
494
+ permissionsSetHandler,
495
+ );
496
+ connection.offNotification(
497
+ X_DISPATCH_PERMISSIONS_METHODS.CLEAR_REQUEST,
498
+ permissionsClearHandler,
499
+ );
500
+ }
501
+ } catch {
502
+ /* connection already torn down */
503
+ }
504
+ };
505
+
506
+ // 4d. map/dispatch/message handler — receives hub-routed envelopes
507
+ // addressed to a specific agent on this swarm via MAP scope. The hub
508
+ // takes this path (not mail/turn) when the target agent declares
509
+ // `messaging.canReceive: true` per-agent but not `mail.canJoin`,
510
+ // which is the default for long-lived workers/coordinators registered
511
+ // by the lifecycle bridge. Without this handler, mail+reuse dispatches
512
+ // are silently dropped on the swarm side.
513
+ //
514
+ // Translate the hub-assigned MAP ULID (`to_agent_id`) → local agent
515
+ // id and forward the envelope into the local inbox so the new
516
+ // `mail-inbound-reuse-consumer` picks it up.
517
+ const dispatchMessageHandler = async (params: unknown): Promise<void> => {
518
+ const p = params as
519
+ | (Record<string, unknown> & {
520
+ to_agent_id?: string;
521
+ envelope?: unknown;
522
+ from_agent_id?: string;
523
+ })
524
+ | undefined;
525
+ const toAgentId = p?.to_agent_id;
526
+ const envelope = p?.envelope;
527
+ if (!toAgentId || !envelope) {
528
+ console.warn(
529
+ "[sidecar] map/dispatch/message missing to_agent_id or envelope; ignoring",
530
+ );
531
+ return;
532
+ }
533
+ const localAgentId = findLocalAgentByMapId(toAgentId);
534
+ if (!localAgentId) {
535
+ console.warn(
536
+ `[sidecar] map/dispatch/message recipient ${toAgentId} not registered locally; dropping`,
537
+ );
538
+ return;
539
+ }
540
+ // Translate envelope { type, body } → { schema, data } shape that the
541
+ // mail-inbound-reuse-consumer expects (mirrors mail-bridge's
542
+ // translation for `mail/turn.received`).
543
+ //
544
+ // Hub-side mail-transport injects `body._conversationId` when sending
545
+ // via MAP scope (sendViaMapScope) — extract it here and surface it
546
+ // on the top-level content (alongside `data`) so the
547
+ // mail-inbound-reuse-consumer's reply path can `postMailTurn` to
548
+ // the right conversation. Without this, the consumer drops the
549
+ // reply with "No conversationId".
550
+ const env = envelope as { type?: string; body?: Record<string, unknown> };
551
+ const conversationId =
552
+ env.body && typeof env.body._conversationId === "string"
553
+ ? (env.body._conversationId as string)
554
+ : undefined;
555
+ const content: Record<string, unknown> =
556
+ env.type && env.body
557
+ ? { schema: env.type, data: env.body }
558
+ : (envelope as Record<string, unknown>);
559
+ const contentWithMarker: Record<string, unknown> = {
560
+ type: "data",
561
+ ...content,
562
+ ...(conversationId ? { _conversationId: conversationId } : {}),
563
+ };
564
+ try {
565
+ await inboxAdapter.send(
566
+ (p?.from_agent_id as string | undefined) ?? "openhive-hub",
567
+ localAgentId,
568
+ contentWithMarker as never,
569
+ { importance: "normal" },
570
+ );
571
+ } catch (err) {
572
+ console.warn(
573
+ `[sidecar] map/dispatch/message inbox.send failed for ${localAgentId}: ` +
574
+ `${(err as Error).message}`,
575
+ );
576
+ }
577
+ };
578
+ // Dual-listen: subscribe to BOTH the canonical `x-dispatch/message`
579
+ // (the new method name owned by swarm-dispatch's protocol-constants
580
+ // module) AND the legacy `map/dispatch/message` alias for one
581
+ // release window. Older hub builds send under the legacy name; new
582
+ // builds send under the canonical name. Once the dual-listen window
583
+ // closes, drop the legacy registration.
584
+ const {
585
+ X_DISPATCH_METHODS,
586
+ LEGACY_MAP_DISPATCH_MESSAGE_METHOD,
587
+ } = await import("swarm-dispatch/client");
588
+ connection.onNotification(
589
+ X_DISPATCH_METHODS.MESSAGE,
590
+ dispatchMessageHandler,
591
+ );
592
+ connection.onNotification(
593
+ LEGACY_MAP_DISPATCH_MESSAGE_METHOD,
594
+ dispatchMessageHandler,
595
+ );
596
+ dispatchMessageHandlerCleanup = () => {
597
+ try {
598
+ if (typeof connection.offNotification === "function") {
599
+ connection.offNotification(
600
+ X_DISPATCH_METHODS.MESSAGE,
601
+ dispatchMessageHandler,
602
+ );
603
+ connection.offNotification(
604
+ LEGACY_MAP_DISPATCH_MESSAGE_METHOD,
605
+ dispatchMessageHandler,
606
+ );
607
+ }
608
+ } catch {
609
+ /* connection already torn down */
610
+ }
611
+ };
612
+
297
613
  // 5. Cascade Bridge + Action Handler (optional — only when a GitCascadeAdapter is available)
298
614
  if (gitCascadeAdapter) {
299
615
  const { createCascadeBridge } = await import("./cascade-bridge.js");
@@ -363,5 +679,34 @@ export function createMAPSidecar(
363
679
  // Best effort — MAP hub may be temporarily unavailable
364
680
  }
365
681
  },
682
+
683
+ async postMailTurn(
684
+ conversationId: string,
685
+ participantId: string,
686
+ content: string,
687
+ ): Promise<void> {
688
+ if (!connection || !isConnected) {
689
+ console.warn(
690
+ `[map-sidecar] postMailTurn skipped (connection=${!!connection} ` +
691
+ `isConnected=${isConnected}) conv=${conversationId}`,
692
+ );
693
+ return;
694
+ }
695
+ try {
696
+ await connection.sendNotification("mail/turn", {
697
+ conversationId,
698
+ participantId,
699
+ contentType: "text/plain",
700
+ content,
701
+ });
702
+ } catch (err) {
703
+ // Best effort — hub may be temporarily unavailable. Log at warn
704
+ // so silent failures are visible during postmortem.
705
+ console.warn(
706
+ `[map-sidecar] postMailTurn failed for conv=${conversationId}: ` +
707
+ `${(err as Error).message ?? String(err)}`,
708
+ );
709
+ }
710
+ },
366
711
  };
367
712
  }
package/src/map/types.ts CHANGED
@@ -83,6 +83,15 @@ export interface MAPSidecarDeps {
83
83
  * without hub observability).
84
84
  */
85
85
  gitCascadeAdapter?: import("../workspace/git-cascade-adapter.js").GitCascadeAdapter;
86
+
87
+ /**
88
+ * The swarm-dispatch dispatcher agent ID (e.g. "dispatcher:<claimantId>").
89
+ * When provided, the mail bridge delivers hub-forwarded mail turns directly
90
+ * to this inbox recipient so createAgentInboxPort.onIncoming fires
91
+ * correctly. Without it, bridged turns land in BRIDGE_RECIPIENT_ID and the
92
+ * MessagePort never sees them.
93
+ */
94
+ dispatcherAgentId?: string;
86
95
  }
87
96
 
88
97
  // =============================================================================
@@ -106,6 +115,18 @@ export interface MAPSidecar {
106
115
 
107
116
  /** Emit a custom event to the MAP hub scope (best-effort, no-op if disconnected) */
108
117
  emitEvent?(event: Record<string, unknown>): Promise<void>;
118
+
119
+ /**
120
+ * Post a mail turn back to the hub via the `mail/turn` MAP notification.
121
+ * Used by the dispatch reply bridge to forward worker output into the hub's
122
+ * mail conversation after a mail-inbound task completes.
123
+ * No-op if disconnected.
124
+ */
125
+ postMailTurn?(
126
+ conversationId: string,
127
+ participantId: string,
128
+ content: string,
129
+ ): Promise<void>;
109
130
  }
110
131
 
111
132
  // =============================================================================
@@ -178,6 +178,7 @@ export function createDoneHandlerV2(
178
178
  inboxAdapter,
179
179
  tasksAdapter,
180
180
  agentManager,
181
+ agentStore,
181
182
  taskMode: deps.taskMode,
182
183
  mergeQueue: deps.mergeQueue,
183
184
  };