macro-agent 0.2.0 → 0.2.2

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 (40) hide show
  1. package/dist/acp/macro-agent.d.ts.map +1 -1
  2. package/dist/acp/macro-agent.js +18 -40
  3. package/dist/acp/macro-agent.js.map +1 -1
  4. package/dist/agent/agent-manager-v2.d.ts.map +1 -1
  5. package/dist/agent/agent-manager-v2.js +1 -1
  6. package/dist/agent/agent-manager-v2.js.map +1 -1
  7. package/dist/boot-v2.d.ts.map +1 -1
  8. package/dist/boot-v2.js +2 -0
  9. package/dist/boot-v2.js.map +1 -1
  10. package/dist/dispatch/mail-inbound-consumer.d.ts +47 -0
  11. package/dist/dispatch/mail-inbound-consumer.d.ts.map +1 -1
  12. package/dist/dispatch/mail-inbound-consumer.js +117 -18
  13. package/dist/dispatch/mail-inbound-consumer.js.map +1 -1
  14. package/dist/map/mail-bridge.d.ts.map +1 -1
  15. package/dist/map/mail-bridge.js +8 -1
  16. package/dist/map/mail-bridge.js.map +1 -1
  17. package/dist/map/repo-workspace.d.ts +46 -0
  18. package/dist/map/repo-workspace.d.ts.map +1 -0
  19. package/dist/map/repo-workspace.js +39 -0
  20. package/dist/map/repo-workspace.js.map +1 -0
  21. package/dist/map/server.d.ts.map +1 -1
  22. package/dist/map/server.js +1 -0
  23. package/dist/map/server.js.map +1 -1
  24. package/dist/map/sidecar.d.ts.map +1 -1
  25. package/dist/map/sidecar.js +63 -0
  26. package/dist/map/sidecar.js.map +1 -1
  27. package/dist/map/types.d.ts +14 -0
  28. package/dist/map/types.d.ts.map +1 -1
  29. package/package.json +3 -2
  30. package/src/acp/macro-agent.ts +20 -42
  31. package/src/agent/agent-manager-v2.ts +1 -0
  32. package/src/boot-v2.ts +2 -0
  33. package/src/dispatch/__tests__/mail-inbound-consumer.test.ts +211 -0
  34. package/src/dispatch/mail-inbound-consumer.ts +195 -32
  35. package/src/map/__tests__/mail-bridge.test.ts +88 -0
  36. package/src/map/mail-bridge.ts +13 -1
  37. package/src/map/repo-workspace.ts +82 -0
  38. package/src/map/server.ts +1 -0
  39. package/src/map/sidecar.ts +85 -0
  40. package/src/map/types.ts +13 -0
@@ -61,6 +61,38 @@ export interface MailInboundSidecar {
61
61
  ): Promise<void>;
62
62
  }
63
63
 
64
+ /** Repo metadata surfaced by the hub's enrichWithRepo → mail port injection. */
65
+ export interface DispatchRepoMetadata {
66
+ repo_id?: string;
67
+ canonical_url?: string;
68
+ branch?: string;
69
+ commit_sha?: string;
70
+ clone_policy?: string;
71
+ clone_path?: string;
72
+ }
73
+
74
+ /**
75
+ * Narrow interface for the sidecar's RepoManager — keeps the consumer
76
+ * testable without importing the full agent-workspace concrete type.
77
+ */
78
+ export interface RepoManagerLike {
79
+ list(): Array<{ identity: { canonicalUrl: string }; localPath: string }>;
80
+ attach(config: {
81
+ remoteUrl: string;
82
+ localPath: string;
83
+ currentBranch?: string;
84
+ }): Promise<{ localPath: string }>;
85
+ }
86
+
87
+ /**
88
+ * Narrow interface for the sidecar's RepoClient transport — used to
89
+ * declare newly-attached repos to the hub after clone.
90
+ */
91
+ export interface RepoClientTransportLike {
92
+ notify(method: string, params: unknown): Promise<void>;
93
+ request(method: string, params: unknown): Promise<unknown>;
94
+ }
95
+
64
96
  export interface MailInboundConsumerOptions {
65
97
  /**
66
98
  * The inbox agent ID that mail-bridge delivers envelopes to.
@@ -85,6 +117,19 @@ export interface MailInboundConsumerOptions {
85
117
  */
86
118
  getSidecar: () => MailInboundSidecar | null | undefined;
87
119
 
120
+ /**
121
+ * Optional repo manager for pre-spawn mount. When provided, the consumer
122
+ * can clone/attach repos before spawning workers and set the worker's cwd
123
+ * to the repo path. Populated lazily from the sidecar's workspace manager.
124
+ */
125
+ getRepoManager?: () => RepoManagerLike | null | undefined;
126
+
127
+ /**
128
+ * Optional repo client transport for declaring newly-cloned repos to the
129
+ * hub after a pre-spawn clone. Uses the sidecar's MAP connection transport.
130
+ */
131
+ getRepoTransport?: () => RepoClientTransportLike | null | undefined;
132
+
88
133
  /** Optional logger (default: console.log). */
89
134
  log?: (msg: string) => void;
90
135
  }
