@teammates/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +212 -0
- package/dist/adapter.d.ts +70 -0
- package/dist/adapter.js +159 -0
- package/dist/adapter.test.d.ts +1 -0
- package/dist/adapter.test.js +145 -0
- package/dist/adapters/cli-proxy.d.ts +74 -0
- package/dist/adapters/cli-proxy.js +463 -0
- package/dist/adapters/codex.d.ts +50 -0
- package/dist/adapters/codex.js +213 -0
- package/dist/adapters/echo.d.ts +13 -0
- package/dist/adapters/echo.js +24 -0
- package/dist/adapters/echo.test.d.ts +1 -0
- package/dist/adapters/echo.test.js +40 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +1529 -0
- package/dist/dropdown.d.ts +23 -0
- package/dist/dropdown.js +80 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/onboard.d.ts +18 -0
- package/dist/onboard.js +199 -0
- package/dist/orchestrator.d.ts +70 -0
- package/dist/orchestrator.js +240 -0
- package/dist/orchestrator.test.d.ts +1 -0
- package/dist/orchestrator.test.js +245 -0
- package/dist/registry.d.ts +24 -0
- package/dist/registry.js +145 -0
- package/dist/registry.test.d.ts +1 -0
- package/dist/registry.test.js +171 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.js +4 -0
- package/package.json +42 -0
- package/template/CROSS-TEAM.md +31 -0
- package/template/PROTOCOL.md +99 -0
- package/template/README.md +48 -0
- package/template/TEMPLATE.md +109 -0
- package/template/example/MEMORIES.md +26 -0
- package/template/example/SOUL.md +81 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic CLI proxy adapter — spawns any coding agent as a subprocess
|
|
3
|
+
* and streams its output live to the user's terminal.
|
|
4
|
+
*
|
|
5
|
+
* Supports any CLI agent that accepts a prompt and runs to completion:
|
|
6
|
+
* claude -p "prompt"
|
|
7
|
+
* codex exec "prompt" --full-auto
|
|
8
|
+
* aider --message "prompt"
|
|
9
|
+
* etc.
|
|
10
|
+
*
|
|
11
|
+
* The adapter:
|
|
12
|
+
* 1. Writes the full prompt (identity + memory + task) to a temp file
|
|
13
|
+
* 2. Spawns the agent with the prompt file
|
|
14
|
+
* 3. Tees stdout/stderr to the user's terminal in real time
|
|
15
|
+
* 4. Captures output for result parsing (changed files, handoff envelopes)
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { writeFile, unlink, mkdir } from "node:fs/promises";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
import { buildTeammatePrompt } from "../adapter.js";
|
|
23
|
+
export const PRESETS = {
|
|
24
|
+
claude: {
|
|
25
|
+
name: "claude",
|
|
26
|
+
command: "claude",
|
|
27
|
+
buildArgs({ prompt }, teammate, options) {
|
|
28
|
+
const args = ["-p", "--verbose", "--dangerously-skip-permissions"];
|
|
29
|
+
if (options.model)
|
|
30
|
+
args.push("--model", options.model);
|
|
31
|
+
args.push("--", prompt);
|
|
32
|
+
return args;
|
|
33
|
+
},
|
|
34
|
+
env: { FORCE_COLOR: "1", CLAUDECODE: "" },
|
|
35
|
+
},
|
|
36
|
+
codex: {
|
|
37
|
+
name: "codex",
|
|
38
|
+
command: "codex",
|
|
39
|
+
buildArgs({ prompt }, teammate, options) {
|
|
40
|
+
const args = ["exec", prompt];
|
|
41
|
+
if (teammate.cwd)
|
|
42
|
+
args.push("-C", teammate.cwd);
|
|
43
|
+
const sandbox = teammate.sandbox ?? options.defaultSandbox ?? "workspace-write";
|
|
44
|
+
args.push("-s", sandbox);
|
|
45
|
+
args.push("--full-auto");
|
|
46
|
+
if (options.model)
|
|
47
|
+
args.push("-m", options.model);
|
|
48
|
+
return args;
|
|
49
|
+
},
|
|
50
|
+
env: { FORCE_COLOR: "1" },
|
|
51
|
+
},
|
|
52
|
+
aider: {
|
|
53
|
+
name: "aider",
|
|
54
|
+
command: "aider",
|
|
55
|
+
buildArgs({ promptFile }, teammate, options) {
|
|
56
|
+
const args = ["--message-file", promptFile, "--yes", "--no-git"];
|
|
57
|
+
if (options.model)
|
|
58
|
+
args.push("--model", options.model);
|
|
59
|
+
return args;
|
|
60
|
+
},
|
|
61
|
+
env: { FORCE_COLOR: "1" },
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
let nextId = 1;
|
|
65
|
+
export class CliProxyAdapter {
|
|
66
|
+
name;
|
|
67
|
+
/** Team roster — set by the orchestrator so prompts include teammate info. */
|
|
68
|
+
roster = [];
|
|
69
|
+
/** Installed services — set by the CLI so prompts include service info. */
|
|
70
|
+
services = [];
|
|
71
|
+
preset;
|
|
72
|
+
options;
|
|
73
|
+
/** Session files per teammate — persists state across task invocations. */
|
|
74
|
+
sessionFiles = new Map();
|
|
75
|
+
/** Base directory for session files. */
|
|
76
|
+
sessionsDir = "";
|
|
77
|
+
/** Temp prompt files that need cleanup — guards against crashes before finally. */
|
|
78
|
+
pendingTempFiles = new Set();
|
|
79
|
+
constructor(options) {
|
|
80
|
+
this.options = options;
|
|
81
|
+
this.preset =
|
|
82
|
+
typeof options.preset === "string"
|
|
83
|
+
? PRESETS[options.preset]
|
|
84
|
+
: options.preset;
|
|
85
|
+
if (!this.preset) {
|
|
86
|
+
throw new Error(`Unknown agent preset: ${options.preset}. Available: ${Object.keys(PRESETS).join(", ")}`);
|
|
87
|
+
}
|
|
88
|
+
this.name = this.preset.name;
|
|
89
|
+
}
|
|
90
|
+
async startSession(teammate) {
|
|
91
|
+
const id = `${this.name}-${teammate.name}-${nextId++}`;
|
|
92
|
+
// Create session file for this teammate
|
|
93
|
+
if (!this.sessionsDir) {
|
|
94
|
+
this.sessionsDir = join(tmpdir(), `teammates-sessions-${randomUUID()}`);
|
|
95
|
+
await mkdir(this.sessionsDir, { recursive: true });
|
|
96
|
+
}
|
|
97
|
+
const sessionFile = join(this.sessionsDir, `${teammate.name}.md`);
|
|
98
|
+
await writeFile(sessionFile, `# Session — ${teammate.name}\n\n`, "utf-8");
|
|
99
|
+
this.sessionFiles.set(teammate.name, sessionFile);
|
|
100
|
+
return id;
|
|
101
|
+
}
|
|
102
|
+
async executeTask(sessionId, teammate, prompt) {
|
|
103
|
+
// If the teammate has no soul (e.g. the raw agent), skip identity/memory
|
|
104
|
+
// wrapping but include handoff instructions so it can delegate to teammates
|
|
105
|
+
const sessionFile = this.sessionFiles.get(teammate.name);
|
|
106
|
+
let fullPrompt;
|
|
107
|
+
if (teammate.soul) {
|
|
108
|
+
fullPrompt = buildTeammatePrompt(teammate, prompt, {
|
|
109
|
+
roster: this.roster,
|
|
110
|
+
services: this.services,
|
|
111
|
+
sessionFile,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const parts = [prompt];
|
|
116
|
+
const others = this.roster.filter((r) => r.name !== teammate.name);
|
|
117
|
+
if (others.length > 0) {
|
|
118
|
+
parts.push("\n\n---\n");
|
|
119
|
+
parts.push("If part of this task belongs to a specialist, you can hand it off.");
|
|
120
|
+
parts.push("Your teammates:");
|
|
121
|
+
for (const t of others) {
|
|
122
|
+
const owns = t.ownership.primary.length > 0
|
|
123
|
+
? ` — owns: ${t.ownership.primary.join(", ")}`
|
|
124
|
+
: "";
|
|
125
|
+
parts.push(`- @${t.name}: ${t.role}${owns}`);
|
|
126
|
+
}
|
|
127
|
+
parts.push("\nTo hand off, end your response with:");
|
|
128
|
+
parts.push("```json");
|
|
129
|
+
parts.push('{ "handoff": { "to": "<teammate>", "task": "<what you need them to do>", "context": "<any context>" } }');
|
|
130
|
+
parts.push("```");
|
|
131
|
+
}
|
|
132
|
+
fullPrompt = parts.join("\n");
|
|
133
|
+
}
|
|
134
|
+
// Write prompt to temp file to avoid shell escaping issues
|
|
135
|
+
const promptFile = join(tmpdir(), `teammates-${this.name}-${randomUUID()}.md`);
|
|
136
|
+
await writeFile(promptFile, fullPrompt, "utf-8");
|
|
137
|
+
this.pendingTempFiles.add(promptFile);
|
|
138
|
+
try {
|
|
139
|
+
const output = await this.spawnAndProxy(teammate, promptFile, fullPrompt);
|
|
140
|
+
const teammateNames = this.roster.map((r) => r.name);
|
|
141
|
+
return parseResult(teammate.name, output, teammateNames, prompt);
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
this.pendingTempFiles.delete(promptFile);
|
|
145
|
+
await unlink(promptFile).catch(() => { });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async routeTask(task, roster) {
|
|
149
|
+
const lines = [
|
|
150
|
+
"You are a task router. Given a task and a list of teammates, reply with ONLY the name of the teammate who should handle it. No explanation, no punctuation — just the name.",
|
|
151
|
+
"",
|
|
152
|
+
"Teammates:",
|
|
153
|
+
];
|
|
154
|
+
for (const t of roster) {
|
|
155
|
+
const owns = t.ownership.primary.length > 0
|
|
156
|
+
? ` — owns: ${t.ownership.primary.join(", ")}`
|
|
157
|
+
: "";
|
|
158
|
+
lines.push(`- ${t.name}: ${t.role}${owns}`);
|
|
159
|
+
}
|
|
160
|
+
lines.push("", `Task: ${task}`);
|
|
161
|
+
const prompt = lines.join("\n");
|
|
162
|
+
const promptFile = join(tmpdir(), `teammates-route-${randomUUID()}.md`);
|
|
163
|
+
await writeFile(promptFile, prompt, "utf-8");
|
|
164
|
+
try {
|
|
165
|
+
const command = this.options.commandPath ?? this.preset.command;
|
|
166
|
+
const args = this.preset.buildArgs({ promptFile, prompt }, { name: "_router", role: "", soul: "", memories: "", dailyLogs: [], ownership: { primary: [], secondary: [] } }, { ...this.options, model: this.options.model ?? "haiku" });
|
|
167
|
+
const env = { ...process.env, ...this.preset.env };
|
|
168
|
+
const output = await new Promise((resolve, reject) => {
|
|
169
|
+
const child = spawn(command, args, {
|
|
170
|
+
cwd: process.cwd(),
|
|
171
|
+
env,
|
|
172
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
173
|
+
shell: this.preset.shell ?? false,
|
|
174
|
+
});
|
|
175
|
+
const captured = [];
|
|
176
|
+
child.stdout?.on("data", (chunk) => captured.push(chunk));
|
|
177
|
+
child.stderr?.on("data", (chunk) => captured.push(chunk));
|
|
178
|
+
const timer = setTimeout(() => {
|
|
179
|
+
if (!child.killed)
|
|
180
|
+
child.kill("SIGTERM");
|
|
181
|
+
}, 30_000);
|
|
182
|
+
child.on("close", () => {
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
resolve(Buffer.concat(captured).toString("utf-8"));
|
|
185
|
+
});
|
|
186
|
+
child.on("error", (err) => {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
reject(err);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
// Extract the teammate name from the output
|
|
192
|
+
const rosterNames = roster.map((r) => r.name);
|
|
193
|
+
const trimmed = output.trim().toLowerCase();
|
|
194
|
+
// Check each name — the agent should have returned just one
|
|
195
|
+
for (const name of rosterNames) {
|
|
196
|
+
if (trimmed === name.toLowerCase() || trimmed.endsWith(name.toLowerCase())) {
|
|
197
|
+
return name;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Fuzzy: check if any name appears in the output
|
|
201
|
+
for (const name of rosterNames) {
|
|
202
|
+
if (trimmed.includes(name.toLowerCase())) {
|
|
203
|
+
return name;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
await unlink(promptFile).catch(() => { });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async destroySession(sessionId) {
|
|
216
|
+
// Clean up any leaked temp prompt files
|
|
217
|
+
for (const file of this.pendingTempFiles) {
|
|
218
|
+
await unlink(file).catch(() => { });
|
|
219
|
+
}
|
|
220
|
+
this.pendingTempFiles.clear();
|
|
221
|
+
// Clean up session files
|
|
222
|
+
for (const [, file] of this.sessionFiles) {
|
|
223
|
+
await unlink(file).catch(() => { });
|
|
224
|
+
}
|
|
225
|
+
this.sessionFiles.clear();
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Spawn the agent, stream its output live, and capture it.
|
|
229
|
+
*/
|
|
230
|
+
spawnAndProxy(teammate, promptFile, fullPrompt) {
|
|
231
|
+
return new Promise((resolve, reject) => {
|
|
232
|
+
const args = [
|
|
233
|
+
...this.preset.buildArgs({ promptFile, prompt: fullPrompt }, teammate, this.options),
|
|
234
|
+
...(this.options.extraFlags ?? []),
|
|
235
|
+
];
|
|
236
|
+
const command = this.options.commandPath ?? this.preset.command;
|
|
237
|
+
const env = { ...process.env, ...this.preset.env };
|
|
238
|
+
const timeout = this.options.timeout ?? 600_000;
|
|
239
|
+
const interactive = this.preset.interactive ?? false;
|
|
240
|
+
const child = spawn(command, args, {
|
|
241
|
+
cwd: teammate.cwd ?? process.cwd(),
|
|
242
|
+
env,
|
|
243
|
+
stdio: [interactive ? "pipe" : "ignore", "pipe", "pipe"],
|
|
244
|
+
shell: this.preset.shell ?? false,
|
|
245
|
+
});
|
|
246
|
+
// ── Timeout with SIGTERM → SIGKILL escalation ──────────────
|
|
247
|
+
let killed = false;
|
|
248
|
+
let killTimer = null;
|
|
249
|
+
const timeoutTimer = setTimeout(() => {
|
|
250
|
+
if (!child.killed) {
|
|
251
|
+
killed = true;
|
|
252
|
+
child.kill("SIGTERM");
|
|
253
|
+
// If SIGTERM doesn't work after 5s, force-kill
|
|
254
|
+
killTimer = setTimeout(() => {
|
|
255
|
+
if (!child.killed) {
|
|
256
|
+
child.kill("SIGKILL");
|
|
257
|
+
}
|
|
258
|
+
}, 5_000);
|
|
259
|
+
}
|
|
260
|
+
}, timeout);
|
|
261
|
+
// Connect user's stdin → child only if agent may ask questions
|
|
262
|
+
let onUserInput = null;
|
|
263
|
+
if (interactive && child.stdin) {
|
|
264
|
+
onUserInput = (chunk) => {
|
|
265
|
+
child.stdin?.write(chunk);
|
|
266
|
+
};
|
|
267
|
+
if (process.stdin.isTTY) {
|
|
268
|
+
process.stdin.setRawMode(false);
|
|
269
|
+
}
|
|
270
|
+
process.stdin.resume();
|
|
271
|
+
process.stdin.on("data", onUserInput);
|
|
272
|
+
}
|
|
273
|
+
const captured = [];
|
|
274
|
+
child.stdout?.on("data", (chunk) => {
|
|
275
|
+
captured.push(chunk);
|
|
276
|
+
});
|
|
277
|
+
child.stderr?.on("data", (chunk) => {
|
|
278
|
+
captured.push(chunk);
|
|
279
|
+
});
|
|
280
|
+
const cleanup = () => {
|
|
281
|
+
clearTimeout(timeoutTimer);
|
|
282
|
+
if (killTimer)
|
|
283
|
+
clearTimeout(killTimer);
|
|
284
|
+
if (onUserInput) {
|
|
285
|
+
process.stdin.removeListener("data", onUserInput);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
child.on("close", (code) => {
|
|
289
|
+
cleanup();
|
|
290
|
+
const output = Buffer.concat(captured).toString("utf-8");
|
|
291
|
+
if (killed) {
|
|
292
|
+
resolve(output + `\n\n[TIMEOUT] Agent process killed after ${timeout}ms`);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
resolve(output);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
child.on("error", (err) => {
|
|
299
|
+
cleanup();
|
|
300
|
+
reject(new Error(`Failed to spawn ${command}: ${err.message}`));
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// ─── Output parsing (shared across all agents) ─────────────────────
|
|
306
|
+
function parseResult(teammateName, output, teammateNames = [], originalTask) {
|
|
307
|
+
// Try to parse the structured JSON block the agent was asked to produce
|
|
308
|
+
const structured = parseStructuredOutput(output);
|
|
309
|
+
if (structured) {
|
|
310
|
+
return { ...structured, teammate: teammateName, rawOutput: output };
|
|
311
|
+
}
|
|
312
|
+
// Fallback: scrape what we can from freeform output
|
|
313
|
+
return {
|
|
314
|
+
teammate: teammateName,
|
|
315
|
+
success: true,
|
|
316
|
+
summary: extractSummary(output),
|
|
317
|
+
changedFiles: parseChangedFiles(output),
|
|
318
|
+
handoff: parseHandoffEnvelope(output) ?? parseHandoffFromMention(teammateName, output, teammateNames, originalTask) ?? undefined,
|
|
319
|
+
rawOutput: output,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Parse the structured JSON block from the output protocol.
|
|
324
|
+
* Looks for either { "result": ... } or { "handoff": ... }.
|
|
325
|
+
*/
|
|
326
|
+
function parseStructuredOutput(output) {
|
|
327
|
+
const jsonBlocks = [...output.matchAll(/```json\s*\n([\s\S]*?)```/g)];
|
|
328
|
+
// Take the last JSON block — that's the protocol output
|
|
329
|
+
for (let i = jsonBlocks.length - 1; i >= 0; i--) {
|
|
330
|
+
const block = jsonBlocks[i][1].trim();
|
|
331
|
+
try {
|
|
332
|
+
const parsed = JSON.parse(block);
|
|
333
|
+
if (parsed.result) {
|
|
334
|
+
return {
|
|
335
|
+
success: true,
|
|
336
|
+
summary: parsed.result.summary ?? "",
|
|
337
|
+
changedFiles: parsed.result.changedFiles ?? parsed.result.changed_files ?? [],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (parsed.handoff && parsed.handoff.to && parsed.handoff.task) {
|
|
341
|
+
const h = parsed.handoff;
|
|
342
|
+
return {
|
|
343
|
+
success: true,
|
|
344
|
+
summary: h.context ?? h.task,
|
|
345
|
+
changedFiles: h.changedFiles ?? h.changed_files ?? [],
|
|
346
|
+
handoff: {
|
|
347
|
+
from: h.from ?? "",
|
|
348
|
+
to: h.to,
|
|
349
|
+
task: h.task,
|
|
350
|
+
changedFiles: h.changedFiles ?? h.changed_files,
|
|
351
|
+
acceptanceCriteria: h.acceptanceCriteria ?? h.acceptance_criteria,
|
|
352
|
+
openQuestions: h.openQuestions ?? h.open_questions,
|
|
353
|
+
context: h.context,
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// Not valid JSON
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
/** Extract file paths from agent output. */
|
|
365
|
+
function parseChangedFiles(output) {
|
|
366
|
+
const files = new Set();
|
|
367
|
+
// diff --git a/path b/path
|
|
368
|
+
for (const match of output.matchAll(/diff --git a\/(.+?) b\//g)) {
|
|
369
|
+
files.add(match[1]);
|
|
370
|
+
}
|
|
371
|
+
// "Created/Modified/Updated/Wrote/Edited <path>" patterns
|
|
372
|
+
for (const match of output.matchAll(/(?:Created|Modified|Updated|Wrote|Edited)\s+(?:file:\s*)?[`"]?([^\s`"]+\.\w+)[`"]?/gi)) {
|
|
373
|
+
files.add(match[1]);
|
|
374
|
+
}
|
|
375
|
+
return Array.from(files);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Look for a JSON handoff envelope in the output.
|
|
379
|
+
* Agents request handoffs by including a fenced JSON block:
|
|
380
|
+
*
|
|
381
|
+
* ```json
|
|
382
|
+
* { "handoff": { "to": "tester", "task": "...", ... } }
|
|
383
|
+
* ```
|
|
384
|
+
*/
|
|
385
|
+
function parseHandoffEnvelope(output) {
|
|
386
|
+
const jsonBlocks = output.matchAll(/```json\s*\n([\s\S]*?)```/g);
|
|
387
|
+
for (const match of jsonBlocks) {
|
|
388
|
+
const block = match[1].trim();
|
|
389
|
+
if (!block.includes('"handoff"') && !block.includes('"to"'))
|
|
390
|
+
continue;
|
|
391
|
+
try {
|
|
392
|
+
const parsed = JSON.parse(block);
|
|
393
|
+
const envelope = parsed.handoff ?? parsed;
|
|
394
|
+
if (envelope.to && envelope.task) {
|
|
395
|
+
return {
|
|
396
|
+
from: envelope.from ?? "",
|
|
397
|
+
to: envelope.to,
|
|
398
|
+
task: envelope.task,
|
|
399
|
+
changedFiles: envelope.changedFiles ?? envelope.changed_files,
|
|
400
|
+
acceptanceCriteria: envelope.acceptanceCriteria ?? envelope.acceptance_criteria,
|
|
401
|
+
openQuestions: envelope.openQuestions ?? envelope.open_questions,
|
|
402
|
+
context: envelope.context,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
// Not valid JSON, skip
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Detect handoff intent from plain-text @mentions in the agent's response.
|
|
414
|
+
* Catches cases like "This is in @beacon's domain. Let me hand it off."
|
|
415
|
+
* where the agent didn't produce the structured JSON block.
|
|
416
|
+
*/
|
|
417
|
+
/**
|
|
418
|
+
* Detect handoff intent from plain-text @mentions in the agent's response.
|
|
419
|
+
* Catches cases like "This is in @beacon's domain. Let me hand it off."
|
|
420
|
+
* where the agent didn't produce the structured JSON block.
|
|
421
|
+
*/
|
|
422
|
+
function parseHandoffFromMention(fromTeammate, output, teammateNames, originalTask) {
|
|
423
|
+
if (teammateNames.length === 0)
|
|
424
|
+
return null;
|
|
425
|
+
// Look for @teammate mentions (excluding the agent's own name)
|
|
426
|
+
const others = teammateNames.filter((n) => n !== fromTeammate);
|
|
427
|
+
if (others.length === 0)
|
|
428
|
+
return null;
|
|
429
|
+
// Match @name patterns — require handoff-like language nearby
|
|
430
|
+
const handoffPatterns = /\bhand(?:ing)?\s*(?:it\s+)?off\b|\bdelegate\b|\broute\b|\bpass(?:ing)?\s+(?:it\s+)?(?:to|along)\b|\bbelong(?:s)?\s+to\b|\b(?:is\s+in)\s+@\w+'?s?\s+domain\b/i;
|
|
431
|
+
for (const name of others) {
|
|
432
|
+
const mentionPattern = new RegExp(`@${name}\\b`, "i");
|
|
433
|
+
if (mentionPattern.test(output) && handoffPatterns.test(output)) {
|
|
434
|
+
return {
|
|
435
|
+
from: fromTeammate,
|
|
436
|
+
to: name,
|
|
437
|
+
task: originalTask || output.slice(0, 200).trim(),
|
|
438
|
+
context: output.replace(/```json[\s\S]*?```/g, "").trim().slice(0, 500),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
/** Extract a summary from agent output. */
|
|
445
|
+
function extractSummary(output) {
|
|
446
|
+
// Look for a "## Summary" or "Summary:" section
|
|
447
|
+
const summaryMatch = output.match(/(?:##?\s*Summary|Summary:)\s*\n([\s\S]*?)(?:\n##|\n---|\n```|$)/i);
|
|
448
|
+
if (summaryMatch) {
|
|
449
|
+
const summary = summaryMatch[1].trim();
|
|
450
|
+
if (summary.length > 0)
|
|
451
|
+
return summary.slice(0, 500);
|
|
452
|
+
}
|
|
453
|
+
// Fall back to last non-empty paragraph
|
|
454
|
+
const paragraphs = output
|
|
455
|
+
.split(/\n\s*\n/)
|
|
456
|
+
.map((p) => p.trim())
|
|
457
|
+
.filter((p) => p.length > 0 && !p.startsWith("```"));
|
|
458
|
+
if (paragraphs.length > 0) {
|
|
459
|
+
const last = paragraphs[paragraphs.length - 1];
|
|
460
|
+
return last.length > 500 ? last.slice(0, 497) + "..." : last;
|
|
461
|
+
}
|
|
462
|
+
return output.slice(0, 200).trim();
|
|
463
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex adapter — runs each teammate as a `codex exec` subprocess.
|
|
3
|
+
*
|
|
4
|
+
* Uses the OpenAI Codex CLI in non-interactive mode:
|
|
5
|
+
* codex exec "<prompt>" --full-auto -C <cwd> -s <sandbox> -m <model>
|
|
6
|
+
*
|
|
7
|
+
* Each execution is stateless (no thread continuity). The teammate's full
|
|
8
|
+
* identity, memory, and handoff context are injected into the prompt every time.
|
|
9
|
+
*
|
|
10
|
+
* Requirements:
|
|
11
|
+
* - `codex` CLI installed and on PATH
|
|
12
|
+
* - OPENAI_API_KEY or CODEX_API_KEY set in environment
|
|
13
|
+
*/
|
|
14
|
+
import type { AgentAdapter } from "../adapter.js";
|
|
15
|
+
import type { TeammateConfig, TaskResult, SandboxLevel } from "../types.js";
|
|
16
|
+
export interface CodexAdapterOptions {
|
|
17
|
+
/** Codex model override (e.g. "o4-mini", "o3") */
|
|
18
|
+
model?: string;
|
|
19
|
+
/** Default sandbox level if teammate doesn't specify one */
|
|
20
|
+
defaultSandbox?: SandboxLevel;
|
|
21
|
+
/** Use --full-auto mode (default: true) */
|
|
22
|
+
fullAuto?: boolean;
|
|
23
|
+
/** Use --ephemeral to skip persisting session files (default: true) */
|
|
24
|
+
ephemeral?: boolean;
|
|
25
|
+
/** Additional CLI flags to pass to codex exec */
|
|
26
|
+
extraFlags?: string[];
|
|
27
|
+
/** Timeout in ms for codex exec (default: 300000 = 5 min) */
|
|
28
|
+
timeout?: number;
|
|
29
|
+
/** Path to codex binary (default: "codex") */
|
|
30
|
+
codexPath?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare class CodexAdapter implements AgentAdapter {
|
|
33
|
+
readonly name = "codex";
|
|
34
|
+
private options;
|
|
35
|
+
constructor(options?: CodexAdapterOptions);
|
|
36
|
+
startSession(teammate: TeammateConfig): Promise<string>;
|
|
37
|
+
executeTask(sessionId: string, teammate: TeammateConfig, prompt: string): Promise<TaskResult>;
|
|
38
|
+
/**
|
|
39
|
+
* Spawn `codex exec` and capture its output.
|
|
40
|
+
* Prompt is passed via a temp file read with shell substitution.
|
|
41
|
+
*/
|
|
42
|
+
private runCodex;
|
|
43
|
+
/** Build the argument list for codex exec */
|
|
44
|
+
private buildArgs;
|
|
45
|
+
/**
|
|
46
|
+
* Parse codex output into a TaskResult.
|
|
47
|
+
* Looks for changed files and handoff envelopes in the output.
|
|
48
|
+
*/
|
|
49
|
+
private parseResult;
|
|
50
|
+
}
|