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.
- 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/team-loader.d.ts.map +1 -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 -5
- 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/team-loader.ts +3 -1
- package/src/teams/team-runtime-v2.ts +2 -0
- package/dist/workspace/dataplane-adapter.d.ts +0 -260
- package/dist/workspace/dataplane-adapter.d.ts.map +0 -1
- package/dist/workspace/dataplane-adapter.js +0 -416
- 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
|
-
): {
|
|
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
|
-
|
|
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
|
+
}
|
package/src/map/sidecar.ts
CHANGED
|
@@ -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
|
// =============================================================================
|