@@ -121,6 +166,8 @@ export function createMailInboundConsumer(
121
166
  agentManager,
122
167
  agentStore,
123
168
  getSidecar,
169
+ getRepoManager,
170
+ getRepoTransport,
124
171
  log = (msg: string) => console.log(msg),
125
172
  } = opts;
126
173
 
@@ -158,6 +205,102 @@ export function createMailInboundConsumer(
158
205
  `(recipient=${dispatcherAgentId})`,
159
206
  );
160
207
 
208
+ // ── Pre-spawn repo mount ─────────────────────────────────────
209
+ // Resolves the worker's cwd from the dispatch envelope's repo metadata.
210
+ // When clone_policy is 'allowed' and the repo isn't already attached,
211
+ // clones to clone_path (or a default under cwd) then attaches+declares.
212
+ // Best-effort: failures log a warning and return undefined (worker
213
+ // spawns without a repo-specific cwd).
214
+ async function resolveRepoCwd(
215
+ repoMeta: DispatchRepoMetadata,
216
+ taskId: string,
217
+ ): Promise<string | undefined> {
218
+ const manager = getRepoManager?.();
219
+ if (!manager) return undefined;
220
+
221
+ const canonicalUrl = repoMeta.canonical_url;
222
+ if (!canonicalUrl) return undefined;
223
+
224
+ // Check if the repo is already attached (by canonical URL match).
225
+ const existing = manager.list().find(
226
+ (h) => h.identity.canonicalUrl === canonicalUrl,
227
+ );
228
+ if (existing) {
229
+ log(`[mail-inbound] Repo already attached at ${existing.localPath} for taskId=${taskId}`);
230
+ return existing.localPath;
231
+ }
232
+
233
+ // Not attached — clone only if explicitly allowed.
234
+ if (repoMeta.clone_policy !== 'allowed') {
235
+ log(
236
+ `[mail-inbound] Repo ${canonicalUrl} not attached and clone_policy=${repoMeta.clone_policy ?? 'none'} — ` +
237
+ `skipping mount for taskId=${taskId}`,
238
+ );
239
+ return undefined;
240
+ }
241
+
242
+ const clonePath = repoMeta.clone_path ?? `/tmp/openhive-repos/${repoMeta.repo_id}`;
243
+ try {
244
+ const { execSync } = await import("node:child_process");
245
+
246
+ // Clone if the directory doesn't exist yet.
247
+ const fs = await import("node:fs");
248
+ if (!fs.existsSync(clonePath)) {
249
+ log(`[mail-inbound] Cloning ${canonicalUrl} → ${clonePath} for taskId=${taskId}`);
250
+ execSync(`git clone --depth 1 ${canonicalUrl} ${clonePath}`, {
251
+ stdio: "pipe",
252
+ timeout: 120_000,
253
+ });
254
+ }
255
+
256
+ // Checkout target branch if specified.
257
+ if (repoMeta.branch) {
258
+ try {
259
+ execSync(`git -C ${clonePath} fetch origin ${repoMeta.branch} --depth 1`, {
260
+ stdio: "pipe",
261
+ timeout: 60_000,
262
+ });
263
+ execSync(`git -C ${clonePath} checkout ${repoMeta.branch}`, {
264
+ stdio: "pipe",
265
+ timeout: 30_000,
266
+ });
267
+ } catch {
268
+ log(`[mail-inbound] Branch checkout failed for ${repoMeta.branch} — continuing on default branch`);
269
+ }
270
+ }
271
+
272
+ // Attach to the repo manager so future dispatches find it.
273
+ const handle = await manager.attach({
274
+ remoteUrl: canonicalUrl,
275
+ localPath: clonePath,
276
+ currentBranch: repoMeta.branch,
277
+ });
278
+
279
+ // Declare the new workspace to the hub (best-effort).
280
+ const transport = getRepoTransport?.();
281
+ if (transport) {
282
+ try {
283
+ const bindings = manager.list().map((h) => ({
284
+ canonical_url: h.identity.canonicalUrl,
285
+ local_path: h.localPath,
286
+ }));
287
+ await transport.notify("x-workspace/repo.declare", { bindings });
288
+ } catch {
289
+ // Non-fatal — the hub may not support workspace declarations.
290
+ }
291
+ }
292
+
293
+ log(`[mail-inbound] Mounted repo at ${handle.localPath} for taskId=${taskId}`);
294
+ return handle.localPath;
295
+ } catch (err) {
296
+ log(
297
+ `[mail-inbound] Pre-spawn repo mount failed for taskId=${taskId}: ` +
298
+ `${(err as Error).message ?? String(err)}`,
299
+ );
300
+ return undefined;
301
+ }
302
+ }
303
+
161
304
  // ── Inbox message listener ───────────────────────────────────
