macro-agent 0.2.0 → 0.2.1
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/acp/macro-agent.d.ts.map +1 -1
- package/dist/acp/macro-agent.js +18 -40
- package/dist/acp/macro-agent.js.map +1 -1
- package/dist/agent/agent-manager-v2.d.ts.map +1 -1
- package/dist/agent/agent-manager-v2.js +1 -1
- package/dist/agent/agent-manager-v2.js.map +1 -1
- package/dist/boot-v2.d.ts.map +1 -1
- package/dist/boot-v2.js +2 -0
- package/dist/boot-v2.js.map +1 -1
- package/dist/cli/acp.js +0 -0
- package/dist/cli/index.js +0 -0
- package/dist/cli/mcp.js +0 -0
- package/dist/dispatch/mail-inbound-consumer.d.ts +47 -0
- package/dist/dispatch/mail-inbound-consumer.d.ts.map +1 -1
- package/dist/dispatch/mail-inbound-consumer.js +117 -18
- package/dist/dispatch/mail-inbound-consumer.js.map +1 -1
- package/dist/map/repo-workspace.d.ts +46 -0
- package/dist/map/repo-workspace.d.ts.map +1 -0
- package/dist/map/repo-workspace.js +39 -0
- package/dist/map/repo-workspace.js.map +1 -0
- package/dist/map/server.d.ts.map +1 -1
- package/dist/map/server.js +1 -0
- package/dist/map/server.js.map +1 -1
- package/dist/map/sidecar.d.ts.map +1 -1
- package/dist/map/sidecar.js +63 -0
- package/dist/map/sidecar.js.map +1 -1
- package/dist/map/types.d.ts +14 -0
- package/dist/map/types.d.ts.map +1 -1
- package/dist/workspace/dataplane-adapter.d.ts +260 -0
- package/dist/workspace/dataplane-adapter.d.ts.map +1 -0
- package/dist/workspace/dataplane-adapter.js +416 -0
- package/dist/workspace/dataplane-adapter.js.map +1 -0
- package/package.json +2 -1
- package/src/acp/macro-agent.ts +20 -42
- package/src/agent/agent-manager-v2.ts +1 -0
- package/src/boot-v2.ts +2 -0
- package/src/dispatch/__tests__/mail-inbound-consumer.test.ts +211 -0
- package/src/dispatch/mail-inbound-consumer.ts +195 -32
- package/src/map/repo-workspace.ts +82 -0
- package/src/map/server.ts +1 -0
- package/src/map/sidecar.ts +85 -0
- package/src/map/types.ts +13 -0
|
@@ -586,4 +586,215 @@ describe("createMailInboundConsumer", () => {
|
|
|
586
586
|
expect(consumer.stats().seenTaskIds).toBe(1);
|
|
587
587
|
expect(consumer.stats().droppedMalformed).toBe(2);
|
|
588
588
|
});
|
|
589
|
+
|
|
590
|
+
// ── Pre-spawn repo mount ──────────────────────────────────────────
|
|
591
|
+
|
|
592
|
+
describe("pre-spawn repo mount", () => {
|
|
593
|
+
function workEnvelopeWithRepo(
|
|
594
|
+
taskId: string,
|
|
595
|
+
prompt: string,
|
|
596
|
+
repoMeta: Record<string, unknown>,
|
|
597
|
+
conversationId?: string,
|
|
598
|
+
): InboxMessageEvent {
|
|
599
|
+
return {
|
|
600
|
+
agentId: DISPATCHER_ID,
|
|
601
|
+
message: {
|
|
602
|
+
id: `msg-${taskId}`,
|
|
603
|
+
content: {
|
|
604
|
+
schema: "x-dispatch/work",
|
|
605
|
+
data: {
|
|
606
|
+
taskId,
|
|
607
|
+
prompt,
|
|
608
|
+
role: "worker",
|
|
609
|
+
metadata: repoMeta,
|
|
610
|
+
},
|
|
611
|
+
...(conversationId ? { _conversationId: conversationId } : {}),
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function makeRepoManager(existingRepos: Array<{ canonicalUrl: string; localPath: string }> = []) {
|
|
618
|
+
const attached: Array<{ remoteUrl: string; localPath: string }> = [];
|
|
619
|
+
return {
|
|
620
|
+
manager: {
|
|
621
|
+
list: () =>
|
|
622
|
+
[
|
|
623
|
+
...existingRepos.map((r) => ({
|
|
624
|
+
identity: { canonicalUrl: r.canonicalUrl },
|
|
625
|
+
localPath: r.localPath,
|
|
626
|
+
})),
|
|
627
|
+
...attached.map((r) => ({
|
|
628
|
+
identity: { canonicalUrl: r.remoteUrl },
|
|
629
|
+
localPath: r.localPath,
|
|
630
|
+
})),
|
|
631
|
+
],
|
|
632
|
+
attach: vi.fn(async (config: { remoteUrl: string; localPath: string }) => {
|
|
633
|
+
attached.push(config);
|
|
634
|
+
return { localPath: config.localPath };
|
|
635
|
+
}),
|
|
636
|
+
},
|
|
637
|
+
attached,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
it("passes cwd from already-attached repo to spawn", async () => {
|
|
642
|
+
const repo = makeRepoManager([
|
|
643
|
+
{ canonicalUrl: "https://github.com/org/repo.git", localPath: "/repos/repo" },
|
|
644
|
+
]);
|
|
645
|
+
const logs: string[] = [];
|
|
646
|
+
|
|
647
|
+
createMailInboundConsumer({
|
|
648
|
+
dispatcherAgentId: DISPATCHER_ID,
|
|
649
|
+
inboxEvents,
|
|
650
|
+
agentManager: am.manager as AgentManager,
|
|
651
|
+
agentStore: store as AgentStore,
|
|
652
|
+
getSidecar: () => sidecar,
|
|
653
|
+
getRepoManager: () => repo.manager,
|
|
654
|
+
log: (m) => logs.push(m),
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
inboxEvents.fire(
|
|
658
|
+
workEnvelopeWithRepo("task-repo-1", "work on repo", {
|
|
659
|
+
repo_id: "repo_abc",
|
|
660
|
+
canonical_url: "https://github.com/org/repo.git",
|
|
661
|
+
}),
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
665
|
+
|
|
666
|
+
expect(am.spawnFn).toHaveBeenCalledOnce();
|
|
667
|
+
expect(am.spawnFn).toHaveBeenCalledWith(
|
|
668
|
+
expect.objectContaining({ cwd: "/repos/repo" }),
|
|
669
|
+
);
|
|
670
|
+
expect(logs.some((l) => l.includes("already attached"))).toBe(true);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("spawns without cwd when clone_policy is not 'allowed' and repo not attached", async () => {
|
|
674
|
+
const repo = makeRepoManager([]);
|
|
675
|
+
const logs: string[] = [];
|
|
676
|
+
|
|
677
|
+
createMailInboundConsumer({
|
|
678
|
+
dispatcherAgentId: DISPATCHER_ID,
|
|
679
|
+
inboxEvents,
|
|
680
|
+
agentManager: am.manager as AgentManager,
|
|
681
|
+
agentStore: store as AgentStore,
|
|
682
|
+
getSidecar: () => sidecar,
|
|
683
|
+
getRepoManager: () => repo.manager,
|
|
684
|
+
log: (m) => logs.push(m),
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
inboxEvents.fire(
|
|
688
|
+
workEnvelopeWithRepo("task-repo-2", "work", {
|
|
689
|
+
repo_id: "repo_xyz",
|
|
690
|
+
canonical_url: "https://github.com/org/other.git",
|
|
691
|
+
}),
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
695
|
+
|
|
696
|
+
expect(am.spawnFn).toHaveBeenCalledOnce();
|
|
697
|
+
// No cwd passed — clone_policy defaults to 'none'
|
|
698
|
+
const spawnArgs = am.spawnFn.mock.calls[0]![0] as Record<string, unknown>;
|
|
699
|
+
expect(spawnArgs.cwd).toBeUndefined();
|
|
700
|
+
expect(logs.some((l) => l.includes("skipping mount"))).toBe(true);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("spawns without cwd when no repo metadata in envelope", async () => {
|
|
704
|
+
const repo = makeRepoManager([]);
|
|
705
|
+
|
|
706
|
+
createMailInboundConsumer({
|
|
707
|
+
dispatcherAgentId: DISPATCHER_ID,
|
|
708
|
+
inboxEvents,
|
|
709
|
+
agentManager: am.manager as AgentManager,
|
|
710
|
+
agentStore: store as AgentStore,
|
|
711
|
+
getSidecar: () => sidecar,
|
|
712
|
+
getRepoManager: () => repo.manager,
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// Standard envelope without repo metadata
|
|
716
|
+
inboxEvents.fire(workEnvelope("task-no-repo", "plain work"));
|
|
717
|
+
|
|
718
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
719
|
+
|
|
720
|
+
expect(am.spawnFn).toHaveBeenCalledOnce();
|
|
721
|
+
const spawnArgs = am.spawnFn.mock.calls[0]![0] as Record<string, unknown>;
|
|
722
|
+
expect(spawnArgs.cwd).toBeUndefined();
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it("spawns without cwd when getRepoManager is not provided", async () => {
|
|
726
|
+
createMailInboundConsumer({
|
|
727
|
+
dispatcherAgentId: DISPATCHER_ID,
|
|
728
|
+
inboxEvents,
|
|
729
|
+
agentManager: am.manager as AgentManager,
|
|
730
|
+
agentStore: store as AgentStore,
|
|
731
|
+
getSidecar: () => sidecar,
|
|
732
|
+
// no getRepoManager
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
inboxEvents.fire(
|
|
736
|
+
workEnvelopeWithRepo("task-no-mgr", "work", {
|
|
737
|
+
repo_id: "repo_abc",
|
|
738
|
+
canonical_url: "https://github.com/org/repo.git",
|
|
739
|
+
clone_policy: "allowed",
|
|
740
|
+
}),
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
744
|
+
|
|
745
|
+
expect(am.spawnFn).toHaveBeenCalledOnce();
|
|
746
|
+
const spawnArgs = am.spawnFn.mock.calls[0]![0] as Record<string, unknown>;
|
|
747
|
+
expect(spawnArgs.cwd).toBeUndefined();
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("spawns without cwd when canonical_url is missing from repo metadata", async () => {
|
|
751
|
+
const repo = makeRepoManager([]);
|
|
752
|
+
|
|
753
|
+
createMailInboundConsumer({
|
|
754
|
+
dispatcherAgentId: DISPATCHER_ID,
|
|
755
|
+
inboxEvents,
|
|
756
|
+
agentManager: am.manager as AgentManager,
|
|
757
|
+
agentStore: store as AgentStore,
|
|
758
|
+
getSidecar: () => sidecar,
|
|
759
|
+
getRepoManager: () => repo.manager,
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
inboxEvents.fire(
|
|
763
|
+
workEnvelopeWithRepo("task-no-url", "work", {
|
|
764
|
+
repo_id: "repo_abc",
|
|
765
|
+
// no canonical_url
|
|
766
|
+
}),
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
770
|
+
|
|
771
|
+
expect(am.spawnFn).toHaveBeenCalledOnce();
|
|
772
|
+
const spawnArgs = am.spawnFn.mock.calls[0]![0] as Record<string, unknown>;
|
|
773
|
+
expect(spawnArgs.cwd).toBeUndefined();
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it("logs repo_id in the received message log line", async () => {
|
|
777
|
+
const logs: string[] = [];
|
|
778
|
+
|
|
779
|
+
createMailInboundConsumer({
|
|
780
|
+
dispatcherAgentId: DISPATCHER_ID,
|
|
781
|
+
inboxEvents,
|
|
782
|
+
agentManager: am.manager as AgentManager,
|
|
783
|
+
agentStore: store as AgentStore,
|
|
784
|
+
getSidecar: () => sidecar,
|
|
785
|
+
log: (m) => logs.push(m),
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
inboxEvents.fire(
|
|
789
|
+
workEnvelopeWithRepo("task-log", "work", {
|
|
790
|
+
repo_id: "repo_visible",
|
|
791
|
+
canonical_url: "https://github.com/org/visible.git",
|
|
792
|
+
}),
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
796
|
+
|
|
797
|
+
expect(logs.some((l) => l.includes("repo=repo_visible"))).toBe(true);
|
|
798
|
+
});
|
|
799
|
+
});
|
|
589
800
|
});
|
|
@@ -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
|
-
|
|
271
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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]
|
|
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);
|
|
@@ -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,
|