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,89 @@
1
+ /**
2
+ * Permission Overlay Registry
3
+ *
4
+ * Process-singleton map of `agentId → Permissions` for in-flight
5
+ * dispatches. The `PreToolUse` hook installed at spawn time consults
6
+ * this registry on every tool call. Dispatch consumers (e.g., the
7
+ * mail-inbound-reuse-consumer) set the overlay when claiming an
8
+ * in-flight dispatch and clear it on resolution.
9
+ *
10
+ * Why a registry rather than a per-spawn argument:
11
+ * - The session is created with permissive rules at boot. The
12
+ * dispatch's narrower deny rules need to apply at *call time*, not
13
+ * at spawn time, because the agent already exists when the dispatch
14
+ * arrives. The hook closure captures `agentId` and reads the
15
+ * registry per-call so updates propagate without recreating the
16
+ * session.
17
+ *
18
+ * Semantics:
19
+ * - Intersection-only: the overlay can ADD denies to a session but
20
+ * cannot grant new allows. If the session was spawned with broad
21
+ * permissions and the overlay says `allow: [Read]`, the session's
22
+ * other tools still work — the overlay only tightens.
23
+ * - Single overlay per agent: `mail-inbound-reuse-consumer` already
24
+ * enforces one in-flight dispatch per agent (`recipient_busy`
25
+ * reject), so overwriting is safe.
26
+ *
27
+ * @module dispatch/permission-overlay
28
+ */
29
+
30
+ export interface OverlayPermissions {
31
+ allow?: string[];
32
+ deny?: string[];
33
+ ask?: string[];
34
+ }
35
+
36
+ const overlays = new Map<string, OverlayPermissions>();
37
+
38
+ /**
39
+ * Apply a permission overlay for an agent. Subsequent tool calls by
40
+ * that agent flow through the `PreToolUse` hook, which consults this
41
+ * registry. Overwrites any prior overlay for the same agent.
42
+ */
43
+ export function setPermissionOverlay(
44
+ agentId: string,
45
+ perms: OverlayPermissions,
46
+ ): void {
47
+ overlays.set(agentId, perms);
48
+ }
49
+
50
+ /**
51
+ * Remove the active overlay for an agent. Subsequent tool calls fall
52
+ * back to the session's static permission rules.
53
+ */
54
+ export function clearPermissionOverlay(agentId: string): void {
55
+ overlays.delete(agentId);
56
+ }
57
+
58
+ /**
59
+ * Read the current overlay for an agent. Returns `undefined` when no
60
+ * overlay is in effect — the hook treats that as pass-through.
61
+ */
62
+ export function getPermissionOverlay(
63
+ agentId: string,
64
+ ): OverlayPermissions | undefined {
65
+ return overlays.get(agentId);
66
+ }
67
+
68
+ /**
69
+ * Clear all overlays. Used as defense-in-depth when a fresh consumer
70
+ * starts (process startup); also exposed for test isolation.
71
+ */
72
+ export function clearAllPermissionOverlays(): void {
73
+ overlays.clear();
74
+ }
75
+
76
+ /**
77
+ * Test helper — alias for `clearAllPermissionOverlays`. Kept distinct
78
+ * so it's grep-able for "test-only" cleanup paths.
79
+ */
80
+ export function _resetForTest(): void {
81
+ overlays.clear();
82
+ }
83
+
84
+ /**
85
+ * Test helper — number of overlays currently active.
86
+ */
87
+ export function _sizeForTest(): number {
88
+ return overlays.size;
89
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `x-dispatch/permissions.set` and `x-dispatch/permissions.clear` MAP
3
+ * notification handlers — runtime-agnostic wire shape, macro-agent-specific
4
+ * implementation.
5
+ *
6
+ * Called by OpenHive's orchestrator on the ACP+reuse dispatch path to apply
7
+ * per-dispatch loadout deny/allow rules to a long-lived agent's session at
8
+ * runtime, without recreating the session. The handler invokes
9
+ * `setPermissionOverlay` (resp. `clearPermissionOverlay`); the prompt
10
+ * iterator in `agent-manager-v2.ts` enforces the overlay against
11
+ * `permission_request` ACP session updates.
12
+ *
13
+ * See `docs/PERMISSION_OVERLAY_ACP_DESIGN.md` for the four-process flow
14
+ * diagram and rationale. The mail+reuse path uses the same overlay
15
+ * registry but sets/clears it directly from `mail-inbound-reuse-consumer`
16
+ * (which has a clean entry point on swarm side); ACP+reuse needs this MAP
17
+ * wire because the dispatch's prompt arrives through the ACP server with
18
+ * no equivalent consumer to bracket.
19
+ *
20
+ * Wire shape (set):
21
+ *
22
+ * request: { agent_id: string, deny?: string[], allow?: string[] }
23
+ * response: { ok: true } | { ok: false, error: string }
24
+ *
25
+ * Wire shape (clear):
26
+ *
27
+ * request: { agent_id: string }
28
+ * response: { ok: true } | { ok: false, error: string }
29
+ *
30
+ * Notification-pair pattern matches the existing `x-dispatch/spawn-agent`
31
+ * handler (the MAP SDK's AgentConnection doesn't expose
32
+ * setRequestHandler, so we use notifications with correlation_ids).
33
+ */
34
+
35
+ import {
36
+ setPermissionOverlay,
37
+ clearPermissionOverlay,
38
+ } from "./permission-overlay.js";
39
+
40
+ export interface PermissionsSetRequest {
41
+ agent_id: string;
42
+ deny?: string[];
43
+ allow?: string[];
44
+ }
45
+
46
+ export interface PermissionsClearRequest {
47
+ agent_id: string;
48
+ }
49
+
50
+ export type PermissionsResponse =
51
+ | { ok: true }
52
+ | { ok: false; error: string };
53
+
54
+ /**
55
+ * Handle `x-dispatch/permissions.set.request`. Sets the per-agent overlay
56
+ * registry entry that the prompt iterator consults when answering
57
+ * `permission_request` session updates.
58
+ *
59
+ * Idempotent: re-setting on the same agent overwrites the prior overlay.
60
+ * Safe: returns an error response (rather than throwing) for malformed
61
+ * params or unknown agents — the orchestrator should log and proceed.
62
+ */
63
+ export function handlePermissionsSet(
64
+ params: PermissionsSetRequest,
65
+ log: (msg: string) => void = console.log,
66
+ ): PermissionsResponse {
67
+ if (!params || typeof params.agent_id !== "string" || params.agent_id === "") {
68
+ return { ok: false, error: "x-dispatch/permissions.set: missing or invalid 'agent_id'" };
69
+ }
70
+ if (params.deny !== undefined && !Array.isArray(params.deny)) {
71
+ return { ok: false, error: "x-dispatch/permissions.set: 'deny' must be an array of strings" };
72
+ }
73
+ if (params.allow !== undefined && !Array.isArray(params.allow)) {
74
+ return { ok: false, error: "x-dispatch/permissions.set: 'allow' must be an array of strings" };
75
+ }
76
+
77
+ const overlay = {
78
+ ...(params.deny ? { deny: params.deny } : {}),
79
+ ...(params.allow ? { allow: params.allow } : {}),
80
+ };
81
+ setPermissionOverlay(params.agent_id, overlay);
82
+ log(
83
+ `[x-dispatch/permissions.set] agent=${params.agent_id} ` +
84
+ `deny=${params.deny?.length ?? 0} allow=${params.allow?.length ?? 0}`,
85
+ );
86
+ return { ok: true };
87
+ }
88
+
89
+ /**
90
+ * Handle `x-dispatch/permissions.clear.request`. Removes any overlay set
91
+ * on the named agent. Idempotent: clearing a non-existent overlay is a
92
+ * no-op success. Always paired with a prior `set` from the same dispatch;
93
+ * the orchestrator must guarantee `clear` runs even on dispatch failure.
94
+ */
95
+ export function handlePermissionsClear(
96
+ params: PermissionsClearRequest,
97
+ log: (msg: string) => void = console.log,
98
+ ): PermissionsResponse {
99
+ if (!params || typeof params.agent_id !== "string" || params.agent_id === "") {
100
+ return { ok: false, error: "x-dispatch/permissions.clear: missing or invalid 'agent_id'" };
101
+ }
102
+ clearPermissionOverlay(params.agent_id);
103
+ log(`[x-dispatch/permissions.clear] agent=${params.agent_id}`);
104
+ return { ok: true };
105
+ }
106
+
107
+ export const X_DISPATCH_PERMISSIONS_METHODS = {
108
+ SET_REQUEST: "x-dispatch/permissions.set.request",
109
+ SET_RESPONSE: "x-dispatch/permissions.set.response",
110
+ CLEAR_REQUEST: "x-dispatch/permissions.clear.request",
111
+ CLEAR_RESPONSE: "x-dispatch/permissions.clear.response",
112
+ } as const;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `x-dispatch/spawn-agent` MAP request handler — runtime-agnostic wire shape,
3
+ * macro-agent-specific implementation.
4
+ *
5
+ * Called by OpenHive's orchestrator when ACP-routing a dispatch with
6
+ * `lifecycle: 'fresh'` — the hub asks the swarm to spawn a fresh agent
7
+ * with the materialized loadout's structured fields applied to its spawn
8
+ * config. The handler returns the spawned agent's id; the orchestrator
9
+ * then attaches an ACP stream via the existing `findAcpAgentInfo` path.
10
+ *
11
+ * Wire shape — canonical (swarm-dispatch's `RemoteSpawnRequest`):
12
+ *
13
+ * request:
14
+ * {
15
+ * role: string,
16
+ * capabilities_required: string[], // e.g. ["acp"]
17
+ * lifecycle: "fresh" | "reuse",
18
+ * cwd?: string,
19
+ * fullAutonomous?: boolean,
20
+ * consumer_extensions?: {
21
+ * openhive?: { loadout?: WireLoadout }, // OpenHive payload
22
+ * }
23
+ * }
24
+ *
25
+ * response:
26
+ * { agentId: string }
27
+ *
28
+ * Legacy shape support: pre-Tier-2 callers passed `loadout` at the top
29
+ * level (not under consumer_extensions). The handler accepts both for
30
+ * one release window — `consumer_extensions.openhive.loadout` is read
31
+ * first; falls back to top-level `loadout`.
32
+ *
33
+ * Symmetric with the mail path: both routes use `loadoutToSpawnOptions`
34
+ * to translate the wire-level loadout into macro-agent's spawn options.
35
+ */
36
+
37
+ import type { AgentManager } from "../agent/agent-manager.js";
38
+ import { loadoutToSpawnOptions, type WireLoadout } from "./loadout-translation.js";
39
+
40
+ export interface SpawnAgentRequest {
41
+ role: string;
42
+ /** Optional cwd; defaults to the swarm's process.cwd() via agentManager. */
43
+ cwd?: string;
44
+ capabilities_required?: string[];
45
+ lifecycle?: "fresh" | "reuse";
46
+ /**
47
+ * @deprecated — pre-Tier-2 top-level loadout slot. Newer callers ship
48
+ * the loadout under `consumer_extensions.openhive.loadout`. Both
49
+ * shapes are accepted for one release window.
50
+ */
51
+ loadout?: WireLoadout;
52
+ /** Canonical consumer-extension namespace (Tier 2+). */
53
+ consumer_extensions?: {
54
+ openhive?: { loadout?: WireLoadout };
55
+ [otherConsumer: string]: unknown;
56
+ };
57
+ fullAutonomous?: boolean;
58
+ /** Optional initial task description; defaults to a placeholder so the
59
+ * agent's session has *something* to render until the orchestrator's
60
+ * ACP `session/prompt` arrives. */
61
+ task?: string;
62
+ }
63
+
64
+ export interface SpawnAgentResponse {
65
+ agentId: string;
66
+ }
67
+
68
+ export interface SpawnAgentHandlerDeps {
69
+ agentManager: AgentManager;
70
+ /**
71
+ * Optional barrier called after spawn so the spawned agent's
72
+ * lifecycle-bridge has had time to register its `protocols: ['acp']`
73
+ * capability with the hub before this handler returns. Without this
74
+ * the orchestrator's subsequent `findAcpAgentInfo` would race the
75
+ * registration and return null.
76
+ *
77
+ * Returns true once the agent is registered and ACP-capable; false on
78
+ * timeout. Implementations typically wait on a Promise resolved by
79
+ * the lifecycle-bridge's `agent.registered` callback.
80
+ */
81
+ waitForAcpRegistration?: (
82
+ agentId: string,
83
+ timeoutMs?: number,
84
+ ) => Promise<boolean>;
85
+ /** Optional logger; defaults to console.log. */
86
+ log?: (msg: string) => void;
87
+ }
88
+
89
+ const DEFAULT_REGISTRATION_TIMEOUT_MS = 5_000;
90
+
91
+ export async function handleDispatchSpawnAgent(
92
+ params: SpawnAgentRequest,
93
+ deps: SpawnAgentHandlerDeps,
94
+ ): Promise<SpawnAgentResponse> {
95
+ const { agentManager, waitForAcpRegistration, log = console.log } = deps;
96
+
97
+ if (!params.role) {
98
+ throw new Error("x-dispatch/spawn-agent: missing 'role'");
99
+ }
100
+ if (params.lifecycle && params.lifecycle !== "fresh") {
101
+ throw new Error(
102
+ `x-dispatch/spawn-agent: lifecycle='${params.lifecycle}' not supported by this handler — ` +
103
+ `'reuse' is handled hub-side via findAcpAgentInfo, not via this method`,
104
+ );
105
+ }
106
+ // cwd is optional — agentManager.spawn defaults to its own defaultCwd
107
+ // (typically process.cwd() of the macro-agent process) when omitted.
108
+
109
+ // Loadout location: prefer the canonical consumer_extensions.openhive
110
+ // slot (Tier 2+); fall back to the top-level field (pre-Tier-2). Older
111
+ // hubs ship the loadout at the top level; newer hubs ship under
112
+ // consumer_extensions. Both work during the dual-listen window.
113
+ const loadout: WireLoadout | undefined =
114
+ params.consumer_extensions?.openhive?.loadout ?? params.loadout;
115
+
116
+ const fullAutonomous = params.fullAutonomous ?? true;
117
+ const spawnLoadoutOpts = loadoutToSpawnOptions(loadout, {
118
+ fullAutonomous,
119
+ });
120
+
121
+ log(
122
+ `[x-dispatch/spawn-agent] Spawning fresh ${params.role} cwd=${params.cwd} ` +
123
+ `permissions=${
124
+ spawnLoadoutOpts.permissions ? JSON.stringify(spawnLoadoutOpts.permissions) : "(none)"
125
+ } fullAutonomous=${fullAutonomous}`,
126
+ );
127
+
128
+ const spawned = await agentManager.spawn({
129
+ role: params.role,
130
+ ...(params.cwd ? { cwd: params.cwd } : {}),
131
+ parent: null,
132
+ // Mail-inbound and dispatch-spawned coordinators alike run under
133
+ // isolated settings so host-level Claude plugins don't auto-mount.
134
+ isolatedSettings: true,
135
+ task:
136
+ params.task ?? "Awaiting dispatch (created by dispatch/spawn-agent)",
137
+ ...spawnLoadoutOpts,
138
+ });
139
+
140
+ // Block until the lifecycle-bridge has registered ACP capabilities,
141
+ // otherwise the orchestrator's subsequent `findAcpAgentInfo` lookup
142
+ // races the registration and fails. Best-effort: a timeout returns
143
+ // anyway and the orchestrator will retry on its own poll.
144
+ if (waitForAcpRegistration) {
145
+ const ok = await waitForAcpRegistration(
146
+ spawned.id,
147
+ DEFAULT_REGISTRATION_TIMEOUT_MS,
148
+ ).catch(() => false);
149
+ if (!ok) {
150
+ log(
151
+ `[x-dispatch/spawn-agent] Warning: ACP registration not confirmed for ` +
152
+ `agent ${spawned.id} within ${DEFAULT_REGISTRATION_TIMEOUT_MS}ms; ` +
153
+ `orchestrator may need to retry.`,
154
+ );
155
+ }
156
+ }
157
+
158
+ log(`[x-dispatch/spawn-agent] Spawn complete agentId=${spawned.id}`);
159
+ return { agentId: spawned.id };
160
+ }
@@ -16,6 +16,8 @@
16
16
  import type { InboxAdapter } from "../adapters/types.js";
17
17
  import type { TasksAdapter } from "../adapters/types.js";
18
18
  import type { AgentManager } from "../agent/agent-manager.js";
19
+ import type { AgentStore } from "../agent/agent-store.js";
20
+ import { getPermissionOverlay } from "../dispatch/permission-overlay.js";
19
21
  import type {
20
22
  LifecycleContext,
21
23
  DoneArgs,
@@ -47,6 +49,12 @@ export interface HandlerDepsV2 {
47
49
  * Without it, commits use raw git (legacy / null-workspace path).
48
50
  */
49
51
  workspaceManager?: WorkspaceManager;
52
+ /**
53
+ * Optional agent store. When provided, the done() summary is persisted in
54
+ * agent metadata for parentless agents (mail-inbound dispatch workers) so
55
+ * the dispatch reply bridge can forward it as a hub mail turn.
56
+ */
57
+ agentStore?: AgentStore;
50
58
  }
51
59
 
52
60
  // =============================================================================
@@ -210,6 +218,32 @@ async function handleWorkerDone(
210
218
  warnings.push("Failed to emit WORKER_DONE");
211
219
  }
212
220
 
221
+ // Step 3b: Persist `_lastSummary` to agent metadata when:
222
+ // - Agent is parentless (mail-inbound fresh-spawn dispatch workers —
223
+ // emitSignal is a no-op for parentless, so metadata is the only
224
+ // reply-path channel), OR
225
+ // - Agent is processing a dispatch (Phase 1 permission overlay set
226
+ // for this agent → in-flight). Gives the mail-inbound-reuse-consumer
227
+ // a metadata-side fallback for the reply summary that's
228
+ // independent of whether the prompt iterator's update stream
229
+ // races with ACP connection close. Applies to both parentless
230
+ // AND parented in-flight agents (parented dispatch targets are
231
+ // the typical mail+reuse setup).
232
+ const inDispatch = !!getPermissionOverlay(context.agentId);
233
+ if ((inDispatch || !context.parentId) && args.summary && deps.agentStore) {
234
+ try {
235
+ const existing = deps.agentStore.getAgent(context.agentId);
236
+ deps.agentStore.updateAgent(context.agentId, {
237
+ metadata: {
238
+ ...(existing?.metadata ?? {}),
239
+ _lastSummary: args.summary,
240
+ },
241
+ });
242
+ } catch {
243
+ // best effort — don't block termination
244
+ }
245
+ }
246
+
213
247
  // NOTE: Task transition is NOT done here — AgentManagerV2.terminate() handles
214
248
  // it to avoid double-transitioning.
215
249
 
@@ -363,4 +363,68 @@ describe("LifecycleBridge", () => {
363
363
  expect.objectContaining({ name: "agent-99" }),
364
364
  );
365
365
  });
366
+
367
+ // ── awaitRegistration() ─────────────────────────────────────────────
368
+
369
+ describe("awaitRegistration", () => {
370
+ it("returns true once map/agents/register completes (mapId populated)", async () => {
371
+ // Hub returns a MAP-assigned ULID for the registered agent.
372
+ conn.callExtension.mockResolvedValueOnce({ agent: { id: "map-ulid-A" } });
373
+
374
+ const { callback, awaitRegistration } = createLifecycleBridge(
375
+ conn,
376
+ {} as AgentStore,
377
+ scope,
378
+ );
379
+
380
+ callback({
381
+ type: "spawned",
382
+ agent: mockAgent({ id: "agent-A", role: "coordinator" }),
383
+ });
384
+
385
+ // The async register IIFE inside the bridge does waitForLocalMapId(~500ms)
386
+ // then resolves callExtension. Wait long enough for that to settle.
387
+ const ok = await awaitRegistration("agent-A", 2_000);
388
+ expect(ok).toBe(true);
389
+ });
390
+
391
+ it("returns false if timeout elapses before registration completes", async () => {
392
+ // Stall the registration call so mapId is never populated within the window.
393
+ let resolveExt: (value: unknown) => void = () => {};
394
+ conn.callExtension.mockImplementationOnce(
395
+ () =>
396
+ new Promise((resolve) => {
397
+ resolveExt = resolve;
398
+ }),
399
+ );
400
+
401
+ const { callback, awaitRegistration } = createLifecycleBridge(
402
+ conn,
403
+ {} as AgentStore,
404
+ scope,
405
+ );
406
+
407
+ callback({
408
+ type: "spawned",
409
+ agent: mockAgent({ id: "agent-B", role: "coordinator" }),
410
+ });
411
+
412
+ const ok = await awaitRegistration("agent-B", 200);
413
+ expect(ok).toBe(false);
414
+
415
+ // Cleanup the dangling promise so vitest doesn't warn about leaks.
416
+ resolveExt({ agent: { id: "map-ulid-late" } });
417
+ });
418
+
419
+ it("returns false for an agentId that was never spawned", async () => {
420
+ const { awaitRegistration } = createLifecycleBridge(
421
+ conn,
422
+ {} as AgentStore,
423
+ scope,
424
+ );
425
+
426
+ const ok = await awaitRegistration("never-spawned-agent", 150);
427
+ expect(ok).toBe(false);
428
+ });
429
+ });
366
430
  });
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Unit tests for mail-bridge — verifies that hub `mail/turn.received`
3
+ * notifications are forwarded into the local inbox with the correct shape
4
+ * so createAgentInboxPort.onIncoming can classify them.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, vi } from "vitest";
8
+ import { setupMailBridge, type MailBridgeConnection } from "../mail-bridge.js";
9
+
10
+ // Minimal InboxAdapter mock that captures send() calls.
11
+ function mockInboxAdapter() {
12
+ return {
13
+ registerAgent: vi.fn().mockResolvedValue(undefined),
14
+ send: vi.fn().mockResolvedValue("msg-001"),
15
+ };
16
+ }
17
+
18
+ // Minimal MAP connection mock.
19
+ function mockConnection(): MailBridgeConnection & {
20
+ onNotification: ReturnType<typeof vi.fn>;
21
+ offNotification: ReturnType<typeof vi.fn>;
22
+ _fire: (params: unknown) => Promise<void>;
23
+ } {
24
+ let registeredHandler: ((params: unknown) => void | Promise<void>) | null =
25
+ null;
26
+
27
+ const conn = {
28
+ onNotification: vi.fn((method: string, handler: (params: unknown) => void | Promise<void>) => {
29
+ if (method === "mail/turn.received") {
30
+ registeredHandler = handler;
31
+ }
32
+ }),
33
+ offNotification: vi.fn(),
34
+ _fire: async (params: unknown) => {
35
+ if (registeredHandler) await registeredHandler(params);
36
+ },
37
+ };
38
+ return conn;
39
+ }
40
+
41
+ describe("setupMailBridge", () => {
42
+ let conn: ReturnType<typeof mockConnection>;
43
+ let inbox: ReturnType<typeof mockInboxAdapter>;
44
+
45
+ beforeEach(() => {
46
+ conn = mockConnection();
47
+ inbox = mockInboxAdapter();
48
+ });
49
+
50
+ it("registers notification handler on 'mail/turn.received'", async () => {
51
+ await setupMailBridge({ connection: conn, inboxAdapter: inbox as any });
52
+ expect(conn.onNotification).toHaveBeenCalledWith(
53
+ "mail/turn.received",
54
+ expect.any(Function),
55
+ );
56
+ });
57
+
58
+ it("returns cleanup that calls offNotification", async () => {
59
+ const cleanup = await setupMailBridge({
60
+ connection: conn,
61
+ inboxAdapter: inbox as any,
62
+ });
63
+ cleanup();
64
+ expect(conn.offNotification).toHaveBeenCalledWith(
65
+ "mail/turn.received",
66
+ expect.any(Function),
67
+ );
68
+ });
69
+
70
+ it("drops non-JSON turns silently", async () => {
71
+ const logs: string[] = [];
72
+ await setupMailBridge({
73
+ connection: conn,
74
+ inboxAdapter: inbox as any,
75
+ log: (m) => logs.push(m),
76
+ });
77
+
78
+ await conn._fire({
79
+ conversation_id: "conv-1",
80
+ turn_id: "turn-1",
81
+ participant_id: "some-agent",
82
+ content_type: "text/plain",
83
+ content: "hello world",
84
+ });
85
+
86
+ expect(inbox.send).not.toHaveBeenCalled();
87
+ expect(logs.some((l) => l.includes("Dropping"))).toBe(true);
88
+ });
89
+
90
+ describe("with dispatcherAgentId", () => {
91
+ const DISPATCHER_ID = "dispatcher:host:1234:abc";
92
+
93
+ it("registers the dispatcher as inbox recipient", async () => {
94
+ await setupMailBridge({
95
+ connection: conn,
96
+ inboxAdapter: inbox as any,
97
+ dispatcherAgentId: DISPATCHER_ID,
98
+ });
99
+ expect(inbox.registerAgent).toHaveBeenCalledWith(
100
+ DISPATCHER_ID,
101
+ expect.objectContaining({ role: "dispatcher" }),
102
+ );
103
+ });
104
+
105
+ it("translates hub envelope { type, body } → { schema, data } and delivers to dispatcher", async () => {
106
+ await setupMailBridge({
107
+ connection: conn,
108
+ inboxAdapter: inbox as any,
109
+ dispatcherAgentId: DISPATCHER_ID,
110
+ });
111
+
112
+ const hubEnvelope = {
113
+ type: "x-dispatch/work",
114
+ body: { prompt: "do the thing", taskId: "task-123", role: "worker" },
115
+ };
116
+
117
+ await conn._fire({
118
+ conversation_id: "conv-1",
119
+ turn_id: "turn-1",
120
+ participant_id: "openhive:dispatcher",
121
+ content_type: "application/json",
122
+ content: JSON.stringify(hubEnvelope),
123
+ thread_id: "thread-abc",
124
+ });
125
+
126
+ expect(inbox.send).toHaveBeenCalledOnce();
127
+ const [from, to, content, opts] = inbox.send.mock.calls[0];
128
+
129
+ // Must deliver FROM the hub participant, TO the dispatcher
130
+ expect(from).toBe("openhive:dispatcher");
131
+ expect(to).toBe(DISPATCHER_ID);
132
+
133
+ // Content must carry top-level `schema` and `data`, plus `type: "data"`
134
+ // so agent-inbox's `normalizeContent` passes it through unchanged
135
+ // (without a `type`, the inbox wraps the payload as { type: "data",
136
+ // data: <original> }, burying `schema` and breaking downstream
137
+ // classifiers like swarm-dispatch's createAgentInboxPort).
138
+ expect(content).toMatchObject({
139
+ type: "data",
140
+ schema: "x-dispatch/work",
141
+ data: { prompt: "do the thing", taskId: "task-123", role: "worker" },
142
+ });
143
+ expect(content).not.toHaveProperty("body");
144
+
145
+ // Thread tag preserved
146
+ expect(opts?.threadTag).toBe("thread-abc");
147
+ });
148
+
149
+ it("passes through already-canonical { schema, data } payloads unchanged", async () => {
150
+ await setupMailBridge({
151
+ connection: conn,
152
+ inboxAdapter: inbox as any,
153
+ dispatcherAgentId: DISPATCHER_ID,
154
+ });
155
+
156
+ // Payload already in canonical shape (no 'body' key)
157
+ const canonical = {
158
+ schema: "x-dispatch/work",
159
+ data: { taskId: "t-99", prompt: "go", role: "worker" },
160
+ };
161
+
162
+ await conn._fire({
163
+ conversation_id: "conv-2",
164
+ turn_id: "turn-2",
165
+ participant_id: "openhive:dispatcher",
166
+ content_type: "application/json",
167
+ content: JSON.stringify(canonical),
168
+ });
169
+
170
+ expect(inbox.send).toHaveBeenCalledOnce();
171
+ const [, , content] = inbox.send.mock.calls[0];
172
+ expect(content).toMatchObject(canonical);
173
+ });
174
+ });
175
+
176
+ describe("without dispatcherAgentId (fallback mode)", () => {
177
+ it("delivers to BRIDGE_RECIPIENT_ID", async () => {
178
+ await setupMailBridge({
179
+ connection: conn,
180
+ inboxAdapter: inbox as any,
181
+ });
182
+
183
+ await conn._fire({
184
+ conversation_id: "conv-3",
185
+ turn_id: "turn-3",
186
+ participant_id: "openhive:dispatcher",
187
+ content_type: "application/json",
188
+ content: JSON.stringify({ schema: "x-dispatch/work", data: { taskId: "t-1" } }),
189
+ });
190
+
191
+ expect(inbox.send).toHaveBeenCalledOnce();
192
+ const [, to] = inbox.send.mock.calls[0];
193
+ expect(to).toBe("openhive-mail-bridge");
194
+ });
195
+ });
196
+ });