162
305
  const onMessage = (event: InboxMessageEvent): void => {
163
306
  // Only handle messages delivered to our dispatcher recipient.
@@ -258,17 +401,36 @@ export function createMailInboundConsumer(
258
401
  fullAutonomous: true,
259
402
  });
260
403
 
404
+ // Extract repo metadata from the envelope for pre-spawn mount.
405
+ const repoMeta: DispatchRepoMetadata = {
406
+ repo_id: data.metadata?.repo_id as string | undefined,
407
+ canonical_url: data.metadata?.canonical_url as string | undefined,
408
+ branch: data.metadata?.branch as string | undefined,
409
+ commit_sha: data.metadata?.commit_sha as string | undefined,
410
+ clone_policy: data.metadata?.clone_policy as string | undefined,
411
+ clone_path: data.metadata?.clone_path as string | undefined,
412
+ };
413
+
261
414
  log(
262
415
  `[mail-inbound] Received x-dispatch/work taskId=${taskId} ` +
263
416
  `conv=${conversationId ?? "(none)"} role=${role}` +
417
+ (repoMeta.repo_id ? ` repo=${repoMeta.repo_id}` : "") +
264
418
  (spawnLoadoutOpts.permissions
265
419
  ? ` permissions=${JSON.stringify(spawnLoadoutOpts.permissions)}`
266
420
  : ""),
267
421
  );
268
422
 
269
423
  // Spawn is async — fire and forget. Errors are logged, not thrown.
