macro-agent 0.2.2 → 0.2.4
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/boot-v2.d.ts +21 -0
- package/dist/boot-v2.d.ts.map +1 -1
- package/dist/boot-v2.js +53 -1
- package/dist/boot-v2.js.map +1 -1
- package/dist/map/cascade-diff-server.d.ts +43 -0
- package/dist/map/cascade-diff-server.d.ts.map +1 -0
- package/dist/map/cascade-diff-server.js +292 -0
- package/dist/map/cascade-diff-server.js.map +1 -0
- package/dist/map/sidecar.d.ts.map +1 -1
- package/dist/map/sidecar.js +26 -0
- package/dist/map/sidecar.js.map +1 -1
- package/dist/teams/team-loader.d.ts +28 -1
- package/dist/teams/team-loader.d.ts.map +1 -1
- package/dist/teams/team-loader.js +42 -0
- package/dist/teams/team-loader.js.map +1 -1
- package/dist/teams/team-manager-v2.d.ts +20 -0
- package/dist/teams/team-manager-v2.d.ts.map +1 -1
- package/dist/teams/team-manager-v2.js +25 -2
- package/dist/teams/team-manager-v2.js.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 +6 -4
- package/renovate.json5 +6 -0
- package/src/boot-v2.ts +97 -1
- package/src/map/__tests__/cascade-diff-server.test.ts +434 -0
- package/src/map/__tests__/sidecar-diff-install-smoke.test.ts +90 -0
- package/src/map/cascade-diff-server.ts +404 -0
- package/src/map/sidecar.ts +29 -0
- package/src/teams/team-loader.ts +57 -0
- package/src/teams/team-manager-v2.ts +41 -3
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cascade Diff Server — services `cascade/diff.request` notifications.
|
|
3
|
+
*
|
|
4
|
+
* The OpenHive hub fetches unified diffs from this runtime on demand. Flow:
|
|
5
|
+
*
|
|
6
|
+
* 1. Hub sends `cascade/diff.request` with `request_id`, `stream_id`,
|
|
7
|
+
* `head`, optional `base` / `file_paths` / `files_only`.
|
|
8
|
+
* 2. This handler resolves a worktree path from the stream id (falling
|
|
9
|
+
* back to the bare repo when no live worktree is checked out on the
|
|
10
|
+
* stream), shells out to `git show` or `git diff`, and produces a
|
|
11
|
+
* unified diff blob (or a name-only list when `files_only: true`).
|
|
12
|
+
* 3. Response is emitted as a `cascade/diff.response` notification —
|
|
13
|
+
* inline when ≤ 512 KB, streamed via N `cascade/diff.chunk`
|
|
14
|
+
* notifications when larger.
|
|
15
|
+
*
|
|
16
|
+
* The 50 MB raw cap (`MAX_DIFF_BYTES`) defends against runaway monorepo
|
|
17
|
+
* diffs. Errors fold into the same `cascade/diff.response` method via the
|
|
18
|
+
* `error` shape (mirrors trajectory/content.response).
|
|
19
|
+
*
|
|
20
|
+
* @module map/cascade-diff-server
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn } from 'child_process';
|
|
24
|
+
import { createHash, randomBytes } from 'crypto';
|
|
25
|
+
import { existsSync } from 'fs';
|
|
26
|
+
import type { GitCascadeAdapter } from '../workspace/git-cascade-adapter.js';
|
|
27
|
+
|
|
28
|
+
const REQUEST_METHOD = 'cascade/diff.request';
|
|
29
|
+
const RESPONSE_METHOD = 'cascade/diff.response';
|
|
30
|
+
const CHUNK_METHOD = 'cascade/diff.chunk';
|
|
31
|
+
|
|
32
|
+
const INLINE_THRESHOLD_BYTES = 512 * 1024;
|
|
33
|
+
const CHUNK_SIZE_BYTES = 1024 * 1024;
|
|
34
|
+
const MAX_DIFF_BYTES = 50 * 1024 * 1024;
|
|
35
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
36
|
+
|
|
37
|
+
export interface CascadeDiffServerConnection {
|
|
38
|
+
onNotification(
|
|
39
|
+
method: string,
|
|
40
|
+
handler: (params: unknown) => void | Promise<void>,
|
|
41
|
+
): void;
|
|
42
|
+
offNotification(
|
|
43
|
+
method: string,
|
|
44
|
+
handler: (params: unknown) => void | Promise<void>,
|
|
45
|
+
): void;
|
|
46
|
+
/** Send a JSON-RPC notification back to the hub. */
|
|
47
|
+
sendNotification(method: string, params: Record<string, unknown>): void | Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CascadeDiffRequestParams {
|
|
51
|
+
request_id: string;
|
|
52
|
+
stream_id: string;
|
|
53
|
+
head: string;
|
|
54
|
+
base?: string;
|
|
55
|
+
file_paths?: string[];
|
|
56
|
+
files_only?: boolean;
|
|
57
|
+
format?: 'unified';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type DiffErrorCode =
|
|
61
|
+
| 'not_found'
|
|
62
|
+
| 'bad_request'
|
|
63
|
+
| 'integrity_failed'
|
|
64
|
+
| 'internal';
|
|
65
|
+
|
|
66
|
+
interface ProduceResult {
|
|
67
|
+
blob: Buffer;
|
|
68
|
+
filesTouched: string[];
|
|
69
|
+
truncated: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Register the cascade/diff.request handler on the connection.
|
|
74
|
+
* Returns a cleanup function that removes the registration.
|
|
75
|
+
*/
|
|
76
|
+
export function setupCascadeDiffServer(
|
|
77
|
+
connection: CascadeDiffServerConnection,
|
|
78
|
+
adapter: GitCascadeAdapter,
|
|
79
|
+
): () => void {
|
|
80
|
+
const handler = async (params: unknown): Promise<void> => {
|
|
81
|
+
const req = params as CascadeDiffRequestParams | null;
|
|
82
|
+
if (!req?.request_id || typeof req.request_id !== 'string') return;
|
|
83
|
+
if (!req.stream_id || !req.head) {
|
|
84
|
+
await sendError(
|
|
85
|
+
connection,
|
|
86
|
+
req.request_id,
|
|
87
|
+
'bad_request',
|
|
88
|
+
'missing stream_id or head',
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const workdir = resolveWorkdir(adapter, req.stream_id);
|
|
94
|
+
if (!workdir) {
|
|
95
|
+
await sendError(
|
|
96
|
+
connection,
|
|
97
|
+
req.request_id,
|
|
98
|
+
'not_found',
|
|
99
|
+
`no worktree or repo path for stream ${req.stream_id}`,
|
|
100
|
+
);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let produced: ProduceResult;
|
|
105
|
+
try {
|
|
106
|
+
produced = await runGit({
|
|
107
|
+
workdir,
|
|
108
|
+
head: req.head,
|
|
109
|
+
base: req.base,
|
|
110
|
+
filePaths: req.file_paths,
|
|
111
|
+
filesOnly: req.files_only === true,
|
|
112
|
+
});
|
|
113
|
+
} catch (err) {
|
|
114
|
+
await sendError(
|
|
115
|
+
connection,
|
|
116
|
+
req.request_id,
|
|
117
|
+
'internal',
|
|
118
|
+
`git failed: ${(err as Error).message}`,
|
|
119
|
+
);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (produced.blob.length <= INLINE_THRESHOLD_BYTES) {
|
|
124
|
+
await connection.sendNotification(RESPONSE_METHOD, {
|
|
125
|
+
request_id: req.request_id,
|
|
126
|
+
streaming: false,
|
|
127
|
+
diff: produced.blob.toString('utf-8'),
|
|
128
|
+
files_touched: produced.filesTouched,
|
|
129
|
+
truncated: produced.truncated,
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await streamLargeBlob(
|
|
135
|
+
connection,
|
|
136
|
+
req.request_id,
|
|
137
|
+
produced.blob,
|
|
138
|
+
produced.filesTouched,
|
|
139
|
+
produced.truncated,
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
connection.onNotification(REQUEST_METHOD, handler);
|
|
144
|
+
return () => {
|
|
145
|
+
try {
|
|
146
|
+
connection.offNotification(REQUEST_METHOD, handler);
|
|
147
|
+
} catch {
|
|
148
|
+
/* non-fatal */
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ============================================================================
|
|
154
|
+
// Worktree resolution
|
|
155
|
+
// ============================================================================
|
|
156
|
+
|
|
157
|
+
function resolveWorkdir(
|
|
158
|
+
adapter: GitCascadeAdapter,
|
|
159
|
+
streamId: string,
|
|
160
|
+
): string | null {
|
|
161
|
+
// Prefer a live worktree currently checked out on this stream.
|
|
162
|
+
try {
|
|
163
|
+
const match = adapter
|
|
164
|
+
.listWorktrees()
|
|
165
|
+
.find((wt) => wt.currentStream === streamId);
|
|
166
|
+
if (match?.path && existsSync(match.path)) return match.path;
|
|
167
|
+
} catch {
|
|
168
|
+
/* fall through to repo-path fallback */
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Fallback: bare repo path. `git show <sha>` works repo-wide.
|
|
172
|
+
try {
|
|
173
|
+
const repoPath = adapter.repoPath;
|
|
174
|
+
if (repoPath && existsSync(repoPath)) return repoPath;
|
|
175
|
+
} catch {
|
|
176
|
+
/* fall through */
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ============================================================================
|
|
182
|
+
// Git shell-out
|
|
183
|
+
// ============================================================================
|
|
184
|
+
|
|
185
|
+
interface RunGitArgs {
|
|
186
|
+
workdir: string;
|
|
187
|
+
head: string;
|
|
188
|
+
base?: string;
|
|
189
|
+
filePaths?: string[];
|
|
190
|
+
filesOnly: boolean;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function runGit(args: RunGitArgs): Promise<ProduceResult> {
|
|
194
|
+
const gitArgs = buildGitArgs(args);
|
|
195
|
+
const buf = await spawnCapped(args.workdir, gitArgs);
|
|
196
|
+
|
|
197
|
+
if (args.filesOnly) {
|
|
198
|
+
const files = parseNameOnly(buf.data);
|
|
199
|
+
return { blob: Buffer.from(''), filesTouched: files, truncated: buf.truncated };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Extract files_touched from the diff blob headers. Cheap regex; the
|
|
203
|
+
// sidecar can avoid a second git invocation.
|
|
204
|
+
const files = extractFilesFromDiffHeaders(buf.data);
|
|
205
|
+
return { blob: buf.data, filesTouched: files, truncated: buf.truncated };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function buildGitArgs(args: RunGitArgs): string[] {
|
|
209
|
+
// --no-textconv: don't apply textconv filters (force raw bytes).
|
|
210
|
+
// -U3: 3 lines of context (default unified-diff window).
|
|
211
|
+
// --binary suppressed: default "Binary files differ" markers are fine.
|
|
212
|
+
if (args.filesOnly) {
|
|
213
|
+
if (args.base) {
|
|
214
|
+
return ['diff', '--name-only', `${args.base}..${args.head}`, '--', ...(args.filePaths ?? [])];
|
|
215
|
+
}
|
|
216
|
+
return [
|
|
217
|
+
'show',
|
|
218
|
+
'--no-textconv',
|
|
219
|
+
'--format=',
|
|
220
|
+
'--name-only',
|
|
221
|
+
args.head,
|
|
222
|
+
'--',
|
|
223
|
+
...(args.filePaths ?? []),
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
if (args.base) {
|
|
227
|
+
return [
|
|
228
|
+
'diff',
|
|
229
|
+
'--no-textconv',
|
|
230
|
+
'-U3',
|
|
231
|
+
`${args.base}..${args.head}`,
|
|
232
|
+
'--',
|
|
233
|
+
...(args.filePaths ?? []),
|
|
234
|
+
];
|
|
235
|
+
}
|
|
236
|
+
return [
|
|
237
|
+
'show',
|
|
238
|
+
'--no-textconv',
|
|
239
|
+
'-U3',
|
|
240
|
+
'--format=',
|
|
241
|
+
args.head,
|
|
242
|
+
'--',
|
|
243
|
+
...(args.filePaths ?? []),
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
interface CappedSpawnResult {
|
|
248
|
+
data: Buffer;
|
|
249
|
+
truncated: boolean;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Spawn git, capture stdout up to `MAX_DIFF_BYTES`. Beyond that, drain
|
|
254
|
+
* the rest into /dev/null and mark `truncated: true`. Always returns
|
|
255
|
+
* (no rejection on overflow); rejects only on spawn / non-zero exit.
|
|
256
|
+
*/
|
|
257
|
+
function spawnCapped(cwd: string, args: string[]): Promise<CappedSpawnResult> {
|
|
258
|
+
return new Promise((resolve, reject) => {
|
|
259
|
+
const proc = spawn('git', args, {
|
|
260
|
+
cwd,
|
|
261
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const parts: Buffer[] = [];
|
|
265
|
+
let total = 0;
|
|
266
|
+
let truncated = false;
|
|
267
|
+
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
|
268
|
+
let stderrBuf = '';
|
|
269
|
+
|
|
270
|
+
proc.stdout.on('data', (chunk: Buffer) => {
|
|
271
|
+
if (truncated) return;
|
|
272
|
+
if (total + chunk.length > MAX_DIFF_BYTES) {
|
|
273
|
+
const remaining = MAX_DIFF_BYTES - total;
|
|
274
|
+
if (remaining > 0) parts.push(chunk.subarray(0, remaining));
|
|
275
|
+
total = MAX_DIFF_BYTES;
|
|
276
|
+
truncated = true;
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
parts.push(chunk);
|
|
280
|
+
total += chunk.length;
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
proc.stderr.on('data', (chunk: Buffer) => {
|
|
284
|
+
// Cap stderr so a flood doesn't OOM us.
|
|
285
|
+
if (stderrBuf.length < 8192) {
|
|
286
|
+
stderrBuf += chunk.toString('utf-8');
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
killTimer = setTimeout(() => {
|
|
291
|
+
truncated = true;
|
|
292
|
+
try { proc.kill('SIGKILL'); } catch { /* nothing to kill */ }
|
|
293
|
+
}, GIT_TIMEOUT_MS);
|
|
294
|
+
|
|
295
|
+
proc.on('error', (err) => {
|
|
296
|
+
if (killTimer) clearTimeout(killTimer);
|
|
297
|
+
reject(err);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
proc.on('close', (code) => {
|
|
301
|
+
if (killTimer) clearTimeout(killTimer);
|
|
302
|
+
if (code !== 0 && !truncated) {
|
|
303
|
+
reject(
|
|
304
|
+
new Error(
|
|
305
|
+
`git ${args.join(' ')} exited ${code}: ${stderrBuf.trim().slice(0, 200)}`,
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
resolve({ data: Buffer.concat(parts), truncated });
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ============================================================================
|
|
316
|
+
// Output parsing
|
|
317
|
+
// ============================================================================
|
|
318
|
+
|
|
319
|
+
function parseNameOnly(buf: Buffer): string[] {
|
|
320
|
+
return buf
|
|
321
|
+
.toString('utf-8')
|
|
322
|
+
.split('\n')
|
|
323
|
+
.map((s) => s.trim())
|
|
324
|
+
.filter((s) => s.length > 0);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Pull file paths out of the per-file header pair `--- a/X` / `+++ b/Y`.
|
|
329
|
+
*
|
|
330
|
+
* The `diff --git a/X b/Y` line is ambiguous for filenames containing
|
|
331
|
+
* ` b/` (the regex can't tell where the a-side ends and the b-side
|
|
332
|
+
* begins, and git only quotes paths containing control chars / quotes /
|
|
333
|
+
* backslash, not plain spaces). The `--- ` and `+++ ` lines are
|
|
334
|
+
* unambiguous: each appears once per file, anchored at line start, with
|
|
335
|
+
* the full path running to end-of-line.
|
|
336
|
+
*
|
|
337
|
+
* Handles renames (different a/ and b/ paths → both surfaced as touched),
|
|
338
|
+
* new files (`--- /dev/null` → skipped, `+++ b/X` → X), and deletions
|
|
339
|
+
* (`--- a/X` → X, `+++ /dev/null` → skipped). Dedup via Set.
|
|
340
|
+
*/
|
|
341
|
+
function extractFilesFromDiffHeaders(buf: Buffer): string[] {
|
|
342
|
+
const seen = new Set<string>();
|
|
343
|
+
const text = buf.toString('utf-8');
|
|
344
|
+
const regex = /^[-+]{3} (?:a|b)\/(.+)$/gm;
|
|
345
|
+
let m: RegExpExecArray | null;
|
|
346
|
+
while ((m = regex.exec(text)) !== null) {
|
|
347
|
+
seen.add(m[1]);
|
|
348
|
+
}
|
|
349
|
+
return Array.from(seen);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ============================================================================
|
|
353
|
+
// Streaming
|
|
354
|
+
// ============================================================================
|
|
355
|
+
|
|
356
|
+
async function streamLargeBlob(
|
|
357
|
+
connection: CascadeDiffServerConnection,
|
|
358
|
+
requestId: string,
|
|
359
|
+
blob: Buffer,
|
|
360
|
+
filesTouched: string[],
|
|
361
|
+
truncated: boolean,
|
|
362
|
+
): Promise<void> {
|
|
363
|
+
// `request_id` is already unique per-request from the hub, but we add
|
|
364
|
+
// a random nonce so two sidecars routing through the same hub can't
|
|
365
|
+
// collide on the hub-side `chunkStreamToRequest` map even in the
|
|
366
|
+
// unlikely case the same request_id arrives twice (e.g. on retries).
|
|
367
|
+
const chunkStreamId = `cdiff-${requestId}-${randomBytes(6).toString('hex')}`;
|
|
368
|
+
|
|
369
|
+
await connection.sendNotification(RESPONSE_METHOD, {
|
|
370
|
+
request_id: requestId,
|
|
371
|
+
streaming: true,
|
|
372
|
+
chunk_stream_id: chunkStreamId,
|
|
373
|
+
total_size: blob.length,
|
|
374
|
+
files_touched: filesTouched,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const sha = createHash('sha256').update(blob).digest('hex');
|
|
378
|
+
const totalChunks = Math.ceil(blob.length / CHUNK_SIZE_BYTES);
|
|
379
|
+
|
|
380
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
381
|
+
const start = i * CHUNK_SIZE_BYTES;
|
|
382
|
+
const end = Math.min(start + CHUNK_SIZE_BYTES, blob.length);
|
|
383
|
+
const slice = blob.subarray(start, end);
|
|
384
|
+
const isFinal = i === totalChunks - 1;
|
|
385
|
+
await connection.sendNotification(CHUNK_METHOD, {
|
|
386
|
+
chunk_stream_id: chunkStreamId,
|
|
387
|
+
seq: i,
|
|
388
|
+
data: slice.toString('base64'),
|
|
389
|
+
...(isFinal ? { final: true, sha256: sha, truncated } : {}),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function sendError(
|
|
395
|
+
connection: CascadeDiffServerConnection,
|
|
396
|
+
requestId: string,
|
|
397
|
+
code: DiffErrorCode,
|
|
398
|
+
message: string,
|
|
399
|
+
): Promise<void> {
|
|
400
|
+
await connection.sendNotification(RESPONSE_METHOD, {
|
|
401
|
+
request_id: requestId,
|
|
402
|
+
error: { code, message },
|
|
403
|
+
});
|
|
404
|
+
}
|
package/src/map/sidecar.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
TaskBridge,
|
|
24
24
|
} from "./types.js";
|
|
25
25
|
import type { AgentLifecycleCallback } from "../agent/types.js";
|
|
26
|
+
import type { CascadeCapability } from "git-cascade/events";
|
|
26
27
|
import {
|
|
27
28
|
REPO_PROTOCOL_VERSION,
|
|
28
29
|
RepoClient,
|
|
@@ -66,6 +67,22 @@ export function createMAPSidecar(
|
|
|
66
67
|
let workspaceTransport: RepoClientTransport | null = null;
|
|
67
68
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
68
69
|
|
|
70
|
+
// Cascade capability — declared only when a git-cascade adapter is wired.
|
|
71
|
+
// Each flag reflects what macro-agent *actually wired up* (honest declaration):
|
|
72
|
+
// - canServeDiff: the diff server is wired alongside the adapter
|
|
73
|
+
// (setupCascadeDiffServer answers cascade/diff.request).
|
|
74
|
+
// - canAct: the inbound action handler is wired
|
|
75
|
+
// (setupCascadeActionHandlers handles x-cascade/request.*).
|
|
76
|
+
// - emitsConflicts: the cascade-bridge forwards stream.conflicted /
|
|
77
|
+
// stream.conflict_resolved events.
|
|
78
|
+
// autoCloseOnMerge is omitted — it is an opt-in close policy (default
|
|
79
|
+
// manual) and macro-agent has no config/wiring for it.
|
|
80
|
+
const cascadeCapability: CascadeCapability = {
|
|
81
|
+
canServeDiff: true,
|
|
82
|
+
canAct: true,
|
|
83
|
+
emitsConflicts: true,
|
|
84
|
+
};
|
|
85
|
+
|
|
69
86
|
// Resolve the workspace capability from env vars. Setting OPENHIVE_WORKSPACE_DECLARE=off
|
|
70
87
|
// disables both explicit declare AND trajectory-handler bootstrap on the hub side.
|
|
71
88
|
const workspaceCapability: WorkspaceCapability = {
|
|
@@ -173,6 +190,12 @@ export function createMAPSidecar(
|
|
|
173
190
|
canUpdate: true,
|
|
174
191
|
canList: true,
|
|
175
192
|
},
|
|
193
|
+
// Cascade capability is gated on whether a git-cascade adapter is
|
|
194
|
+
// wired — without it there are no worktrees to shell out against,
|
|
195
|
+
// so declaring it would just produce timeouts on the hub.
|
|
196
|
+
...(gitCascadeAdapter
|
|
197
|
+
? { cascade: cascadeCapability }
|
|
198
|
+
: {}),
|
|
176
199
|
workspace: workspaceCapability,
|
|
177
200
|
},
|
|
178
201
|
metadata: {
|
|
@@ -646,9 +669,15 @@ export function createMAPSidecar(
|
|
|
646
669
|
const { setupCascadeActionHandlers } = await import("./cascade-action-handler.js");
|
|
647
670
|
const actionCleanup = setupCascadeActionHandlers(connection, gitCascadeAdapter);
|
|
648
671
|
|
|
672
|
+
// 5c. Inbound diff server — receives cascade/diff.request from hub
|
|
673
|
+
// and replies with cascade/diff.response (+ chunks for large blobs).
|
|
674
|
+
const { setupCascadeDiffServer } = await import("./cascade-diff-server.js");
|
|
675
|
+
const diffCleanup = setupCascadeDiffServer(connection, gitCascadeAdapter);
|
|
676
|
+
|
|
649
677
|
cascadeBridgeCleanup = () => {
|
|
650
678
|
cascadeBridge.dispose();
|
|
651
679
|
actionCleanup();
|
|
680
|
+
diffCleanup();
|
|
652
681
|
};
|
|
653
682
|
}
|
|
654
683
|
|
package/src/teams/team-loader.ts
CHANGED
|
@@ -82,6 +82,63 @@ export async function loadTeam(
|
|
|
82
82
|
throw mapToTeamLoadError(err, teamName, teamDir);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
return finalizeTemplate(template, teamName, roleRegistry);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Load a team template from in-memory content (no filesystem).
|
|
90
|
+
*
|
|
91
|
+
* Wire-delivery counterpart to {@link loadTeam}: instead of reading
|
|
92
|
+
* `team.yaml` + `roles/*.yaml` + `prompts/*` from disk, hydrate via
|
|
93
|
+
* `TemplateLoader.fromObject` from a structured snapshot. Used by hosts
|
|
94
|
+
* that ship the team config inline at boot — most prominently OpenHive's
|
|
95
|
+
* spawn manager packing `bootstrap.openteams.team_content` into the
|
|
96
|
+
* `OPENSWARM_BOOTSTRAP_TOKEN` env var.
|
|
97
|
+
*
|
|
98
|
+
* The result is structurally identical to `loadTeam`'s — same macro-
|
|
99
|
+
* agent enrichment, same validation, same downstream contract.
|
|
100
|
+
*
|
|
101
|
+
* @param teamName - Logical team name (used for error messages + the
|
|
102
|
+
* returned manifest's `name` when the inlined manifest doesn't carry
|
|
103
|
+
* one of its own).
|
|
104
|
+
* @param content - Inline team content. Same shape as openteams's
|
|
105
|
+
* `TemplateLoader.fromObject` input.
|
|
106
|
+
* @param roleRegistry - Role registry for resolving extends chains.
|
|
107
|
+
*/
|
|
108
|
+
export async function loadTeamFromContent(
|
|
109
|
+
teamName: string,
|
|
110
|
+
content: {
|
|
111
|
+
manifest: OpenTeamsManifest;
|
|
112
|
+
roles?: Record<string, RoleDefinition>;
|
|
113
|
+
loadouts?: Record<string, unknown>;
|
|
114
|
+
prompts?: Record<string, unknown>;
|
|
115
|
+
},
|
|
116
|
+
roleRegistry: RoleRegistry,
|
|
117
|
+
): Promise<TeamManifest> {
|
|
118
|
+
let template;
|
|
119
|
+
try {
|
|
120
|
+
template = TemplateLoader.fromObject(content as never, {
|
|
121
|
+
resolveExternalRole: (name) => mapRegistryRole(roleRegistry, name),
|
|
122
|
+
postProcessRole: (role, manifest) =>
|
|
123
|
+
enrichRoleWithSpawnRules(role, manifest),
|
|
124
|
+
});
|
|
125
|
+
} catch (err) {
|
|
126
|
+
throw mapToTeamLoadError(err, teamName, `<inline:${teamName}>`);
|
|
127
|
+
}
|
|
128
|
+
return finalizeTemplate(template, teamName, roleRegistry);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Shared post-load processing applied to whatever `TemplateLoader`
|
|
133
|
+
* (sync or async, disk or in-memory) produces. Builds the enrichment-
|
|
134
|
+
* enriched role map, assembles the multi-file prompt index, validates
|
|
135
|
+
* communication, and returns the macro-agent `TeamManifest` shape.
|
|
136
|
+
*/
|
|
137
|
+
function finalizeTemplate(
|
|
138
|
+
template: ReturnType<typeof TemplateLoader.fromObject>,
|
|
139
|
+
teamName: string,
|
|
140
|
+
roleRegistry: RoleRegistry,
|
|
141
|
+
): TeamManifest {
|
|
85
142
|
const manifest = template.manifest;
|
|
86
143
|
const communication = (manifest.communication ?? {}) as CommunicationConfig;
|
|
87
144
|
const macroAgent = parseMacroAgentExtensions(
|
|
@@ -131,9 +131,7 @@ export class TeamManagerV2 {
|
|
|
131
131
|
* @returns The team instance ID
|
|
132
132
|
*/
|
|
133
133
|
async startTeam(name: string, basePath?: string): Promise<string> {
|
|
134
|
-
const { agentManager
|
|
135
|
-
|
|
136
|
-
// Load template
|
|
134
|
+
const { agentManager } = this.services;
|
|
137
135
|
const { loadTeam } = await import("./team-loader.js");
|
|
138
136
|
const roleRegistry = agentManager.getRoleRegistry();
|
|
139
137
|
const manifest: TeamManifest = await loadTeam(
|
|
@@ -141,7 +139,47 @@ export class TeamManagerV2 {
|
|
|
141
139
|
roleRegistry,
|
|
142
140
|
basePath ?? process.cwd()
|
|
143
141
|
);
|
|
142
|
+
return this.startTeamWithManifest(name, manifest);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Start a team from an in-memory manifest snapshot — used by hosts
|
|
147
|
+
* that ship the team config inline at boot (OpenHive's spawn manager
|
|
148
|
+
* packing `bootstrap.openteams.team_content` into the bootstrap
|
|
149
|
+
* token, for example). Skips disk I/O entirely; otherwise identical
|
|
150
|
+
* to {@link startTeam}.
|
|
151
|
+
*/
|
|
152
|
+
async startTeamFromContent(
|
|
153
|
+
name: string,
|
|
154
|
+
content: {
|
|
155
|
+
manifest: import("openteams").TeamManifest;
|
|
156
|
+
roles?: Record<string, import("../roles/types.js").RoleDefinition>;
|
|
157
|
+
loadouts?: Record<string, unknown>;
|
|
158
|
+
prompts?: Record<string, unknown>;
|
|
159
|
+
},
|
|
160
|
+
): Promise<string> {
|
|
161
|
+
const { agentManager } = this.services;
|
|
162
|
+
const { loadTeamFromContent } = await import("./team-loader.js");
|
|
163
|
+
const roleRegistry = agentManager.getRoleRegistry();
|
|
164
|
+
const manifest: TeamManifest = await loadTeamFromContent(
|
|
165
|
+
name,
|
|
166
|
+
content,
|
|
167
|
+
roleRegistry,
|
|
168
|
+
);
|
|
169
|
+
return this.startTeamWithManifest(name, manifest);
|
|
170
|
+
}
|
|
144
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Shared post-load flow: wire optional topology, build the runtime,
|
|
174
|
+
* bootstrap the team's root + companions, install scoped filters,
|
|
175
|
+
* and register the instance. Callable from any loader path
|
|
176
|
+
* (disk-based `startTeam` or wire-based `startTeamFromContent`).
|
|
177
|
+
*/
|
|
178
|
+
private async startTeamWithManifest(
|
|
179
|
+
name: string,
|
|
180
|
+
manifest: TeamManifest,
|
|
181
|
+
): Promise<string> {
|
|
182
|
+
const { agentManager, inboxAdapter, tasksAdapter, workspaceManager } = this.services;
|
|
145
183
|
// V3: auto-wire TopologyPolicy when the team declares
|
|
146
184
|
// `macro_agent.workspace`. Requires a WorkspaceManager to be present.
|
|
147
185
|
if (workspaceManager) {
|