@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/dist/cli.js ADDED
@@ -0,0 +1,1529 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @teammates/cli — Interactive teammate orchestrator.
4
+ *
5
+ * Start a session:
6
+ * teammates Launch interactive REPL
7
+ * teammates --adapter codex Use a specific agent adapter
8
+ * teammates --dir <path> Override .teammates/ location
9
+ */
10
+ import { createInterface } from "node:readline";
11
+ import { Writable } from "node:stream";
12
+ import { resolve, join } from "node:path";
13
+ import { stat, mkdir, readdir } from "node:fs/promises";
14
+ import { execSync, exec as execCb } from "node:child_process";
15
+ import { statSync, readdirSync } from "node:fs";
16
+ import { promisify } from "node:util";
17
+ const execAsync = promisify(execCb);
18
+ import chalk from "chalk";
19
+ import ora from "ora";
20
+ import { Orchestrator } from "./orchestrator.js";
21
+ import { EchoAdapter } from "./adapters/echo.js";
22
+ import { CliProxyAdapter, PRESETS } from "./adapters/cli-proxy.js";
23
+ import { Dropdown } from "./dropdown.js";
24
+ import { getOnboardingPrompt, copyTemplateFiles } from "./onboard.js";
25
+ // ─── Argument parsing ────────────────────────────────────────────────
26
+ const args = process.argv.slice(2);
27
+ function getFlag(name) {
28
+ const idx = args.indexOf(`--${name}`);
29
+ if (idx >= 0) {
30
+ args.splice(idx, 1);
31
+ return true;
32
+ }
33
+ return false;
34
+ }
35
+ function getOption(name) {
36
+ const idx = args.indexOf(`--${name}`);
37
+ if (idx >= 0 && idx + 1 < args.length) {
38
+ const val = args[idx + 1];
39
+ args.splice(idx, 2);
40
+ return val;
41
+ }
42
+ return undefined;
43
+ }
44
+ const showHelp = getFlag("help");
45
+ const modelOverride = getOption("model");
46
+ const dirOverride = getOption("dir");
47
+ // First remaining positional arg is the agent name (default: echo)
48
+ const adapterName = args.shift() ?? "echo";
49
+ // Everything left passes through to the agent CLI
50
+ const agentPassthrough = [...args];
51
+ args.length = 0;
52
+ // ─── Helpers ─────────────────────────────────────────────────────────
53
+ async function findTeammatesDir() {
54
+ if (dirOverride)
55
+ return resolve(dirOverride);
56
+ let dir = process.cwd();
57
+ while (true) {
58
+ const candidate = join(dir, ".teammates");
59
+ try {
60
+ const s = await stat(candidate);
61
+ if (s.isDirectory())
62
+ return candidate;
63
+ }
64
+ catch { /* keep looking */ }
65
+ const parent = resolve(dir, "..");
66
+ if (parent === dir)
67
+ break;
68
+ dir = parent;
69
+ }
70
+ return null;
71
+ }
72
+ function resolveAdapter(name) {
73
+ if (name === "echo")
74
+ return new EchoAdapter();
75
+ // All other adapters go through the CLI proxy
76
+ if (PRESETS[name]) {
77
+ return new CliProxyAdapter({
78
+ preset: name,
79
+ model: modelOverride,
80
+ extraFlags: agentPassthrough,
81
+ });
82
+ }
83
+ const available = ["echo", ...Object.keys(PRESETS)].join(", ");
84
+ console.error(chalk.red(`Unknown adapter: ${name}`));
85
+ console.error(`Available adapters: ${available}`);
86
+ process.exit(1);
87
+ }
88
+ function relativeTime(date) {
89
+ const diff = Date.now() - date.getTime();
90
+ const secs = Math.floor(diff / 1000);
91
+ if (secs < 60)
92
+ return `${secs}s ago`;
93
+ const mins = Math.floor(secs / 60);
94
+ if (mins < 60)
95
+ return `${mins}m ago`;
96
+ const hrs = Math.floor(mins / 60);
97
+ return `${hrs}h ago`;
98
+ }
99
+ const SERVICE_REGISTRY = {
100
+ recall: {
101
+ package: "@teammates/recall",
102
+ checkCmd: ["teammates-recall", "--help"],
103
+ indexCmd: ["teammates-recall", "index"],
104
+ description: "Local semantic search for teammate memory",
105
+ wireupTask: [
106
+ "The `teammates-recall` service was just installed globally.",
107
+ "Wire it up so every teammate knows it's available:",
108
+ "",
109
+ "1. Verify `teammates-recall --help` works. If it does, great. If not, figure out the correct path to the binary (check recall/package.json bin field) and note it.",
110
+ "2. Read .teammates/PROTOCOL.md and .teammates/CROSS-TEAM.md.",
111
+ "3. If recall is not already documented there, add a short section explaining that `teammates-recall` is now available for semantic memory search, with basic usage (e.g. `teammates-recall search \"query\"`).",
112
+ "4. Check each teammate's SOUL.md (under .teammates/*/SOUL.md). If a teammate's role involves memory or search, note in their SOUL.md that recall is installed and available.",
113
+ "5. Do NOT modify code files — only update .teammates/ markdown files.",
114
+ ].join("\n"),
115
+ },
116
+ };
117
+ // ─── REPL ────────────────────────────────────────────────────────────
118
+ class TeammatesREPL {
119
+ orchestrator;
120
+ adapter;
121
+ rl;
122
+ spinner = null;
123
+ commands = new Map();
124
+ lastResult = null;
125
+ lastResults = new Map();
126
+ conversationHistory = [];
127
+ storeResult(result) {
128
+ this.lastResult = result;
129
+ this.lastResults.set(result.teammate, result);
130
+ this.conversationHistory.push({
131
+ role: result.teammate,
132
+ text: result.rawOutput ?? result.summary,
133
+ });
134
+ }
135
+ buildConversationContext() {
136
+ if (this.conversationHistory.length === 0)
137
+ return "";
138
+ // Keep last 10 exchanges to avoid blowing up prompt size
139
+ const recent = this.conversationHistory.slice(-10);
140
+ const lines = ["## Conversation History\n"];
141
+ for (const entry of recent) {
142
+ lines.push(`**${entry.role}:** ${entry.text}\n`);
143
+ }
144
+ return lines.join("\n");
145
+ }
146
+ adapterName;
147
+ teammatesDir;
148
+ taskQueue = [];
149
+ queueActive = null;
150
+ queueDraining = false;
151
+ /** Mutex to prevent concurrent drainQueue invocations. Resolves when drain finishes. */
152
+ drainLock = null;
153
+ /** True while a task is being dispatched — prevents concurrent dispatches from pasted text. */
154
+ dispatching = false;
155
+ /** Stored pasted text keyed by paste number, expanded on Enter. */
156
+ pastedTexts = new Map();
157
+ dropdown;
158
+ wordwheelItems = [];
159
+ wordwheelIndex = -1; // -1 = no selection, 0+ = highlighted row
160
+ constructor(adapterName) {
161
+ this.adapterName = adapterName;
162
+ }
163
+ // ─── Onboarding ───────────────────────────────────────────────────
164
+ /**
165
+ * Interactive prompt when no .teammates/ directory is found.
166
+ * Returns the new .teammates/ path, or null if user chose to exit.
167
+ */
168
+ async promptOnboarding(adapter) {
169
+ const cwd = process.cwd();
170
+ const teammatesDir = join(cwd, ".teammates");
171
+ const termWidth = process.stdout.columns || 100;
172
+ console.log();
173
+ this.printLogo([
174
+ chalk.bold("Teammates") + chalk.gray(" v0.1.0"),
175
+ chalk.yellow("No .teammates/ directory found"),
176
+ chalk.gray(cwd),
177
+ ]);
178
+ console.log();
179
+ console.log(chalk.gray("─".repeat(termWidth)));
180
+ console.log();
181
+ console.log(chalk.white(" Set up teammates for this project?\n"));
182
+ console.log(chalk.cyan(" 1") + chalk.gray(") ") +
183
+ chalk.white("Run onboarding") +
184
+ chalk.gray(" — analyze this codebase and create .teammates/"));
185
+ console.log(chalk.cyan(" 2") + chalk.gray(") ") +
186
+ chalk.white("Solo mode") +
187
+ chalk.gray(` — use ${this.adapterName} without teammates`));
188
+ console.log(chalk.cyan(" 3") + chalk.gray(") ") +
189
+ chalk.white("Exit"));
190
+ console.log();
191
+ const choice = await this.askChoice("Pick an option (1/2/3): ", ["1", "2", "3"]);
192
+ if (choice === "3") {
193
+ console.log(chalk.gray(" Goodbye."));
194
+ return null;
195
+ }
196
+ if (choice === "2") {
197
+ await mkdir(teammatesDir, { recursive: true });
198
+ console.log();
199
+ console.log(chalk.green(" ✔") + chalk.gray(` Created ${teammatesDir}`));
200
+ console.log(chalk.gray(` Running in solo mode — all tasks go to ${this.adapterName}.`));
201
+ console.log(chalk.gray(" Run /init later to set up teammates."));
202
+ console.log();
203
+ return teammatesDir;
204
+ }
205
+ // choice === "1": Run onboarding via the agent
206
+ await mkdir(teammatesDir, { recursive: true });
207
+ await this.runOnboardingAgent(adapter, cwd);
208
+ return teammatesDir;
209
+ }
210
+ /**
211
+ * Run the onboarding agent to analyze the codebase and create teammates.
212
+ * Used by both promptOnboarding (pre-orchestrator) and cmdInit (post-orchestrator).
213
+ */
214
+ async runOnboardingAgent(adapter, projectDir) {
215
+ console.log();
216
+ console.log(chalk.blue(" Starting onboarding...") +
217
+ chalk.gray(` ${this.adapterName} will analyze your codebase and create .teammates/`));
218
+ console.log();
219
+ // Copy framework files from bundled template
220
+ const teammatesDir = join(projectDir, ".teammates");
221
+ const copied = await copyTemplateFiles(teammatesDir);
222
+ if (copied.length > 0) {
223
+ console.log(chalk.green(" ✔") + chalk.gray(` Copied template files: ${copied.join(", ")}`));
224
+ console.log();
225
+ }
226
+ const onboardingPrompt = await getOnboardingPrompt(projectDir);
227
+ const tempConfig = {
228
+ name: this.adapterName,
229
+ role: "Onboarding agent",
230
+ soul: "",
231
+ memories: "",
232
+ dailyLogs: [],
233
+ ownership: { primary: [], secondary: [] },
234
+ };
235
+ const sessionId = await adapter.startSession(tempConfig);
236
+ const spinner = ora({
237
+ text: chalk.blue(this.adapterName) + chalk.gray(" is analyzing your codebase..."),
238
+ spinner: "dots",
239
+ }).start();
240
+ try {
241
+ const result = await adapter.executeTask(sessionId, tempConfig, onboardingPrompt);
242
+ spinner.stop();
243
+ this.printAgentOutput(result.rawOutput);
244
+ if (result.success) {
245
+ console.log(chalk.green(" ✔ Onboarding complete!"));
246
+ }
247
+ else {
248
+ console.log(chalk.yellow(" ⚠ Onboarding finished with issues: " + result.summary));
249
+ }
250
+ }
251
+ catch (err) {
252
+ spinner.fail(chalk.red("Onboarding failed: " + err.message));
253
+ }
254
+ if (adapter.destroySession) {
255
+ await adapter.destroySession(sessionId);
256
+ }
257
+ // Verify .teammates/ now has content
258
+ try {
259
+ const entries = await readdir(teammatesDir);
260
+ if (!entries.some(e => !e.startsWith("."))) {
261
+ console.log(chalk.yellow(" ⚠ .teammates/ was created but appears empty."));
262
+ console.log(chalk.gray(" You may need to run the onboarding agent again or set up manually."));
263
+ }
264
+ }
265
+ catch { /* dir might not exist if onboarding failed badly */ }
266
+ console.log();
267
+ }
268
+ /**
269
+ * Simple blocking prompt — reads one line from stdin and validates.
270
+ */
271
+ askChoice(prompt, valid) {
272
+ return new Promise((resolve) => {
273
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
274
+ const ask = () => {
275
+ rl.question(chalk.cyan(" ") + prompt, (answer) => {
276
+ const trimmed = answer.trim();
277
+ if (valid.includes(trimmed)) {
278
+ rl.close();
279
+ resolve(trimmed);
280
+ }
281
+ else {
282
+ ask();
283
+ }
284
+ });
285
+ };
286
+ ask();
287
+ });
288
+ }
289
+ // ─── Display helpers ──────────────────────────────────────────────
290
+ /**
291
+ * Render the box logo with up to 4 info lines on the right side.
292
+ */
293
+ printLogo(infoLines) {
294
+ const pad = (i) => infoLines[i] ? " " + infoLines[i] : "";
295
+ console.log(chalk.cyan(" ▐▛▀▀▀▀▀▀▜▌") + pad(0));
296
+ console.log(chalk.cyan(" ▐▌") + " " + chalk.cyan("▐▌") + pad(1));
297
+ console.log(chalk.cyan(" ▐▌") + " 🧬 " + chalk.cyan("▐▌") + pad(2));
298
+ console.log(chalk.cyan(" ▐▌") + " " + chalk.cyan("▐▌") + pad(3));
299
+ console.log(chalk.cyan(" ▐▙▄▄▄▄▄▄▟▌"));
300
+ }
301
+ /**
302
+ * Print agent raw output, stripping the trailing JSON protocol block.
303
+ */
304
+ printAgentOutput(rawOutput) {
305
+ const raw = rawOutput ?? "";
306
+ if (!raw)
307
+ return;
308
+ const cleaned = raw.replace(/```json\s*\n\s*\{[\s\S]*?\}\s*\n\s*```\s*$/, "").trim();
309
+ if (cleaned) {
310
+ console.log(cleaned);
311
+ }
312
+ console.log();
313
+ }
314
+ // ─── Wordwheel ─────────────────────────────────────────────────────
315
+ getUniqueCommands() {
316
+ const seen = new Set();
317
+ const result = [];
318
+ for (const [, cmd] of this.commands) {
319
+ if (seen.has(cmd.name))
320
+ continue;
321
+ seen.add(cmd.name);
322
+ result.push(cmd);
323
+ }
324
+ return result;
325
+ }
326
+ clearWordwheel() {
327
+ this.dropdown.clear();
328
+ }
329
+ writeWordwheel(lines) {
330
+ this.dropdown.render(lines);
331
+ }
332
+ /**
333
+ * Which argument positions are teammate-name completable per command.
334
+ * Key = command name, value = set of 0-based arg positions that take a teammate.
335
+ */
336
+ static TEAMMATE_ARG_POSITIONS = {
337
+ assign: new Set([0]),
338
+ handoff: new Set([0, 1]),
339
+ log: new Set([0]),
340
+ };
341
+ /** Build param-completion items for the current line, if any. */
342
+ getParamItems(cmdName, argsBefore, partial) {
343
+ // Service-name completions for /install
344
+ if (cmdName === "install" && !argsBefore.trim()) {
345
+ const lower = partial.toLowerCase();
346
+ return Object.entries(SERVICE_REGISTRY)
347
+ .filter(([name]) => name.startsWith(lower))
348
+ .map(([name, svc]) => ({
349
+ label: name,
350
+ description: svc.description,
351
+ completion: "/install " + name + " ",
352
+ }));
353
+ }
354
+ const positions = TeammatesREPL.TEAMMATE_ARG_POSITIONS[cmdName];
355
+ if (!positions)
356
+ return [];
357
+ // Count how many complete args precede the current partial
358
+ const completedArgs = argsBefore.trim() ? argsBefore.trim().split(/\s+/).length : 0;
359
+ if (!positions.has(completedArgs))
360
+ return [];
361
+ const teammates = this.orchestrator.listTeammates();
362
+ const lower = partial.toLowerCase();
363
+ return teammates
364
+ .filter((n) => n.toLowerCase().startsWith(lower))
365
+ .map((name) => {
366
+ const t = this.orchestrator.getRegistry().get(name);
367
+ const linePrefix = "/" + cmdName + " " + (argsBefore ? argsBefore : "");
368
+ return {
369
+ label: name,
370
+ description: t?.role ?? "",
371
+ completion: linePrefix + name + " ",
372
+ };
373
+ });
374
+ }
375
+ /**
376
+ * Find the @mention token the cursor is currently inside, if any.
377
+ * Returns { before, partial, atPos } or null.
378
+ */
379
+ findAtMention(line, cursor) {
380
+ // Walk backward from cursor to find the nearest unescaped '@'
381
+ const left = line.slice(0, cursor);
382
+ const atPos = left.lastIndexOf("@");
383
+ if (atPos < 0)
384
+ return null;
385
+ // '@' must be at start of line or preceded by whitespace
386
+ if (atPos > 0 && !/\s/.test(line[atPos - 1]))
387
+ return null;
388
+ const partial = left.slice(atPos + 1);
389
+ // Partial must be a single token (no spaces)
390
+ if (/\s/.test(partial))
391
+ return null;
392
+ return { before: line.slice(0, atPos), partial, atPos };
393
+ }
394
+ /** Build @mention teammate completion items. */
395
+ getAtMentionItems(line, before, partial, atPos) {
396
+ const teammates = this.orchestrator.listTeammates();
397
+ const lower = partial.toLowerCase();
398
+ const after = line.slice(atPos + 1 + partial.length);
399
+ return teammates
400
+ .filter((n) => n.toLowerCase().startsWith(lower))
401
+ .map((name) => {
402
+ const t = this.orchestrator.getRegistry().get(name);
403
+ return {
404
+ label: "@" + name,
405
+ description: t?.role ?? "",
406
+ completion: before + "@" + name + " " + after.replace(/^\s+/, ""),
407
+ };
408
+ });
409
+ }
410
+ /** Recompute matches and draw the wordwheel. */
411
+ updateWordwheel() {
412
+ this.clearWordwheel();
413
+ const line = this.rl.line ?? "";
414
+ const cursor = this.rl.cursor ?? line.length;
415
+ // ── @mention anywhere in the line ──────────────────────────────
416
+ const mention = this.findAtMention(line, cursor);
417
+ if (mention) {
418
+ this.wordwheelItems = this.getAtMentionItems(line, mention.before, mention.partial, mention.atPos);
419
+ if (this.wordwheelItems.length > 0) {
420
+ if (this.wordwheelIndex >= this.wordwheelItems.length) {
421
+ this.wordwheelIndex = this.wordwheelItems.length - 1;
422
+ }
423
+ this.renderItems();
424
+ return;
425
+ }
426
+ }
427
+ // ── /command completion ─────────────────────────────────────────
428
+ if (!line.startsWith("/") || line.length < 2) {
429
+ this.wordwheelItems = [];
430
+ this.wordwheelIndex = -1;
431
+ return;
432
+ }
433
+ const spaceIdx = line.indexOf(" ");
434
+ if (spaceIdx > 0) {
435
+ // Command is known — check for param completions
436
+ const cmdName = line.slice(1, spaceIdx);
437
+ const cmd = this.commands.get(cmdName);
438
+ if (!cmd) {
439
+ this.wordwheelItems = [];
440
+ this.wordwheelIndex = -1;
441
+ return;
442
+ }
443
+ const afterCmd = line.slice(spaceIdx + 1);
444
+ // Split into completed args + current partial token
445
+ const lastSpace = afterCmd.lastIndexOf(" ");
446
+ const argsBefore = lastSpace >= 0 ? afterCmd.slice(0, lastSpace + 1) : "";
447
+ const partial = lastSpace >= 0 ? afterCmd.slice(lastSpace + 1) : afterCmd;
448
+ this.wordwheelItems = this.getParamItems(cmdName, argsBefore, partial);
449
+ if (this.wordwheelItems.length > 0) {
450
+ if (this.wordwheelIndex >= this.wordwheelItems.length) {
451
+ this.wordwheelIndex = this.wordwheelItems.length - 1;
452
+ }
453
+ this.renderItems();
454
+ }
455
+ else {
456
+ // No param completions — show static usage hint
457
+ this.wordwheelIndex = -1;
458
+ this.writeWordwheel([
459
+ ` ${chalk.cyan(cmd.usage)}`,
460
+ ` ${chalk.gray(cmd.description)}`,
461
+ ]);
462
+ }
463
+ return;
464
+ }
465
+ // Partial command — find matching commands
466
+ const partial = line.slice(1).toLowerCase();
467
+ this.wordwheelItems = this.getUniqueCommands()
468
+ .filter((c) => c.name.startsWith(partial) ||
469
+ c.aliases.some((a) => a.startsWith(partial)))
470
+ .map((c) => ({
471
+ label: "/" + c.name,
472
+ description: c.description,
473
+ completion: "/" + c.name + " ",
474
+ }));
475
+ if (this.wordwheelItems.length === 0) {
476
+ this.wordwheelIndex = -1;
477
+ return;
478
+ }
479
+ if (this.wordwheelIndex >= this.wordwheelItems.length) {
480
+ this.wordwheelIndex = this.wordwheelItems.length - 1;
481
+ }
482
+ this.renderItems();
483
+ }
484
+ /** Render the current wordwheelItems list with selection highlight. */
485
+ renderItems() {
486
+ this.writeWordwheel(this.wordwheelItems.map((item, i) => {
487
+ const prefix = i === this.wordwheelIndex ? chalk.cyan("▸ ") : " ";
488
+ const label = item.label.padEnd(14);
489
+ if (i === this.wordwheelIndex) {
490
+ return prefix + chalk.cyanBright.bold(label) + " " + chalk.white(item.description);
491
+ }
492
+ return prefix + chalk.cyan(label) + " " + chalk.gray(item.description);
493
+ }));
494
+ }
495
+ /** Accept the currently highlighted item into the input line. */
496
+ acceptWordwheelSelection() {
497
+ const item = this.wordwheelItems[this.wordwheelIndex];
498
+ if (!item)
499
+ return;
500
+ this.clearWordwheel();
501
+ this.rl.line = item.completion;
502
+ this.rl.cursor = item.completion.length;
503
+ this.rl._refreshLine();
504
+ this.wordwheelItems = [];
505
+ this.wordwheelIndex = -1;
506
+ // Re-render for next param or usage hint
507
+ this.updateWordwheel();
508
+ }
509
+ // ─── Lifecycle ────────────────────────────────────────────────────
510
+ async start() {
511
+ let teammatesDir = await findTeammatesDir();
512
+ const adapter = resolveAdapter(this.adapterName);
513
+ this.adapter = adapter;
514
+ // No .teammates/ found — offer onboarding or solo mode
515
+ if (!teammatesDir) {
516
+ teammatesDir = await this.promptOnboarding(adapter);
517
+ if (!teammatesDir)
518
+ return; // user chose to exit
519
+ }
520
+ // Init orchestrator
521
+ this.teammatesDir = teammatesDir;
522
+ this.orchestrator = new Orchestrator({
523
+ teammatesDir,
524
+ adapter,
525
+ onEvent: (e) => this.handleEvent(e),
526
+ });
527
+ await this.orchestrator.init();
528
+ // Register the agent itself as a mentionable teammate
529
+ const registry = this.orchestrator.getRegistry();
530
+ registry.register({
531
+ name: this.adapterName,
532
+ role: `General-purpose coding agent (${this.adapterName})`,
533
+ soul: "",
534
+ memories: "",
535
+ dailyLogs: [],
536
+ ownership: { primary: [], secondary: [] },
537
+ });
538
+ // Add status entry (init() already ran, so we add it manually)
539
+ this.orchestrator.getAllStatuses().set(this.adapterName, { state: "idle" });
540
+ // Populate roster on the adapter so prompts include team info
541
+ if ("roster" in this.adapter) {
542
+ const registry = this.orchestrator.getRegistry();
543
+ this.adapter.roster = this.orchestrator.listTeammates().map((name) => {
544
+ const t = registry.get(name);
545
+ return { name: t.name, role: t.role, ownership: t.ownership };
546
+ });
547
+ }
548
+ // Detect installed services and tell the adapter
549
+ if ("services" in this.adapter) {
550
+ const services = [];
551
+ // Check if any teammate has a .index/ directory (recall is indexed)
552
+ try {
553
+ const entries = readdirSync(this.teammatesDir, { withFileTypes: true });
554
+ const hasIndex = entries.some((e) => {
555
+ if (!e.isDirectory() || e.name.startsWith("."))
556
+ return false;
557
+ try {
558
+ return statSync(join(this.teammatesDir, e.name, ".index")).isDirectory();
559
+ }
560
+ catch {
561
+ return false;
562
+ }
563
+ });
564
+ if (hasIndex) {
565
+ services.push({
566
+ name: "recall",
567
+ description: "Local semantic search across teammate memories and daily logs. Use this to find relevant context before starting a task.",
568
+ usage: 'teammates-recall search "your query" --dir .teammates',
569
+ });
570
+ }
571
+ }
572
+ catch { /* can't read teammates dir */ }
573
+ this.adapter.services = services;
574
+ }
575
+ // Register commands
576
+ this.registerCommands();
577
+ // Create readline with a mutable output stream so we can mute
578
+ // echo during paste detection.
579
+ let outputMuted = false;
580
+ const mutableOutput = new Writable({
581
+ write(chunk, _encoding, callback) {
582
+ if (!outputMuted)
583
+ process.stdout.write(chunk);
584
+ callback();
585
+ },
586
+ });
587
+ // Trick readline into thinking it's a real TTY
588
+ mutableOutput.columns = process.stdout.columns;
589
+ mutableOutput.rows = process.stdout.rows;
590
+ mutableOutput.isTTY = true;
591
+ mutableOutput.cursorTo = process.stdout.cursorTo?.bind(process.stdout);
592
+ mutableOutput.clearLine = process.stdout.clearLine?.bind(process.stdout);
593
+ mutableOutput.moveCursor = process.stdout.moveCursor?.bind(process.stdout);
594
+ mutableOutput.getWindowSize = () => [process.stdout.columns ?? 80, process.stdout.rows ?? 24];
595
+ process.stdout.on("resize", () => {
596
+ mutableOutput.columns = process.stdout.columns;
597
+ mutableOutput.rows = process.stdout.rows;
598
+ mutableOutput.emit("resize");
599
+ });
600
+ this.rl = createInterface({
601
+ input: process.stdin,
602
+ output: mutableOutput,
603
+ prompt: chalk.cyan("teammates") + chalk.gray("> "),
604
+ terminal: true,
605
+ });
606
+ this.dropdown = new Dropdown(this.rl);
607
+ // Pre-mute: if stdin delivers a chunk with multiple newlines (paste),
608
+ // mute output immediately BEFORE readline echoes anything.
609
+ process.stdin.prependListener("data", (chunk) => {
610
+ const str = chunk.toString();
611
+ if (str.includes("\n") && str.indexOf("\n") < str.length - 1) {
612
+ // Multiple lines in one chunk — it's a paste, mute now
613
+ outputMuted = true;
614
+ }
615
+ });
616
+ // Intercept all keypress via _ttyWrite so we can capture
617
+ // arrow-down / arrow-up / Tab for wordwheel navigation.
618
+ // Also used for paste prefix detection via timing heuristic.
619
+ let lastKeystrokeTime = 0;
620
+ const origTtyWrite = this.rl._ttyWrite.bind(this.rl);
621
+ this.rl._ttyWrite = (s, key) => {
622
+ // Timing-based paste prefix detection: if >50ms since last keystroke,
623
+ // this is a new input burst. Snapshot rl.line BEFORE readline processes
624
+ // this character — during a paste burst, characters arrive <5ms apart
625
+ // so the snapshot stays at the pre-paste value.
626
+ const now = Date.now();
627
+ if (now - lastKeystrokeTime > 50) {
628
+ prePastePrefix = this.rl.line ?? "";
629
+ }
630
+ lastKeystrokeTime = now;
631
+ const hasWheel = this.wordwheelItems.length > 0;
632
+ if (hasWheel && key) {
633
+ if (key.name === "down") {
634
+ this.wordwheelIndex = Math.min(this.wordwheelIndex + 1, this.wordwheelItems.length - 1);
635
+ this.renderItems(); // calls dropdown.render() → _refreshLine()
636
+ return;
637
+ }
638
+ if (key.name === "up") {
639
+ this.wordwheelIndex = Math.max(this.wordwheelIndex - 1, -1);
640
+ this.renderItems(); // calls dropdown.render() → _refreshLine()
641
+ return;
642
+ }
643
+ if (key.name === "tab" && this.wordwheelIndex >= 0) {
644
+ this.acceptWordwheelSelection();
645
+ return;
646
+ }
647
+ }
648
+ // Enter/return — if a wordwheel item is highlighted, accept it into the
649
+ // input line first. For no-arg commands this means a single Enter both
650
+ // populates and executes (e.g. arrow-down to /exit → Enter → exits).
651
+ if (key && key.name === "return") {
652
+ if (hasWheel && this.wordwheelIndex >= 0) {
653
+ const item = this.wordwheelItems[this.wordwheelIndex];
654
+ if (item) {
655
+ this.rl.line = item.completion;
656
+ this.rl.cursor = item.completion.length;
657
+ }
658
+ }
659
+ this.dropdown.clear();
660
+ this.wordwheelItems = [];
661
+ this.wordwheelIndex = -1;
662
+ // Force a refresh to erase dropdown, then let readline process Enter
663
+ this.rl._refreshLine();
664
+ origTtyWrite(s, key);
665
+ return;
666
+ }
667
+ // Any other key — clear dropdown, let readline handle keystroke,
668
+ // then recompute and render the new dropdown.
669
+ this.dropdown.clear();
670
+ this.wordwheelItems = [];
671
+ this.wordwheelIndex = -1;
672
+ origTtyWrite(s, key);
673
+ // origTtyWrite called _refreshLine which cleared old dropdown.
674
+ // Now compute new items and render (calls _refreshLine again with new suffix).
675
+ this.updateWordwheel();
676
+ };
677
+ // Banner
678
+ this.printBanner(this.orchestrator.listTeammates());
679
+ // REPL loop
680
+ this.rl.prompt();
681
+ // ── Paste detection ──────────────────────────────────────────────
682
+ // Strategy: the first `line` event echoes normally. We immediately
683
+ // mute output so subsequent pasted lines are invisible. After 30ms
684
+ // of quiet, we check: if only 1 line arrived it was normal typing
685
+ // (already echoed, good). If multiple lines arrived, we erase the
686
+ // one echoed line and show a placeholder instead.
687
+ let pasteBuffer = [];
688
+ let pasteTimer = null;
689
+ let pasteCount = 0;
690
+ let prePastePrefix = ""; // text user typed before paste started
691
+ const processPaste = async () => {
692
+ pasteTimer = null;
693
+ outputMuted = false;
694
+ const lines = pasteBuffer;
695
+ pasteBuffer = [];
696
+ if (lines.length === 0)
697
+ return;
698
+ if (lines.length > 1) {
699
+ // Multi-line paste — the first line was echoed, the rest were muted.
700
+ // Erase the first echoed line (move up 1, clear).
701
+ process.stdout.write("\x1b[A\x1b[2K");
702
+ pasteCount++;
703
+ const combined = lines.join("\n");
704
+ const sizeKB = Buffer.byteLength(combined, "utf-8") / 1024;
705
+ const tag = `[Pasted text #${pasteCount} +${lines.length} lines, ${sizeKB.toFixed(1)}KB] `;
706
+ // Store the pasted text — expanded when the user presses Enter.
707
+ this.pastedTexts.set(pasteCount, combined);
708
+ // Restore what the user typed before the paste, plus the placeholder.
709
+ const newLine = prePastePrefix + tag;
710
+ prePastePrefix = ""; // reset for next paste
711
+ this.rl.line = newLine;
712
+ this.rl.cursor = newLine.length;
713
+ this.rl.prompt(true);
714
+ return;
715
+ }
716
+ // Expand paste placeholders with actual content
717
+ const rawLine = lines[0];
718
+ const hasPaste = /\[Pasted text #\d+/.test(rawLine);
719
+ let input = rawLine.replace(/\[Pasted text #(\d+) \+\d+ lines, [\d.]+KB\]\s*/g, (_match, num) => {
720
+ const n = parseInt(num, 10);
721
+ const text = this.pastedTexts.get(n);
722
+ if (text) {
723
+ this.pastedTexts.delete(n);
724
+ return text + "\n";
725
+ }
726
+ return "";
727
+ }).trim();
728
+ // Show the expanded pasted content on Enter
729
+ if (hasPaste && input) {
730
+ const sizeKB = Buffer.byteLength(input, "utf-8") / 1024;
731
+ const lineCount = input.split("\n").length;
732
+ console.log();
733
+ console.log(chalk.gray(` ┌ Expanded paste (${lineCount} lines, ${sizeKB.toFixed(1)}KB)`));
734
+ // Show first few lines as preview
735
+ const previewLines = input.split("\n").slice(0, 5);
736
+ for (const l of previewLines) {
737
+ console.log(chalk.gray(` │ `) + l.slice(0, 120));
738
+ }
739
+ if (lineCount > 5) {
740
+ console.log(chalk.gray(` │ ... ${lineCount - 5} more lines`));
741
+ }
742
+ console.log(chalk.gray(` └`));
743
+ }
744
+ if (!input || this.dispatching) {
745
+ this.rl.prompt();
746
+ return;
747
+ }
748
+ if (!input.startsWith("/")) {
749
+ this.conversationHistory.push({ role: "user", text: input });
750
+ }
751
+ this.dispatching = true;
752
+ try {
753
+ await this.dispatch(input);
754
+ }
755
+ catch (err) {
756
+ console.log(chalk.red(`Error: ${err.message}`));
757
+ }
758
+ finally {
759
+ this.dispatching = false;
760
+ }
761
+ this.rl.prompt();
762
+ };
763
+ this.rl.on("line", (line) => {
764
+ this.dropdown.clear();
765
+ this.wordwheelItems = [];
766
+ this.wordwheelIndex = -1;
767
+ pasteBuffer.push(line);
768
+ // After the first line, mute readline output so subsequent
769
+ // pasted lines don't echo to the terminal.
770
+ if (pasteBuffer.length === 1) {
771
+ outputMuted = true;
772
+ }
773
+ if (pasteTimer)
774
+ clearTimeout(pasteTimer);
775
+ pasteTimer = setTimeout(processPaste, 30);
776
+ });
777
+ this.rl.on("close", async () => {
778
+ this.clearWordwheel();
779
+ console.log(chalk.gray("\nShutting down..."));
780
+ await this.orchestrator.shutdown();
781
+ process.exit(0);
782
+ });
783
+ }
784
+ printBanner(teammates) {
785
+ const registry = this.orchestrator.getRegistry();
786
+ const termWidth = process.stdout.columns || 100;
787
+ const divider = chalk.gray("─".repeat(termWidth));
788
+ // Detect recall — check for .index/ inside any teammate folder
789
+ let recallInstalled = false;
790
+ try {
791
+ const entries = readdirSync(this.teammatesDir, { withFileTypes: true });
792
+ for (const entry of entries) {
793
+ if (!entry.isDirectory() || entry.name.startsWith("."))
794
+ continue;
795
+ try {
796
+ const s = statSync(join(this.teammatesDir, entry.name, ".index"));
797
+ if (s.isDirectory()) {
798
+ recallInstalled = true;
799
+ break;
800
+ }
801
+ }
802
+ catch { /* no index for this teammate */ }
803
+ }
804
+ }
805
+ catch { /* can't read teammates dir */ }
806
+ console.log();
807
+ this.printLogo([
808
+ chalk.bold("Teammates") + chalk.gray(" v0.1.0"),
809
+ chalk.white(this.adapterName) +
810
+ chalk.gray(` · ${teammates.length} teammate${teammates.length === 1 ? "" : "s"}`),
811
+ chalk.gray(process.cwd()),
812
+ recallInstalled
813
+ ? chalk.green("● recall") + chalk.gray(" installed")
814
+ : chalk.yellow("○ recall") + chalk.gray(" not installed"),
815
+ ]);
816
+ // Roster
817
+ console.log();
818
+ for (const name of teammates) {
819
+ const t = registry.get(name);
820
+ if (t) {
821
+ console.log(chalk.gray(" ") +
822
+ chalk.cyan("●") +
823
+ chalk.cyan(` @${name}`.padEnd(14)) +
824
+ chalk.gray(t.role));
825
+ }
826
+ }
827
+ console.log();
828
+ console.log(divider);
829
+ // Quick reference — 3 columns
830
+ const col1 = [
831
+ ["@mention", "assign to teammate"],
832
+ ["text", "auto-route task"],
833
+ ["/queue", "queue tasks"],
834
+ ];
835
+ const col2 = [
836
+ ["/status", "session overview"],
837
+ ["/debug", "raw agent output"],
838
+ ["/log", "last task output"],
839
+ ];
840
+ const col3 = [
841
+ ["/install", "add a service"],
842
+ ["/help", "all commands"],
843
+ ["/exit", "exit session"],
844
+ ];
845
+ for (let i = 0; i < col1.length; i++) {
846
+ const c1 = chalk.cyan(col1[i][0].padEnd(12)) + chalk.gray(col1[i][1].padEnd(22));
847
+ const c2 = chalk.cyan(col2[i][0].padEnd(12)) + chalk.gray(col2[i][1].padEnd(22));
848
+ const c3 = chalk.cyan(col3[i][0].padEnd(12)) + chalk.gray(col3[i][1]);
849
+ console.log(` ${c1}${c2}${c3}`);
850
+ }
851
+ console.log();
852
+ console.log(divider);
853
+ }
854
+ registerCommands() {
855
+ const cmds = [
856
+ {
857
+ name: "status",
858
+ aliases: ["s"],
859
+ usage: "/status",
860
+ description: "Show teammate roster and session status",
861
+ run: () => this.cmdStatus(),
862
+ },
863
+ {
864
+ name: "teammates",
865
+ aliases: ["team", "t"],
866
+ usage: "/teammates",
867
+ description: "List all teammates and their roles",
868
+ run: () => this.cmdTeammates(),
869
+ },
870
+ {
871
+ name: "log",
872
+ aliases: ["l"],
873
+ usage: "/log [teammate]",
874
+ description: "Show the last task result for a teammate",
875
+ run: (args) => this.cmdLog(args),
876
+ },
877
+ {
878
+ name: "help",
879
+ aliases: ["h", "?"],
880
+ usage: "/help",
881
+ description: "Show available commands",
882
+ run: () => this.cmdHelp(),
883
+ },
884
+ {
885
+ name: "debug",
886
+ aliases: ["raw"],
887
+ usage: "/debug [teammate]",
888
+ description: "Show raw agent output from the last task",
889
+ run: (args) => this.cmdDebug(args),
890
+ },
891
+ {
892
+ name: "queue",
893
+ aliases: ["qu"],
894
+ usage: "/queue [@teammate] [task]",
895
+ description: "Add to queue, or show queue if no args",
896
+ run: (args) => this.cmdQueue(args),
897
+ },
898
+ {
899
+ name: "cancel",
900
+ aliases: [],
901
+ usage: "/cancel <n>",
902
+ description: "Cancel a queued task by number",
903
+ run: (args) => this.cmdCancel(args),
904
+ },
905
+ {
906
+ name: "init",
907
+ aliases: ["onboard", "setup"],
908
+ usage: "/init",
909
+ description: "Run onboarding to set up teammates for this project",
910
+ run: () => this.cmdInit(),
911
+ },
912
+ {
913
+ name: "clear",
914
+ aliases: ["cls", "reset"],
915
+ usage: "/clear",
916
+ description: "Clear history and reset the session",
917
+ run: () => this.cmdClear(),
918
+ },
919
+ {
920
+ name: "install",
921
+ aliases: [],
922
+ usage: "/install <service>",
923
+ description: "Install a teammates service (e.g. recall)",
924
+ run: (args) => this.cmdInstall(args),
925
+ },
926
+ {
927
+ name: "exit",
928
+ aliases: ["q", "quit"],
929
+ usage: "/exit",
930
+ description: "Exit the session",
931
+ run: async () => {
932
+ console.log(chalk.gray("Shutting down..."));
933
+ await this.orchestrator.shutdown();
934
+ process.exit(0);
935
+ },
936
+ },
937
+ ];
938
+ for (const cmd of cmds) {
939
+ this.commands.set(cmd.name, cmd);
940
+ for (const alias of cmd.aliases) {
941
+ this.commands.set(alias, cmd);
942
+ }
943
+ }
944
+ }
945
+ async dispatch(input) {
946
+ // Handle pending handoff menu (1/2/3)
947
+ if (this.orchestrator.getPendingHandoff()) {
948
+ const handled = await this.handleHandoffChoice(input);
949
+ if (handled)
950
+ return;
951
+ }
952
+ if (input.startsWith("/")) {
953
+ const spaceIdx = input.indexOf(" ");
954
+ const cmdName = spaceIdx > 0 ? input.slice(1, spaceIdx) : input.slice(1);
955
+ const cmdArgs = spaceIdx > 0 ? input.slice(spaceIdx + 1).trim() : "";
956
+ const cmd = this.commands.get(cmdName);
957
+ if (cmd) {
958
+ await cmd.run(cmdArgs);
959
+ }
960
+ else {
961
+ console.log(chalk.yellow(`Unknown command: /${cmdName}`));
962
+ console.log(chalk.gray("Type /help for available commands"));
963
+ }
964
+ }
965
+ else {
966
+ // Check for @mention — extract teammate and treat rest as task
967
+ const mentionMatch = input.match(/^@(\S+)\s+([\s\S]+)$/);
968
+ if (mentionMatch) {
969
+ const [, teammate, task] = mentionMatch;
970
+ const names = this.orchestrator.listTeammates();
971
+ if (names.includes(teammate)) {
972
+ await this.cmdAssign(`${teammate} ${task}`);
973
+ return;
974
+ }
975
+ }
976
+ // Also handle @mentions inline: strip @names and route to them
977
+ const inlineMention = input.match(/@(\S+)/);
978
+ if (inlineMention) {
979
+ const teammate = inlineMention[1];
980
+ const names = this.orchestrator.listTeammates();
981
+ if (names.includes(teammate)) {
982
+ const task = input.replace(/@\S+\s*/, "").trim();
983
+ if (task) {
984
+ await this.cmdAssign(`${teammate} ${task}`);
985
+ return;
986
+ }
987
+ }
988
+ }
989
+ // Bare text — auto-route
990
+ await this.cmdRoute(input);
991
+ }
992
+ }
993
+ // ─── Event handler ───────────────────────────────────────────────
994
+ handleEvent(event) {
995
+ // When queue is draining in background, never use spinner — it blocks the prompt
996
+ const useSpinner = !this.queueDraining;
997
+ switch (event.type) {
998
+ case "task_assigned":
999
+ if (useSpinner) {
1000
+ this.spinner = ora({
1001
+ text: chalk.blue(`${event.assignment.teammate}`) +
1002
+ chalk.gray(` is working on: ${event.assignment.task.slice(0, 60)}...`),
1003
+ spinner: "dots",
1004
+ }).start();
1005
+ }
1006
+ else if (!this.queueDraining) {
1007
+ console.log(chalk.blue(` ${event.assignment.teammate}`) +
1008
+ chalk.gray(` is working on: ${event.assignment.task.slice(0, 60)}...`));
1009
+ }
1010
+ break;
1011
+ case "task_completed":
1012
+ {
1013
+ if (this.spinner) {
1014
+ this.spinner.stop();
1015
+ this.spinner = null;
1016
+ }
1017
+ const raw = event.result.rawOutput ?? "";
1018
+ const cleaned = raw.replace(/```json\s*\n\s*\{[\s\S]*?\}\s*\n\s*```\s*$/, "").trim();
1019
+ const sizeKB = cleaned ? Buffer.byteLength(cleaned, "utf-8") / 1024 : 0;
1020
+ console.log();
1021
+ if (sizeKB > 5) {
1022
+ console.log(chalk.gray(" ─".repeat(40)));
1023
+ console.log(chalk.yellow(` ⚠ Response is ${sizeKB.toFixed(1)}KB — use /debug ${event.result.teammate} to view full output`));
1024
+ console.log(chalk.gray(" ─".repeat(40)));
1025
+ }
1026
+ else if (cleaned) {
1027
+ console.log(cleaned);
1028
+ }
1029
+ console.log();
1030
+ console.log(chalk.green(` ✔ ${event.result.teammate}`) +
1031
+ chalk.gray(": ") +
1032
+ event.result.summary);
1033
+ }
1034
+ break;
1035
+ case "handoff_initiated":
1036
+ if (this.spinner) {
1037
+ this.spinner.info(chalk.yellow("Handoff: ") +
1038
+ chalk.bold(event.envelope.from) +
1039
+ chalk.yellow(" → ") +
1040
+ chalk.bold(event.envelope.to));
1041
+ this.spinner = null;
1042
+ }
1043
+ this.printHandoffDetails(event.envelope);
1044
+ break;
1045
+ case "handoff_completed":
1046
+ // Already handled via task_completed
1047
+ break;
1048
+ case "error":
1049
+ if (this.spinner) {
1050
+ this.spinner.fail(chalk.red(event.teammate) + chalk.gray(": ") + event.error);
1051
+ this.spinner = null;
1052
+ }
1053
+ else {
1054
+ console.log(chalk.red(` ${event.teammate}: ${event.error}`));
1055
+ }
1056
+ break;
1057
+ }
1058
+ }
1059
+ printHandoffDetails(envelope) {
1060
+ console.log(chalk.gray(" ┌─────────────────────────────────────"));
1061
+ console.log(chalk.gray(" │ ") +
1062
+ chalk.white("Task: ") +
1063
+ envelope.task);
1064
+ if (envelope.changedFiles?.length) {
1065
+ console.log(chalk.gray(" │ ") +
1066
+ chalk.white("Files: ") +
1067
+ envelope.changedFiles.join(", "));
1068
+ }
1069
+ if (envelope.acceptanceCriteria?.length) {
1070
+ console.log(chalk.gray(" │ ") + chalk.white("Criteria:"));
1071
+ for (const c of envelope.acceptanceCriteria) {
1072
+ console.log(chalk.gray(" │ ") + chalk.gray("• ") + c);
1073
+ }
1074
+ }
1075
+ if (envelope.openQuestions?.length) {
1076
+ console.log(chalk.gray(" │ ") + chalk.white("Questions:"));
1077
+ for (const q of envelope.openQuestions) {
1078
+ console.log(chalk.gray(" │ ") + chalk.gray("? ") + q);
1079
+ }
1080
+ }
1081
+ console.log(chalk.gray(" └─────────────────────────────────────"));
1082
+ console.log();
1083
+ console.log(chalk.cyan(" 1") + chalk.gray(") Approve"));
1084
+ console.log(chalk.cyan(" 2") + chalk.gray(") Always approve handoffs"));
1085
+ console.log(chalk.cyan(" 3") + chalk.gray(") Reject"));
1086
+ console.log();
1087
+ }
1088
+ /** Handle the numbered handoff menu choice. */
1089
+ async handleHandoffChoice(choice) {
1090
+ const pending = this.orchestrator.getPendingHandoff();
1091
+ if (!pending)
1092
+ return false;
1093
+ switch (choice) {
1094
+ case "1": {
1095
+ this.orchestrator.clearPendingHandoff(pending.from);
1096
+ const result = await this.orchestrator.assign({
1097
+ teammate: pending.to,
1098
+ task: pending.task,
1099
+ handoff: pending,
1100
+ });
1101
+ this.storeResult(result);
1102
+ return true;
1103
+ }
1104
+ case "2": {
1105
+ this.orchestrator.requireApproval = false;
1106
+ this.orchestrator.clearPendingHandoff(pending.from);
1107
+ console.log(chalk.gray(" Auto-approving all future handoffs."));
1108
+ const result = await this.orchestrator.assign({
1109
+ teammate: pending.to,
1110
+ task: pending.task,
1111
+ handoff: pending,
1112
+ });
1113
+ this.storeResult(result);
1114
+ return true;
1115
+ }
1116
+ case "3": {
1117
+ this.orchestrator.clearPendingHandoff(pending.from);
1118
+ console.log(chalk.gray(` Rejected handoff from `) +
1119
+ chalk.bold(pending.from) +
1120
+ chalk.gray(" to ") +
1121
+ chalk.bold(pending.to));
1122
+ return true;
1123
+ }
1124
+ default:
1125
+ return false;
1126
+ }
1127
+ }
1128
+ // ─── Commands ────────────────────────────────────────────────────
1129
+ async cmdAssign(argsStr) {
1130
+ const parts = argsStr.match(/^(\S+)\s+(.+)$/);
1131
+ if (!parts) {
1132
+ console.log(chalk.yellow("Usage: /assign <teammate> <task...>"));
1133
+ return;
1134
+ }
1135
+ const [, teammate, task] = parts;
1136
+ // Pause readline so streamed agent output isn't garbled by the prompt
1137
+ const extraContext = this.buildConversationContext();
1138
+ const result = await this.orchestrator.assign({ teammate, task, extraContext: extraContext || undefined });
1139
+ this.storeResult(result);
1140
+ if (result.handoff && this.orchestrator.requireApproval) {
1141
+ // Handoff is pending — user was already prompted
1142
+ }
1143
+ }
1144
+ async cmdRoute(argsStr) {
1145
+ let match = this.orchestrator.route(argsStr);
1146
+ if (!match) {
1147
+ // Keyword routing didn't find a strong match — ask the agent
1148
+ match = await this.orchestrator.agentRoute(argsStr);
1149
+ }
1150
+ match = match ?? this.adapterName;
1151
+ console.log(chalk.gray(` Routed to: ${chalk.bold(match)}`));
1152
+ const extraContext = this.buildConversationContext();
1153
+ const result = await this.orchestrator.assign({ teammate: match, task: argsStr, extraContext: extraContext || undefined });
1154
+ this.storeResult(result);
1155
+ }
1156
+ async cmdStatus() {
1157
+ const statuses = this.orchestrator.getAllStatuses();
1158
+ const registry = this.orchestrator.getRegistry();
1159
+ console.log();
1160
+ console.log(chalk.bold(" Status"));
1161
+ console.log(chalk.gray(" " + "─".repeat(60)));
1162
+ for (const [name, status] of statuses) {
1163
+ const teammate = registry.get(name);
1164
+ const stateColor = status.state === "idle"
1165
+ ? chalk.gray
1166
+ : status.state === "working"
1167
+ ? chalk.blue
1168
+ : chalk.yellow;
1169
+ const stateLabel = stateColor(status.state.padEnd(16));
1170
+ const nameLabel = chalk.bold(name.padEnd(14));
1171
+ let detail = chalk.gray("—");
1172
+ if (status.lastSummary) {
1173
+ const time = status.lastTimestamp ? chalk.gray(` (${relativeTime(status.lastTimestamp)})`) : "";
1174
+ detail = chalk.white(status.lastSummary.slice(0, 50)) + time;
1175
+ }
1176
+ if (status.state === "pending-handoff" && status.pendingHandoff) {
1177
+ detail = chalk.yellow(`→ ${status.pendingHandoff.to}: ${status.pendingHandoff.task.slice(0, 40)}`);
1178
+ }
1179
+ console.log(` ${nameLabel} ${stateLabel} ${detail}`);
1180
+ }
1181
+ console.log();
1182
+ }
1183
+ async cmdTeammates() {
1184
+ const names = this.orchestrator.listTeammates();
1185
+ const registry = this.orchestrator.getRegistry();
1186
+ console.log();
1187
+ for (const name of names) {
1188
+ const t = registry.get(name);
1189
+ console.log(chalk.cyan(` @${name}`.padEnd(16)) +
1190
+ chalk.gray(t.role));
1191
+ if (t.ownership.primary.length > 0) {
1192
+ console.log(chalk.gray(" ") +
1193
+ chalk.gray("owns: ") +
1194
+ chalk.white(t.ownership.primary.join(", ")));
1195
+ }
1196
+ }
1197
+ console.log();
1198
+ }
1199
+ async cmdLog(argsStr) {
1200
+ const teammate = argsStr.trim();
1201
+ if (teammate) {
1202
+ // Show specific teammate's last result
1203
+ const status = this.orchestrator.getStatus(teammate);
1204
+ if (!status) {
1205
+ console.log(chalk.yellow(`Unknown teammate: ${teammate}`));
1206
+ return;
1207
+ }
1208
+ this.printTeammateLog(teammate, status);
1209
+ }
1210
+ else if (this.lastResult) {
1211
+ // Show last result globally
1212
+ const status = this.orchestrator.getStatus(this.lastResult.teammate);
1213
+ if (status)
1214
+ this.printTeammateLog(this.lastResult.teammate, status);
1215
+ }
1216
+ else {
1217
+ console.log(chalk.gray("No task results yet."));
1218
+ }
1219
+ }
1220
+ printTeammateLog(name, status) {
1221
+ console.log();
1222
+ console.log(chalk.bold(` ${name}`));
1223
+ if (status.lastSummary) {
1224
+ console.log(chalk.white(` Summary: `) + status.lastSummary);
1225
+ }
1226
+ if (status.lastChangedFiles?.length) {
1227
+ console.log(chalk.white(` Changed:`));
1228
+ for (const f of status.lastChangedFiles) {
1229
+ console.log(chalk.gray(` • `) + f);
1230
+ }
1231
+ }
1232
+ if (status.lastTimestamp) {
1233
+ console.log(chalk.gray(` Time: ${relativeTime(status.lastTimestamp)}`));
1234
+ }
1235
+ if (!status.lastSummary) {
1236
+ console.log(chalk.gray(" No task results yet."));
1237
+ }
1238
+ console.log();
1239
+ }
1240
+ async cmdDebug(argsStr) {
1241
+ const teammate = argsStr.trim();
1242
+ const result = teammate
1243
+ ? this.lastResults.get(teammate)
1244
+ : this.lastResult;
1245
+ if (!result?.rawOutput) {
1246
+ console.log(chalk.gray(" No raw output available." + (teammate ? "" : " Try: /debug <teammate>")));
1247
+ return;
1248
+ }
1249
+ console.log();
1250
+ console.log(chalk.gray(` ── raw output from ${result.teammate} ──`));
1251
+ console.log();
1252
+ console.log(result.rawOutput);
1253
+ console.log();
1254
+ console.log(chalk.gray(` ── end raw output ──`));
1255
+ console.log();
1256
+ }
1257
+ async cmdCancel(argsStr) {
1258
+ const n = parseInt(argsStr.trim(), 10);
1259
+ if (isNaN(n) || n < 1 || n > this.taskQueue.length) {
1260
+ if (this.taskQueue.length === 0) {
1261
+ console.log(chalk.gray(" Queue is empty."));
1262
+ }
1263
+ else {
1264
+ console.log(chalk.yellow(` Usage: /cancel <1-${this.taskQueue.length}>`));
1265
+ }
1266
+ return;
1267
+ }
1268
+ const removed = this.taskQueue.splice(n - 1, 1)[0];
1269
+ console.log(chalk.gray(" Cancelled: ") +
1270
+ chalk.cyan(`@${removed.teammate}`) +
1271
+ chalk.gray(" — ") +
1272
+ chalk.white(removed.task.slice(0, 60)));
1273
+ }
1274
+ async cmdQueue(argsStr) {
1275
+ if (!argsStr) {
1276
+ // Show queue
1277
+ if (this.taskQueue.length === 0 && !this.queueDraining) {
1278
+ console.log(chalk.gray(" Queue is empty."));
1279
+ return;
1280
+ }
1281
+ console.log();
1282
+ console.log(chalk.bold(" Task Queue") +
1283
+ (this.queueDraining ? chalk.blue(" (draining)") : ""));
1284
+ console.log(chalk.gray(" " + "─".repeat(50)));
1285
+ if (this.queueActive) {
1286
+ console.log(chalk.blue(" ▸ ") +
1287
+ chalk.cyan(`@${this.queueActive.teammate}`) +
1288
+ chalk.gray(" — ") +
1289
+ chalk.white(this.queueActive.task.length > 60 ? this.queueActive.task.slice(0, 57) + "..." : this.queueActive.task) +
1290
+ chalk.blue(" (running)"));
1291
+ }
1292
+ for (let i = 0; i < this.taskQueue.length; i++) {
1293
+ const entry = this.taskQueue[i];
1294
+ console.log(chalk.gray(` ${i + 1}. `) +
1295
+ chalk.cyan(`@${entry.teammate}`) +
1296
+ chalk.gray(" — ") +
1297
+ chalk.white(entry.task.length > 60 ? entry.task.slice(0, 57) + "..." : entry.task));
1298
+ }
1299
+ if (this.taskQueue.length > 0) {
1300
+ console.log(chalk.gray(" /cancel <n> to remove a task"));
1301
+ }
1302
+ console.log();
1303
+ return;
1304
+ }
1305
+ // Parse: @teammate task or teammate task
1306
+ const match = argsStr.match(/^@?(\S+)(?:\s+([\s\S]+))?$/);
1307
+ if (!match) {
1308
+ console.log(chalk.yellow(" Usage: /queue @teammate <task...>"));
1309
+ return;
1310
+ }
1311
+ const [, teammate, task] = match;
1312
+ const names = this.orchestrator.listTeammates();
1313
+ if (!names.includes(teammate)) {
1314
+ console.log(chalk.yellow(` Unknown teammate: ${teammate}`));
1315
+ return;
1316
+ }
1317
+ if (!task?.trim()) {
1318
+ console.log(chalk.yellow(` Missing task. Usage: /queue @${teammate} <task...>`));
1319
+ return;
1320
+ }
1321
+ this.taskQueue.push({ teammate, task: task.trim() });
1322
+ console.log();
1323
+ console.log(chalk.gray(" Queued: ") +
1324
+ chalk.cyan(`@${teammate}`) +
1325
+ chalk.gray(" — ") +
1326
+ chalk.white(task.trim().slice(0, 60)) +
1327
+ chalk.gray(` (${this.taskQueue.length} in queue)`));
1328
+ console.log(chalk.blue(` ${teammate}`) +
1329
+ chalk.gray(` is working on: ${task.trim().slice(0, 60)}...`));
1330
+ console.log();
1331
+ // Start draining if not already (mutex-protected)
1332
+ if (!this.drainLock) {
1333
+ this.drainLock = this.drainQueue().finally(() => { this.drainLock = null; });
1334
+ }
1335
+ }
1336
+ /** Drain the queue in the background — REPL stays responsive. Mutex via drainLock. */
1337
+ async drainQueue() {
1338
+ this.queueDraining = true;
1339
+ try {
1340
+ while (this.taskQueue.length > 0) {
1341
+ // If a handoff is pending, pause until it's resolved
1342
+ if (this.orchestrator.getPendingHandoff()) {
1343
+ await new Promise((resolve) => {
1344
+ const check = () => {
1345
+ if (!this.orchestrator.getPendingHandoff()) {
1346
+ resolve();
1347
+ }
1348
+ else {
1349
+ setTimeout(check, 500);
1350
+ }
1351
+ };
1352
+ setTimeout(check, 500);
1353
+ });
1354
+ continue;
1355
+ }
1356
+ const entry = this.taskQueue.shift();
1357
+ this.queueActive = entry;
1358
+ const extraContext = this.buildConversationContext();
1359
+ const result = await this.orchestrator.assign({
1360
+ teammate: entry.teammate,
1361
+ task: entry.task,
1362
+ extraContext: extraContext || undefined,
1363
+ });
1364
+ this.queueActive = null;
1365
+ this.storeResult(result);
1366
+ }
1367
+ console.log(chalk.green(" ✔ Queue complete."));
1368
+ this.rl.prompt();
1369
+ }
1370
+ finally {
1371
+ this.queueDraining = false;
1372
+ }
1373
+ }
1374
+ async cmdInit() {
1375
+ const cwd = process.cwd();
1376
+ await mkdir(join(cwd, ".teammates"), { recursive: true });
1377
+ await this.runOnboardingAgent(this.adapter, cwd);
1378
+ // Reload the registry to pick up newly created teammates
1379
+ await this.orchestrator.init();
1380
+ console.log(chalk.gray(" Run /teammates to see the roster."));
1381
+ }
1382
+ async cmdInstall(argsStr) {
1383
+ const serviceName = argsStr.trim().toLowerCase();
1384
+ if (!serviceName) {
1385
+ console.log(chalk.bold("\n Available services:"));
1386
+ for (const [name, svc] of Object.entries(SERVICE_REGISTRY)) {
1387
+ console.log(` ${chalk.cyan(name.padEnd(16))}${chalk.gray(svc.description)}`);
1388
+ }
1389
+ console.log();
1390
+ return;
1391
+ }
1392
+ const service = SERVICE_REGISTRY[serviceName];
1393
+ if (!service) {
1394
+ console.log(chalk.red(` Unknown service: ${serviceName}`));
1395
+ console.log(chalk.gray(` Available: ${Object.keys(SERVICE_REGISTRY).join(", ")}`));
1396
+ return;
1397
+ }
1398
+ // Install the package globally
1399
+ const spinner = ora({
1400
+ text: chalk.blue(serviceName) + chalk.gray(` installing ${service.package}...`),
1401
+ spinner: "dots",
1402
+ }).start();
1403
+ try {
1404
+ await execAsync(`npm install -g ${service.package}`, {
1405
+ timeout: 5 * 60 * 1000,
1406
+ });
1407
+ spinner.stop();
1408
+ }
1409
+ catch (err) {
1410
+ spinner.fail(chalk.red(`Install failed: ${err.message}`));
1411
+ return;
1412
+ }
1413
+ // Verify the binary works
1414
+ const checkCmdStr = service.checkCmd.join(" ");
1415
+ try {
1416
+ execSync(checkCmdStr, { stdio: "ignore" });
1417
+ }
1418
+ catch {
1419
+ console.log(chalk.green(` ✔ ${serviceName}`) + chalk.gray(" installed"));
1420
+ console.log(chalk.yellow(` ⚠ Restart your terminal to add ${service.checkCmd[0]} to your PATH, then run /install ${serviceName} again to build the index.`));
1421
+ return;
1422
+ }
1423
+ console.log(chalk.green(` ✔ ${serviceName}`) + chalk.gray(" installed successfully"));
1424
+ // Build initial index if this service supports it
1425
+ if (service.indexCmd) {
1426
+ const indexSpinner = ora({
1427
+ text: chalk.blue(serviceName) + chalk.gray(` building index...`),
1428
+ spinner: "dots",
1429
+ }).start();
1430
+ const indexCmdStr = service.indexCmd.join(" ");
1431
+ try {
1432
+ await execAsync(indexCmdStr, {
1433
+ cwd: resolve(this.teammatesDir, ".."),
1434
+ timeout: 5 * 60 * 1000,
1435
+ });
1436
+ indexSpinner.succeed(chalk.blue(serviceName) + chalk.gray(" index built"));
1437
+ }
1438
+ catch (err) {
1439
+ indexSpinner.warn(chalk.yellow(`Index build failed: ${err.message}`));
1440
+ }
1441
+ }
1442
+ // Ask the coding agent to wire the service into the project
1443
+ if (service.wireupTask) {
1444
+ console.log();
1445
+ console.log(chalk.gray(` Wiring up ${serviceName}...`));
1446
+ const result = await this.orchestrator.assign({
1447
+ teammate: this.adapterName,
1448
+ task: service.wireupTask,
1449
+ });
1450
+ this.storeResult(result);
1451
+ }
1452
+ }
1453
+ async cmdClear() {
1454
+ // Reset all session state
1455
+ this.conversationHistory.length = 0;
1456
+ this.lastResult = null;
1457
+ this.lastResults.clear();
1458
+ this.taskQueue.length = 0;
1459
+ this.queueActive = null;
1460
+ this.pastedTexts.clear();
1461
+ await this.orchestrator.reset();
1462
+ // Clear terminal and reprint banner
1463
+ process.stdout.write("\x1b[2J\x1b[H");
1464
+ this.printBanner(this.orchestrator.listTeammates());
1465
+ }
1466
+ async cmdHelp() {
1467
+ console.log();
1468
+ console.log(chalk.bold(" Commands"));
1469
+ console.log(chalk.gray(" " + "─".repeat(50)));
1470
+ // De-duplicate (aliases map to same command)
1471
+ const seen = new Set();
1472
+ for (const [, cmd] of this.commands) {
1473
+ if (seen.has(cmd.name))
1474
+ continue;
1475
+ seen.add(cmd.name);
1476
+ const aliases = cmd.aliases.length > 0
1477
+ ? chalk.gray(` (${cmd.aliases.map((a) => "/" + a).join(", ")})`)
1478
+ : "";
1479
+ console.log(` ${chalk.cyan(cmd.usage.padEnd(36))}${cmd.description}${aliases}`);
1480
+ }
1481
+ console.log();
1482
+ console.log(chalk.gray(" Tip: ") +
1483
+ chalk.white("Type text without / to auto-route to the best teammate"));
1484
+ console.log(chalk.gray(" Tip: ") +
1485
+ chalk.white("Press Tab to autocomplete commands and teammate names"));
1486
+ console.log();
1487
+ }
1488
+ }
1489
+ // ─── Usage (non-interactive) ─────────────────────────────────────────
1490
+ function printUsage() {
1491
+ console.log(`
1492
+ ${chalk.bold("@teammates/cli")} — Agent-agnostic teammate orchestrator
1493
+
1494
+ ${chalk.bold("Usage:")}
1495
+ teammates <agent> Launch session with an agent
1496
+ teammates claude Use Claude Code
1497
+ teammates codex Use OpenAI Codex
1498
+ teammates aider Use Aider
1499
+
1500
+ ${chalk.bold("Options:")}
1501
+ --model <model> Override the agent model
1502
+ --dir <path> Override .teammates/ location
1503
+
1504
+ ${chalk.bold("Agents:")}
1505
+ claude Claude Code CLI (requires 'claude' on PATH)
1506
+ codex OpenAI Codex CLI (requires 'codex' on PATH)
1507
+ aider Aider CLI (requires 'aider' on PATH)
1508
+ echo Test adapter — echoes prompts (no external agent)
1509
+
1510
+ ${chalk.bold("In-session:")}
1511
+ @teammate <task> Assign directly via @mention
1512
+ <text> Auto-route to the best teammate
1513
+ /status Session overview
1514
+ /help All commands
1515
+ `.trim());
1516
+ }
1517
+ // ─── Main ────────────────────────────────────────────────────────────
1518
+ async function main() {
1519
+ if (showHelp) {
1520
+ printUsage();
1521
+ process.exit(0);
1522
+ }
1523
+ const repl = new TeammatesREPL(adapterName);
1524
+ await repl.start();
1525
+ }
1526
+ main().catch((err) => {
1527
+ console.error(chalk.red(err.message));
1528
+ process.exit(1);
1529
+ });