270
- agentManager
271
- .spawn({
424
+ // Pre-spawn mount resolves the worker's cwd from the repo metadata
425
+ // before spawning. Best-effort: mount failures proceed without a
426
+ // repo-specific cwd.
427
+ (async () => {
428
+ let repoCwd: string | undefined;
429
+ if (repoMeta.repo_id) {
430
+ repoCwd = await resolveRepoCwd(repoMeta, taskId);
431
+ }
432
+
433
+ const spawned = await agentManager.spawn({
272
434
  task: prompt,
273
435
  task_id: taskId,
274
436
  role,
@@ -278,40 +440,41 @@ export function createMailInboundConsumer(
278
440
  // (claude-code-swarm, oh-my-claudecode, …) don't auto-load and hang
279
441
  // session/new on environments where the host services aren't reachable.
280
442
  isolatedSettings: true,
443
+ ...(repoCwd ? { cwd: repoCwd } : {}),
281
444
  ...spawnLoadoutOpts,
282
- })
283
- .then(async (spawned) => {
284
- log(
285
- `[mail-inbound] Spawned worker agentId=${spawned.id} for taskId=${taskId}`,
286
- );
287
- if (conversationId) {
288
- agentConversationMap.set(spawned.id, conversationId);
289
- }
445
+ });
290
446
 
291
- // Spawn only creates an idle ACP session — the task lives in the
292
- // system prompt as instructions. To get the model to actually do
293
- // the work, send the prompt as a user message via promptUntilDone.
294
- // This drives the worker to completion (done() called) so the
295
- // lifecycle stopped listener below fires and posts the reply
296
- // back to the hub. Fire-and-forget; errors are logged.
297
- try {
298
- await agentManager.promptUntilDone(spawned.id, prompt, {
299
- maxFollowUps: 0,
300
- });
301
- } catch (err) {
302
- log(
303
- `[mail-inbound] promptUntilDone failed for agentId=${spawned.id}: ` +
304
- `${(err as Error).message ?? String(err)}`,
305
- );
306
- }
307
- })
308
- .catch((err: unknown) => {
447
+ log(
448
+ `[mail-inbound] Spawned worker agentId=${spawned.id} for taskId=${taskId}` +
449
+ (repoCwd ? ` cwd=${repoCwd}` : ""),
450
+ );
451
+ if (conversationId) {
452
+ agentConversationMap.set(spawned.id, conversationId);
453
+ }
454
+
455
+ // Spawn only creates an idle ACP session — the task lives in the
456
+ // system prompt as instructions. To get the model to actually do
457
+ // the work, send the prompt as a user message via promptUntilDone.
458
+ // This drives the worker to completion (done() called) so the
459
+ // lifecycle stopped listener below fires and posts the reply
460
+ // back to the hub. Fire-and-forget; errors are logged.
461
+ try {
462
+ await agentManager.promptUntilDone(spawned.id, prompt, {
463
+ maxFollowUps: 0,
464
+ });
465
+ } catch (err) {
309
466
  log(
310
- `[mail-inbound] Spawn failed for taskId=${taskId}: ${
311
- (err as Error).message ?? String(err)
312
- }`,
467
+ `[mail-inbound] promptUntilDone failed for agentId=${spawned.id}: ` +
468
+ `${(err as Error).message ?? String(err)}`,
313
469
  );
314
- });
470
+ }
471
+ })().catch((err: unknown) => {
472
+ log(
473
+ `[mail-inbound] Spawn failed for taskId=${taskId}: ${
474
+ (err as Error).message ?? String(err)
475
+ }`,
476
+ );
477
+ });
315
478
  };
316
479
 
317
480
  inboxEvents.on("inbox.message", onMessage);
@@ -173,6 +173,94 @@ describe("setupMailBridge", () => {
173
173
  });
174
174
  });
175
175
 
176
+ describe("importance derivation", () => {
177
+ const DISPATCHER_ID = "dispatcher:host:1234:abc";
178
+
179
+ it("passes through importance from hub notification params", async () => {
180
+ await setupMailBridge({
181
+ connection: conn,
182
+ inboxAdapter: inbox as any,
183
+ dispatcherAgentId: DISPATCHER_ID,
184
+ });
185
+
186
+ await conn._fire({
187
+ conversation_id: "conv-imp-1",
188
+ turn_id: "turn-imp-1",
189
+ participant_id: "user:admin",
190
+ content_type: "application/json",
191
+ content: JSON.stringify({ schema: "x-dispatch/work", data: { taskId: "t-1" } }),
192
+ importance: "high",
193
+ });
194
+
195
+ expect(inbox.send).toHaveBeenCalledOnce();
196
+ const [, , , opts] = inbox.send.mock.calls[0];
197
+ expect(opts?.importance).toBe("high");
198
+ });
199
+
200
+ it("passes through 'urgent' importance for orchestrator recall", async () => {
201
+ await setupMailBridge({
202
+ connection: conn,
203
+ inboxAdapter: inbox as any,
204
+ dispatcherAgentId: DISPATCHER_ID,
205
+ });
206
+
207
+ await conn._fire({
208
+ conversation_id: "conv-imp-2",
209
+ turn_id: "turn-imp-2",
210
+ participant_id: "system:dispatch-orchestrator",
211
+ content_type: "application/json",
212
+ content: JSON.stringify({ schema: "x-dispatch/work", data: { taskId: "t-2" } }),
213
+ importance: "urgent",
214
+ });
215
+
216
+ expect(inbox.send).toHaveBeenCalledOnce();
217
+ const [, , , opts] = inbox.send.mock.calls[0];
218
+ expect(opts?.importance).toBe("urgent");
219
+ });
220
+
221
+ it("defaults to 'normal' when importance is missing", async () => {
222
+ await setupMailBridge({
223
+ connection: conn,
224
+ inboxAdapter: inbox as any,
225
+ dispatcherAgentId: DISPATCHER_ID,
226
+ });
227
+
228
+ await conn._fire({
229
+ conversation_id: "conv-imp-3",
230
+ turn_id: "turn-imp-3",
231
+ participant_id: "user:admin",
232
+ content_type: "application/json",
233
+ content: JSON.stringify({ schema: "x-dispatch/work", data: { taskId: "t-3" } }),
234
+ // no importance field
235
+ });
236
+
237
+ expect(inbox.send).toHaveBeenCalledOnce();
238
+ const [, , , opts] = inbox.send.mock.calls[0];
239
+ expect(opts?.importance).toBe("normal");
240
+ });
241
+
242
+ it("ignores invalid importance values and falls back to 'normal'", async () => {
243
+ await setupMailBridge({
244
+ connection: conn,
245
+ inboxAdapter: inbox as any,
246
+ dispatcherAgentId: DISPATCHER_ID,
247
+ });
248
+
249
+ await conn._fire({
250
+ conversation_id: "conv-imp-4",
251
+ turn_id: "turn-imp-4",
252
+ participant_id: "user:admin",
253
+ content_type: "application/json",
254
+ content: JSON.stringify({ schema: "x-dispatch/work", data: { taskId: "t-4" } }),
255
+ importance: "critical", // invalid value
256
+ });
257
+
258
+ expect(inbox.send).toHaveBeenCalledOnce();
259
+ const [, , , opts] = inbox.send.mock.calls[0];
260
+ expect(opts?.importance).toBe("normal");
261
+ });
262
+ });
263
+
176
264
  describe("without dispatcherAgentId (fallback mode)", () => {
177
265
  it("delivers to BRIDGE_RECIPIENT_ID", async () => {
178
266
  await setupMailBridge({
@@ -52,6 +52,9 @@ interface MailTurnReceivedParams {
52
52
  content?: unknown;
53
53
  thread_id?: string;
54
54
  created_at?: string;
55
+ /** Importance hint from the hub. When present, drives wake/interrupt
56
+ * decisions via TriggerSystemV2's mapImportanceToWakeAction. */
57
+ importance?: string;
55
58
  }
56
59
 
57
60
  /**
@@ -169,6 +172,15 @@ export async function setupMailBridge(
169
172
  ...(turn.conversation_id ? { _conversationId: turn.conversation_id } : {}),
170
173
  };
171
174
 
175
+ // Derive importance from the hub's wire params. Default to "normal"
176
+ // when the hub doesn't tag the turn (backward compat).
177
+ const VALID_IMPORTANCE = ["low", "normal", "high", "urgent"];
178
+ const wireImportance =
179
+ typeof turn.importance === "string" &&
180
+ VALID_IMPORTANCE.includes(turn.importance)
181
+ ? (turn.importance as "low" | "normal" | "high" | "urgent")
182
+ : "normal";
183
+
172
184
  try {
173
185
  await inboxAdapter.send(
174
186
  turn.participant_id ?? "openhive-hub",
@@ -176,7 +188,7 @@ export async function setupMailBridge(
176
188
  contentWithConvId as never,
177
189
  {
178
190
  threadTag: turn.thread_id,
179
- importance: "normal",
191
+ importance: wireImportance,
180
192
  },
181
193
  );
182
194
  log(
@@ -0,0 +1,82 @@
1
+ export const REPO_PROTOCOL_VERSION = "0.1.0";
2
+
3
+ export interface WorkspaceCapability {
4
+ protocolVersion: string;
5
+ declare: {
6
+ enabled: boolean;
7
+ defaultVisibility: "private" | "hub_local" | "federated";
8
+ };
9
+ list: {
10
+ enabled: boolean;
11
+ };
12
+ }
13
+
14
+ export interface RepoClientTransport {
15
+ notify(method: string, params: unknown): Promise<void>;
16
+ request(method: string, params: unknown): Promise<unknown>;
17
+ }
18
+
19
+ export interface RepoAttachConfig {
20
+ remoteUrl: string;
21
+ localPath: string;
22
+ currentBranch?: string;
23
+ }
24
+
25
+ export interface RepoHandle {
26
+ identity: {
27
+ canonicalUrl: string;
28
+ };
29
+ localPath: string;
30
+ currentBranch?: string;
31
+ }
32
+
33
+ export interface RepoDeclaration {
34
+ bindings: Array<{
35
+ canonical_url: string;
36
+ local_path: string;
37
+ current_branch?: string;
38
+ }>;
39
+ }
40
+
41
+ export class RepoManager {
42
+ private readonly repos: RepoHandle[] = [];
43
+
44
+ async attach(config: RepoAttachConfig): Promise<RepoHandle> {
45
+ const existing = this.repos.find(
46
+ (repo) =>
47
+ repo.identity.canonicalUrl === config.remoteUrl ||
48
+ repo.localPath === config.localPath,
49
+ );
50
+ if (existing) return existing;
51
+
52
+ const handle: RepoHandle = {
53
+ identity: { canonicalUrl: config.remoteUrl },
54
+ localPath: config.localPath,
55
+ ...(config.currentBranch ? { currentBranch: config.currentBranch } : {}),
56
+ };
57
+ this.repos.push(handle);
58
+ return handle;
59
+ }
60
+
61
+ list(): RepoHandle[] {
62
+ return [...this.repos];
63
+ }
64
+ }
65
+
66
+ export class RepoClient {
67
+ constructor(private readonly transport: RepoClientTransport) {}
68
+
69
+ static snapshot(manager: RepoManager): RepoDeclaration {
70
+ return {
71
+ bindings: manager.list().map((repo) => ({
72
+ canonical_url: repo.identity.canonicalUrl,
73
+ local_path: repo.localPath,
74
+ ...(repo.currentBranch ? { current_branch: repo.currentBranch } : {}),
75
+ })),
76
+ };
77
+ }
78
+
79
+ async declare(declaration: RepoDeclaration): Promise<void> {
80
+ await this.transport.notify("x-workspace/repo.declare", declaration);
81
+ }
82
+ }
package/src/map/server.ts CHANGED
@@ -108,6 +108,7 @@ export function createMAPServerInstance(
108
108
  cwd: params.cwd,
109
109
  role: params.role ?? "worker",
110
110
  permissionMode: params.permissionMode,
111
+ askForAllTools: params.askForAllTools,
111
112
  agentType: params.agentType,
112
113
  customPrompt: params.customPrompt,
113
114
  topics: params.topics,
@@ -23,6 +23,13 @@ import type {
23
23
  TaskBridge,
24
24
  } from "./types.js";
25
25
  import type { AgentLifecycleCallback } from "../agent/types.js";
26
+ import {
27
+ REPO_PROTOCOL_VERSION,
28
+ RepoClient,
29
+ RepoManager,
30
+ type RepoClientTransport,
31
+ type WorkspaceCapability,
32
+ } from "./repo-workspace.js";
26
33
 
27
34
  /**
28
35
  * Create a MAP sidecar that connects macro-agent to an OpenHive MAP hub.
@@ -55,8 +62,25 @@ export function createMAPSidecar(
55
62
  let dispatchSpawnHandlerCleanup: (() => void) | null = null;
56
63
  let dispatchMessageHandlerCleanup: (() => void) | null = null;
57
64
  let dispatchPermissionsHandlerCleanup: (() => void) | null = null;
65
+ let workspaceManager: RepoManager | null = null;
66
+ let workspaceTransport: RepoClientTransport | null = null;
58
67
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
59
68
 
69
+ // Resolve the workspace capability from env vars. Setting OPENHIVE_WORKSPACE_DECLARE=off
70
+ // disables both explicit declare AND trajectory-handler bootstrap on the hub side.
71
+ const workspaceCapability: WorkspaceCapability = {
72
+ protocolVersion: REPO_PROTOCOL_VERSION,
73
+ declare: {
74
+ enabled: process.env.OPENHIVE_WORKSPACE_DECLARE !== "off",
75
+ defaultVisibility:
76
+ (process.env.OPENHIVE_WORKSPACE_VISIBILITY as
77
+ | "private"
78
+ | "hub_local"
79
+ | "federated") ?? "hub_local",
80
+ },
81
+ list: { enabled: true },
82
+ };
83
+
60
84
  /**
61
85
  * Build the MAP connection URL with auth token.
62
86
  */
@@ -121,6 +145,8 @@ export function createMAPSidecar(
121
145
  }
122
146
  lifecycleCallback = null;
123
147
  taskBridge = null;
148
+ workspaceManager = null;
149
+ workspaceTransport = null;
124
150
  }
125
151
 
126
152
  /**
@@ -147,6 +173,7 @@ export function createMAPSidecar(
147
173
  canUpdate: true,
148
174
  canList: true,
149
175
  },
176
+ workspace: workspaceCapability,
150
177
  },
151
178
  metadata: {
152
179
  systemId: config.systemId ?? "macro-agent",
@@ -624,6 +651,56 @@ export function createMAPSidecar(
624
651
  actionCleanup();
625
652
  };
626
653
  }
654
+
655
+ // 6. Workspace (kinds/repo) — declare attached repos to the hub.
656
+ // Discovers repos from WORKSPACE_* env vars (set by openhive's swarm-spawn
657
+ // flow when spawning with a `repo_id`) plus OPENHIVE_WORKSPACE_REPOS for
658
+ // multi-repo declarations. Skipped entirely when capability.declare is off.
659
+ if (workspaceCapability.declare.enabled) {
660
+ try {
661
+ // OpenHive's MAP server registers x-workspace/repo.* as request handlers
662
+ // (additionalHandlers), not notification handlers — so route notify
663
+ // through callExtension and ignore the (void) response.
664
+ const repoTransport: RepoClientTransport = {
665
+ notify: async (method: string, params: unknown) => {
666
+ await connection.callExtension(method, params);
667
+ },
668
+ request: (method: string, params: unknown) =>
669
+ connection.callExtension(method, params),
670
+ };
671
+ const manager = new RepoManager();
672
+ const single =
673
+ process.env.WORKSPACE_REPO_URL && process.env.WORKSPACE_LOCAL_PATH
674
+ ? [{
675
+ remoteUrl: process.env.WORKSPACE_REPO_URL,
676
+ localPath: process.env.WORKSPACE_LOCAL_PATH,
677
+ }]
678
+ : [];
679
+ const multi = process.env.OPENHIVE_WORKSPACE_REPOS
680
+ ? (JSON.parse(process.env.OPENHIVE_WORKSPACE_REPOS) as Array<{
681
+ remoteUrl: string;
682
+ localPath: string;
683
+ }>)
684
+ : [];
685
+ for (const cfg of [...single, ...multi]) {
686
+ await manager.attach(cfg);
687
+ }
688
+ if (manager.list().length > 0) {
689
+ const client = new RepoClient(repoTransport);
690
+ await client.declare(RepoClient.snapshot(manager));
691
+ workspaceManager = manager;
692
+ workspaceTransport = repoTransport;
693
+ console.log(
694
+ `[map-sidecar] Declared ${manager.list().length} workspace(s) to hub`,
695
+ );
696
+ }
697
+ } catch (err) {
698
+ // Non-fatal — sidecar continues without workspace declarations
699
+ console.warn(
700
+ `[map-sidecar] Workspace declare failed: ${(err as Error).message}`,
701
+ );
702
+ }
703
+ }
627
704
  }
628
705
 
629
706
  return {
@@ -708,5 +785,13 @@ export function createMAPSidecar(
708
785
  );
709
786
  }
710
787
  },
788
+
789
+ getWorkspaceManager() {
790
+ return workspaceManager;
791
+ },
792
+
793
+ getRepoTransport() {
794
+ return workspaceTransport;
795
+ },
711
796
  };
712
797
  }
package/src/map/types.ts CHANGED
@@ -127,6 +127,19 @@ export interface MAPSidecar {
127
127
  participantId: string,
128
128
  content: string,
129
129
  ): Promise<void>;
130
+
131
+ /**
132
+ * Access the sidecar's workspace RepoManager (if workspace declarations
133
+ * are enabled and the sidecar is connected). Used by mail-inbound-consumer
134
+ * for pre-spawn repo mount.
135
+ */
136
+ getWorkspaceManager?(): unknown;
137
+
138
+ /**
139
+ * Access the sidecar's repo client transport for declaring newly-attached
140
+ * repos to the hub. Used by mail-inbound-consumer after pre-spawn clone.
141
+ */
142
+ getRepoTransport?(): { notify(method: string, params: unknown): Promise<void>; request(method: string, params: unknown): Promise<unknown> } | null;
130
143
  }
131
144
 
132
145
  // =============================================================================