@teammates/cli 0.1.0 → 0.2.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.
Files changed (76) hide show
  1. package/README.md +31 -22
  2. package/dist/adapter.d.ts +1 -1
  3. package/dist/adapter.js +68 -56
  4. package/dist/adapter.test.js +34 -21
  5. package/dist/adapters/cli-proxy.d.ts +11 -4
  6. package/dist/adapters/cli-proxy.js +176 -162
  7. package/dist/adapters/copilot.d.ts +50 -0
  8. package/dist/adapters/copilot.js +210 -0
  9. package/dist/adapters/echo.d.ts +2 -2
  10. package/dist/adapters/echo.js +2 -1
  11. package/dist/adapters/echo.test.js +4 -2
  12. package/dist/cli-utils.d.ts +21 -0
  13. package/dist/cli-utils.js +74 -0
  14. package/dist/cli-utils.test.d.ts +1 -0
  15. package/dist/cli-utils.test.js +179 -0
  16. package/dist/cli.js +3160 -961
  17. package/dist/compact.d.ts +39 -0
  18. package/dist/compact.js +269 -0
  19. package/dist/compact.test.d.ts +1 -0
  20. package/dist/compact.test.js +198 -0
  21. package/dist/console/ansi.d.ts +18 -0
  22. package/dist/console/ansi.js +20 -0
  23. package/dist/console/ansi.test.d.ts +1 -0
  24. package/dist/console/ansi.test.js +50 -0
  25. package/dist/console/dropdown.d.ts +23 -0
  26. package/dist/console/dropdown.js +63 -0
  27. package/dist/console/file-drop.d.ts +59 -0
  28. package/dist/console/file-drop.js +186 -0
  29. package/dist/console/file-drop.test.d.ts +1 -0
  30. package/dist/console/file-drop.test.js +145 -0
  31. package/dist/console/index.d.ts +22 -0
  32. package/dist/console/index.js +23 -0
  33. package/dist/console/interactive-readline.d.ts +65 -0
  34. package/dist/console/interactive-readline.js +132 -0
  35. package/dist/console/markdown-table.d.ts +17 -0
  36. package/dist/console/markdown-table.js +270 -0
  37. package/dist/console/markdown-table.test.d.ts +1 -0
  38. package/dist/console/markdown-table.test.js +130 -0
  39. package/dist/console/mutable-output.d.ts +21 -0
  40. package/dist/console/mutable-output.js +51 -0
  41. package/dist/console/paste-handler.d.ts +63 -0
  42. package/dist/console/paste-handler.js +177 -0
  43. package/dist/console/prompt-box.d.ts +55 -0
  44. package/dist/console/prompt-box.js +120 -0
  45. package/dist/console/prompt-input.d.ts +136 -0
  46. package/dist/console/prompt-input.js +618 -0
  47. package/dist/console/startup.d.ts +20 -0
  48. package/dist/console/startup.js +138 -0
  49. package/dist/console/startup.test.d.ts +1 -0
  50. package/dist/console/startup.test.js +41 -0
  51. package/dist/console/wordwheel.d.ts +75 -0
  52. package/dist/console/wordwheel.js +123 -0
  53. package/dist/dropdown.js +4 -21
  54. package/dist/index.d.ts +5 -5
  55. package/dist/index.js +3 -3
  56. package/dist/onboard.d.ts +24 -0
  57. package/dist/onboard.js +174 -11
  58. package/dist/orchestrator.d.ts +8 -11
  59. package/dist/orchestrator.js +33 -81
  60. package/dist/orchestrator.test.js +59 -79
  61. package/dist/registry.d.ts +1 -1
  62. package/dist/registry.js +56 -12
  63. package/dist/registry.test.js +57 -13
  64. package/dist/theme.d.ts +56 -0
  65. package/dist/theme.js +54 -0
  66. package/dist/types.d.ts +18 -13
  67. package/package.json +8 -3
  68. package/template/CROSS-TEAM.md +2 -2
  69. package/template/PROTOCOL.md +72 -15
  70. package/template/README.md +2 -2
  71. package/template/TEMPLATE.md +118 -15
  72. package/template/example/SOUL.md +2 -1
  73. package/template/example/WISDOM.md +9 -0
  74. package/dist/adapters/codex.d.ts +0 -50
  75. package/dist/adapters/codex.js +0 -213
  76. package/template/example/MEMORIES.md +0 -26
package/dist/cli.js CHANGED
@@ -7,21 +7,37 @@
7
7
  * teammates --adapter codex Use a specific agent adapter
8
8
  * teammates --dir <path> Override .teammates/ location
9
9
  */
10
+ import { spawn as cpSpawn, exec as execCb, execSync, } from "node:child_process";
11
+ import { readFileSync, writeFileSync } from "node:fs";
12
+ import { mkdir, readdir, rm, stat, unlink } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join, resolve } from "node:path";
10
15
  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
16
  import { promisify } from "node:util";
17
17
  const execAsync = promisify(execCb);
18
+ import { App, ChatView, Control, concat, esc, Interview, pen, renderMarkdown, StyledText, stripAnsi, } from "@teammates/consolonia";
18
19
  import chalk from "chalk";
19
20
  import ora from "ora";
20
- import { Orchestrator } from "./orchestrator.js";
21
- import { EchoAdapter } from "./adapters/echo.js";
22
21
  import { CliProxyAdapter, PRESETS } from "./adapters/cli-proxy.js";
23
- import { Dropdown } from "./dropdown.js";
24
- import { getOnboardingPrompt, copyTemplateFiles } from "./onboard.js";
22
+ import { CopilotAdapter } from "./adapters/copilot.js";
23
+ import { EchoAdapter } from "./adapters/echo.js";
24
+ import { findAtMention, isImagePath, relativeTime, wrapLine, } from "./cli-utils.js";
25
+ import { compactEpisodic } from "./compact.js";
26
+ import { PromptInput } from "./console/prompt-input.js";
27
+ import { buildTitle } from "./console/startup.js";
28
+ import { buildAdaptationPrompt, copyTemplateFiles, getOnboardingPrompt, importTeammates, } from "./onboard.js";
29
+ import { Orchestrator } from "./orchestrator.js";
30
+ import { colorToHex, theme } from "./theme.js";
31
+ // ─── Version ─────────────────────────────────────────────────────────
32
+ const PKG_VERSION = (() => {
33
+ try {
34
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
35
+ return pkg.version ?? "0.0.0";
36
+ }
37
+ catch {
38
+ return "0.0.0";
39
+ }
40
+ })();
25
41
  // ─── Argument parsing ────────────────────────────────────────────────
26
42
  const args = process.argv.slice(2);
27
43
  function getFlag(name) {
@@ -61,7 +77,9 @@ async function findTeammatesDir() {
61
77
  if (s.isDirectory())
62
78
  return candidate;
63
79
  }
64
- catch { /* keep looking */ }
80
+ catch {
81
+ /* keep looking */
82
+ }
65
83
  const parent = resolve(dir, "..");
66
84
  if (parent === dir)
67
85
  break;
@@ -72,6 +90,12 @@ async function findTeammatesDir() {
72
90
  function resolveAdapter(name) {
73
91
  if (name === "echo")
74
92
  return new EchoAdapter();
93
+ // GitHub Copilot SDK adapter
94
+ if (name === "copilot") {
95
+ return new CopilotAdapter({
96
+ model: modelOverride,
97
+ });
98
+ }
75
99
  // All other adapters go through the CLI proxy
76
100
  if (PRESETS[name]) {
77
101
  return new CliProxyAdapter({
@@ -80,22 +104,11 @@ function resolveAdapter(name) {
80
104
  extraFlags: agentPassthrough,
81
105
  });
82
106
  }
83
- const available = ["echo", ...Object.keys(PRESETS)].join(", ");
107
+ const available = ["echo", "copilot", ...Object.keys(PRESETS)].join(", ");
84
108
  console.error(chalk.red(`Unknown adapter: ${name}`));
85
109
  console.error(`Available adapters: ${available}`);
86
110
  process.exit(1);
87
111
  }
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
112
  const SERVICE_REGISTRY = {
100
113
  recall: {
101
114
  package: "@teammates/recall",
@@ -108,18 +121,306 @@ const SERVICE_REGISTRY = {
108
121
  "",
109
122
  "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
123
  "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\"`).",
124
+ '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
125
  "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
126
  "5. Do NOT modify code files — only update .teammates/ markdown files.",
114
127
  ].join("\n"),
115
128
  },
116
129
  };
130
+ // WordwheelItem is now DropdownItem from @teammates/consolonia
131
+ // ── Themed pen shortcuts ────────────────────────────────────────────
132
+ //
133
+ // Thin wrappers that read from the active theme() at call time, so
134
+ // every styled span picks up the current palette automatically.
135
+ const tp = {
136
+ accent: (s) => pen.fg(theme().accent)(s),
137
+ accentBright: (s) => pen.fg(theme().accentBright)(s),
138
+ accentDim: (s) => pen.fg(theme().accentDim)(s),
139
+ text: (s) => pen.fg(theme().text)(s),
140
+ muted: (s) => pen.fg(theme().textMuted)(s),
141
+ dim: (s) => pen.fg(theme().textDim)(s),
142
+ success: (s) => pen.fg(theme().success)(s),
143
+ warning: (s) => pen.fg(theme().warning)(s),
144
+ error: (s) => pen.fg(theme().error)(s),
145
+ info: (s) => pen.fg(theme().info)(s),
146
+ bold: (s) => pen.bold.fg(theme().text)(s),
147
+ };
148
+ /**
149
+ * Custom banner widget that plays a reveal animation inside the
150
+ * consolonia rendering loop (alternate screen already active).
151
+ *
152
+ * Phases:
153
+ * 1. Reveal "teammates" letter by letter in block font
154
+ * 2. Collapse to "TM" + stats panel
155
+ * 3. Fade in teammate roster
156
+ * 4. Fade in command reference
157
+ */
158
+ class AnimatedBanner extends Control {
159
+ _lines = [];
160
+ _info;
161
+ _phase = "idle";
162
+ _inner;
163
+ _timer = null;
164
+ _onDirty = null;
165
+ // Spelling state
166
+ _word = "teammates";
167
+ _charIndex = 0;
168
+ _builtTop = "";
169
+ _builtBot = "";
170
+ _versionStr = ` v${PKG_VERSION}`;
171
+ _versionIndex = 0;
172
+ // Roster/command reveal state
173
+ _revealIndex = 0;
174
+ /** When true, the animation pauses after roster reveal (before commands). */
175
+ _held = false;
176
+ // The final lines (built once, revealed progressively)
177
+ _finalLines = [];
178
+ // Line index where roster starts and commands start
179
+ _rosterStart = 0;
180
+ _commandsStart = 0;
181
+ static GLYPHS = {
182
+ t: ["▀█▀", " █ "],
183
+ e: ["█▀▀", "██▄"],
184
+ a: ["▄▀█", "█▀█"],
185
+ m: ["█▀▄▀█", "█ ▀ █"],
186
+ s: ["█▀", "▄█"],
187
+ };
188
+ constructor(info) {
189
+ super();
190
+ this._info = info;
191
+ this._inner = new StyledText({ lines: [], wrap: true });
192
+ this.addChild(this._inner);
193
+ this._buildFinalLines();
194
+ }
195
+ /** Set a callback that fires when the banner needs a re-render. */
196
+ set onDirty(fn) {
197
+ this._onDirty = fn;
198
+ }
199
+ /** Start the animation sequence. */
200
+ start() {
201
+ this._phase = "spelling";
202
+ this._charIndex = 0;
203
+ this._builtTop = "";
204
+ this._builtBot = "";
205
+ this._tick();
206
+ }
207
+ _buildFinalLines() {
208
+ const info = this._info;
209
+ const [tmTop, tmBot] = buildTitle("tm");
210
+ const tmPad = " ".repeat(tmTop.length);
211
+ const gap = " ";
212
+ const lines = [];
213
+ // TM logo row 1 + adapter info
214
+ lines.push(concat(tp.accent(tmTop), tp.text(gap + info.adapterName), tp.muted(` · ${info.teammateCount} teammate${info.teammateCount === 1 ? "" : "s"}`), tp.muted(` · v${PKG_VERSION}`)));
215
+ // TM logo row 2 + cwd
216
+ lines.push(concat(tp.accent(tmBot), tp.muted(gap + info.cwd)));
217
+ // Recall status (indented to align with info above)
218
+ lines.push(info.recallInstalled
219
+ ? concat(tp.text(tmPad + gap), tp.success("● "), tp.success("recall"), tp.muted(" installed"))
220
+ : concat(tp.text(tmPad + gap), tp.warning("○ "), tp.warning("recall"), tp.muted(" not installed")));
221
+ // blank
222
+ lines.push("");
223
+ this._rosterStart = lines.length;
224
+ // Teammate roster
225
+ for (const t of info.teammates) {
226
+ lines.push(concat(tp.accent(" ● "), tp.accent(`@${t.name}`.padEnd(14)), tp.muted(t.role)));
227
+ }
228
+ // blank
229
+ lines.push("");
230
+ this._commandsStart = lines.length;
231
+ // Command reference (must match printBanner normal-mode layout)
232
+ const col1 = [
233
+ ["@mention", "assign to teammate"],
234
+ ["text", "auto-route task"],
235
+ ["[image]", "drag & drop images"],
236
+ ];
237
+ const col2 = [
238
+ ["/status", "teammates & queue"],
239
+ ["/compact", "compact memory"],
240
+ ["/retro", "run retrospective"],
241
+ ];
242
+ const col3 = [
243
+ [
244
+ info.recallInstalled ? "/copy" : "/install",
245
+ info.recallInstalled ? "copy session text" : "add a service",
246
+ ],
247
+ ["/help", "all commands"],
248
+ ["/exit", "exit session"],
249
+ ];
250
+ for (let i = 0; i < col1.length; i++) {
251
+ lines.push(concat(tp.accent(` ${col1[i][0].padEnd(12)}`), tp.muted(col1[i][1].padEnd(22)), tp.accent(col2[i][0].padEnd(12)), tp.muted(col2[i][1].padEnd(22)), tp.accent(col3[i][0].padEnd(12)), tp.muted(col3[i][1])));
252
+ }
253
+ this._finalLines = lines;
254
+ }
255
+ _tick() {
256
+ switch (this._phase) {
257
+ case "spelling": {
258
+ const ch = this._word[this._charIndex];
259
+ const g = AnimatedBanner.GLYPHS[ch];
260
+ if (g) {
261
+ if (this._builtTop.length > 0) {
262
+ this._builtTop += " ";
263
+ this._builtBot += " ";
264
+ }
265
+ this._builtTop += g[0];
266
+ this._builtBot += g[1];
267
+ }
268
+ this._lines = [
269
+ concat(tp.accent(this._builtTop)),
270
+ concat(tp.accent(this._builtBot)),
271
+ ];
272
+ this._apply();
273
+ this._charIndex++;
274
+ if (this._charIndex >= this._word.length) {
275
+ this._phase = "version";
276
+ this._versionIndex = 0;
277
+ this._schedule(60);
278
+ }
279
+ else {
280
+ this._schedule(60);
281
+ }
282
+ break;
283
+ }
284
+ case "version": {
285
+ // Type out version string character by character on the bottom row
286
+ this._versionIndex++;
287
+ const partial = this._versionStr.slice(0, this._versionIndex);
288
+ this._lines = [
289
+ concat(tp.accent(this._builtTop)),
290
+ concat(tp.accent(this._builtBot), tp.muted(partial)),
291
+ ];
292
+ this._apply();
293
+ if (this._versionIndex >= this._versionStr.length) {
294
+ this._phase = "pause";
295
+ this._schedule(600);
296
+ }
297
+ else {
298
+ this._schedule(60);
299
+ }
300
+ break;
301
+ }
302
+ case "pause": {
303
+ // Brief pause before transitioning to compact view
304
+ this._phase = "compact";
305
+ this._schedule(800);
306
+ break;
307
+ }
308
+ case "compact": {
309
+ // Switch to TM + stats — show first 4 lines of final
310
+ this._lines = this._finalLines.slice(0, 4);
311
+ this._apply();
312
+ this._phase = "roster";
313
+ this._revealIndex = 0;
314
+ this._schedule(80);
315
+ break;
316
+ }
317
+ case "roster": {
318
+ // Reveal roster lines one at a time
319
+ const end = this._rosterStart + this._revealIndex + 1;
320
+ this._lines = [
321
+ ...this._finalLines.slice(0, this._rosterStart),
322
+ ...this._finalLines.slice(this._rosterStart, end),
323
+ ];
324
+ this._apply();
325
+ this._revealIndex++;
326
+ const rosterCount = this._commandsStart - 1 - this._rosterStart; // -1 for blank line
327
+ if (this._revealIndex >= rosterCount) {
328
+ if (this._held) {
329
+ // Pause here until releaseHold() is called
330
+ this._phase = "roster-held";
331
+ }
332
+ else {
333
+ this._phase = "commands";
334
+ this._revealIndex = 0;
335
+ this._schedule(80);
336
+ }
337
+ }
338
+ else {
339
+ this._schedule(40);
340
+ }
341
+ break;
342
+ }
343
+ case "commands": {
344
+ // Add the blank line between roster and commands, then reveal commands
345
+ const rosterEnd = this._commandsStart; // includes the blank line
346
+ const cmdEnd = this._commandsStart + this._revealIndex + 1;
347
+ this._lines = [
348
+ ...this._finalLines.slice(0, rosterEnd),
349
+ ...this._finalLines.slice(this._commandsStart, cmdEnd),
350
+ ];
351
+ this._apply();
352
+ this._revealIndex++;
353
+ const cmdCount = this._finalLines.length - this._commandsStart;
354
+ if (this._revealIndex >= cmdCount) {
355
+ this._phase = "done";
356
+ }
357
+ else {
358
+ this._schedule(30);
359
+ }
360
+ break;
361
+ }
362
+ }
363
+ }
364
+ _apply() {
365
+ this._inner.lines = this._lines;
366
+ this.invalidate();
367
+ if (this._onDirty)
368
+ this._onDirty();
369
+ }
370
+ _schedule(ms) {
371
+ this._timer = setTimeout(() => {
372
+ this._timer = null;
373
+ this._tick();
374
+ }, ms);
375
+ }
376
+ /**
377
+ * Hold the animation — it will pause after the roster phase and
378
+ * not reveal the command reference until releaseHold() is called.
379
+ */
380
+ hold() {
381
+ this._held = true;
382
+ }
383
+ /**
384
+ * Release the hold and continue to the commands phase.
385
+ * If the animation already reached the hold point, it resumes immediately.
386
+ */
387
+ releaseHold() {
388
+ this._held = false;
389
+ // If we're waiting at the hold point, resume
390
+ if (this._phase === "roster-held") {
391
+ this._phase = "commands";
392
+ this._revealIndex = 0;
393
+ this._schedule(80);
394
+ }
395
+ }
396
+ /** Cancel any pending animation timer. */
397
+ dispose() {
398
+ if (this._timer) {
399
+ clearTimeout(this._timer);
400
+ this._timer = null;
401
+ }
402
+ }
403
+ // ── Layout delegation ───────────────────────────────────────────
404
+ measure(constraint) {
405
+ const size = this._inner.measure(constraint);
406
+ this.desiredSize = size;
407
+ return size;
408
+ }
409
+ arrange(rect) {
410
+ this.bounds = rect;
411
+ this._inner.arrange(rect);
412
+ }
413
+ render(ctx) {
414
+ this._inner.render(ctx);
415
+ }
416
+ }
117
417
  // ─── REPL ────────────────────────────────────────────────────────────
118
418
  class TeammatesREPL {
119
419
  orchestrator;
120
420
  adapter;
121
- rl;
122
- spinner = null;
421
+ input;
422
+ chatView;
423
+ app;
123
424
  commands = new Map();
124
425
  lastResult = null;
125
426
  lastResults = new Map();
@@ -145,199 +446,1242 @@ class TeammatesREPL {
145
446
  }
146
447
  adapterName;
147
448
  teammatesDir;
449
+ recallWatchProcess = null;
148
450
  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;
451
+ /** Per-agent active tasks — one per agent running in parallel. */
452
+ agentActive = new Map();
453
+ /** Per-agent drain locks prevents double-draining a single agent. */
454
+ agentDrainLocks = new Map();
155
455
  /** Stored pasted text keyed by paste number, expanded on Enter. */
156
456
  pastedTexts = new Map();
157
- dropdown;
457
+ pasteCounter = 0;
158
458
  wordwheelItems = [];
159
459
  wordwheelIndex = -1; // -1 = no selection, 0+ = highlighted row
460
+ escPending = false; // true after first ESC, waiting for second
461
+ escTimer = null;
462
+ ctrlcPending = false; // true after first Ctrl+C, waiting for second
463
+ ctrlcTimer = null;
464
+ lastCleanedOutput = ""; // last teammate output for clipboard copy
465
+ dispatching = false;
466
+ autoApproveHandoffs = false;
467
+ /** Pending handoffs awaiting user approval. */
468
+ pendingHandoffs = [];
469
+ /** Pending retro proposals awaiting user approval. */
470
+ pendingRetroProposals = [];
471
+ /** Maps reply action IDs to their context (teammate + message). */
472
+ _replyContexts = new Map();
473
+ /** Quoted reply text to expand on next submit. */
474
+ _pendingQuotedReply = null;
475
+ defaultFooter = null; // cached default footer content
476
+ // ── Animated status tracker ─────────────────────────────────────
477
+ activeTasks = new Map();
478
+ statusTimer = null;
479
+ statusFrame = 0;
480
+ statusRotateIndex = 0;
481
+ statusRotateTimer = null;
482
+ static SPINNER = [
483
+ "⠋",
484
+ "⠙",
485
+ "⠹",
486
+ "⠸",
487
+ "⠼",
488
+ "⠴",
489
+ "⠦",
490
+ "⠧",
491
+ "⠇",
492
+ "⠏",
493
+ ];
160
494
  constructor(adapterName) {
161
495
  this.adapterName = adapterName;
162
496
  }
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;
497
+ /** Show the prompt with the fenced border. */
498
+ showPrompt() {
499
+ if (this.chatView) {
500
+ // ChatView is always visible just refresh
501
+ this.app.refresh();
195
502
  }
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;
503
+ else {
504
+ this.input.activate();
204
505
  }
205
- // choice === "1": Run onboarding via the agent
206
- await mkdir(teammatesDir, { recursive: true });
207
- await this.runOnboardingAgent(adapter, cwd);
208
- return teammatesDir;
209
506
  }
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));
507
+ /** Start or update the animated status tracker above the prompt. */
508
+ startStatusAnimation() {
509
+ if (this.statusTimer)
510
+ return; // already running
511
+ this.statusFrame = 0;
512
+ this.statusRotateIndex = 0;
513
+ this.renderStatusFrame();
514
+ // Animate spinner at ~80ms
515
+ this.statusTimer = setInterval(() => {
516
+ this.statusFrame++;
517
+ this.renderStatusFrame();
518
+ }, 80);
519
+ // Rotate through teammates every 3 seconds
520
+ this.statusRotateTimer = setInterval(() => {
521
+ if (this.activeTasks.size > 1) {
522
+ this.statusRotateIndex =
523
+ (this.statusRotateIndex + 1) % this.activeTasks.size;
249
524
  }
525
+ }, 3000);
526
+ }
527
+ /** Stop the status animation and clear the status line. */
528
+ stopStatusAnimation() {
529
+ if (this.statusTimer) {
530
+ clearInterval(this.statusTimer);
531
+ this.statusTimer = null;
250
532
  }
251
- catch (err) {
252
- spinner.fail(chalk.red("Onboarding failed: " + err.message));
533
+ if (this.statusRotateTimer) {
534
+ clearInterval(this.statusRotateTimer);
535
+ this.statusRotateTimer = null;
253
536
  }
254
- if (adapter.destroySession) {
255
- await adapter.destroySession(sessionId);
537
+ if (this.chatView) {
538
+ this.chatView.setProgress(null);
539
+ this.app.refresh();
256
540
  }
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
- }
541
+ else {
542
+ this.input.setStatus(null);
543
+ }
544
+ }
545
+ /** Render one frame of the status animation. */
546
+ renderStatusFrame() {
547
+ if (this.activeTasks.size === 0)
548
+ return;
549
+ const entries = Array.from(this.activeTasks.values());
550
+ const idx = this.statusRotateIndex % entries.length;
551
+ const { teammate, task } = entries[idx];
552
+ const spinChar = TeammatesREPL.SPINNER[this.statusFrame % TeammatesREPL.SPINNER.length];
553
+ const taskPreview = task.length > 50 ? `${task.slice(0, 47)}...` : task;
554
+ const queueInfo = this.activeTasks.size > 1 ? ` (${idx + 1}/${this.activeTasks.size})` : "";
555
+ if (this.chatView) {
556
+ // Strip newlines and truncate task text for single-line display
557
+ const cleanTask = task.replace(/[\r\n]+/g, " ").trim();
558
+ const maxLen = Math.max(20, (process.stdout.columns || 80) - teammate.length - 10);
559
+ const taskText = cleanTask.length > maxLen
560
+ ? `${cleanTask.slice(0, maxLen - 1)}…`
561
+ : cleanTask;
562
+ const queueTag = this.activeTasks.size > 1
563
+ ? ` (${idx + 1}/${this.activeTasks.size})`
564
+ : "";
565
+ this.chatView.setProgress(concat(tp.accent(`${spinChar} ${teammate}… `), tp.muted(taskText + queueTag)));
566
+ this.app.refresh();
567
+ }
568
+ else {
569
+ // Mostly bright blue, periodically flicker to dark blue
570
+ const spinColor = this.statusFrame % 8 === 0 ? chalk.blue : chalk.blueBright;
571
+ const line = ` ${spinColor(spinChar)} ` +
572
+ chalk.bold(teammate) +
573
+ chalk.gray(`… ${taskPreview}`) +
574
+ (queueInfo ? chalk.gray(queueInfo) : "");
575
+ this.input.setStatus(line);
264
576
  }
265
- catch { /* dir might not exist if onboarding failed badly */ }
266
- console.log();
267
577
  }
268
578
  /**
269
- * Simple blocking prompt reads one line from stdin and validates.
579
+ * Print the user's message as an inverted block in the feed.
580
+ * White text on dark background, right-aligned indicator.
270
581
  */
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);
582
+ _userBg = { r: 25, g: 25, b: 25, a: 255 };
583
+ /** Feed a line with the user message background, padded to full width. */
584
+ feedUserLine(spans) {
585
+ if (!this.chatView)
586
+ return;
587
+ const termW = (process.stdout.columns || 80) - 1; // -1 for scrollbar
588
+ // Calculate visible length of spans
589
+ let len = 0;
590
+ for (const seg of spans)
591
+ len += seg.text.length;
592
+ const pad = Math.max(0, termW - len);
593
+ const padded = concat(spans, pen.fg(this._userBg).bg(this._userBg)(" ".repeat(pad)));
594
+ this.chatView.appendStyledToFeed(padded);
595
+ }
596
+ /** Word-wrap text to maxWidth, breaking at spaces. */
597
+ wrapLine(text, maxWidth) {
598
+ return wrapLine(text, maxWidth);
599
+ }
600
+ printUserMessage(text) {
601
+ if (this.chatView) {
602
+ const bg = this._userBg;
603
+ const t = theme();
604
+ const termW = (process.stdout.columns || 80) - 1; // -1 for scrollbar
605
+ const allLines = text.split("\n");
606
+ // Separate non-quote lines from blockquote lines (> prefix)
607
+ // Find contiguous blockquote regions and fence them with empty lines
608
+ const rendered = [];
609
+ let inQuote = false;
610
+ for (const line of allLines) {
611
+ const isQuote = line.startsWith("> ") || line === ">";
612
+ if (isQuote && !inQuote) {
613
+ rendered.push({ type: "text", content: "" }); // empty line before quotes
614
+ inQuote = true;
615
+ }
616
+ else if (!isQuote && inQuote) {
617
+ rendered.push({ type: "text", content: "" }); // empty line after quotes
618
+ inQuote = false;
619
+ }
620
+ if (isQuote) {
621
+ rendered.push({
622
+ type: "quote",
623
+ content: line.startsWith("> ") ? line.slice(2) : "",
624
+ });
625
+ }
626
+ else {
627
+ rendered.push({ type: "text", content: line });
628
+ }
629
+ }
630
+ // Render first line with "User: " label
631
+ const label = "user: ";
632
+ const first = rendered.shift();
633
+ if (first) {
634
+ if (first.type === "text") {
635
+ const firstWrapW = termW - label.length;
636
+ const firstWrapped = this.wrapLine(first.content, firstWrapW);
637
+ // First wrapped segment gets the label
638
+ const seg0 = firstWrapped.shift() ?? "";
639
+ const pad0 = Math.max(0, termW - label.length - seg0.length);
640
+ this.chatView.appendStyledToFeed(concat(pen.fg(t.accent).bg(bg)(label), pen.fg(t.text).bg(bg)(seg0 + " ".repeat(pad0))));
641
+ // Remaining wrapped segments are indented to align with content
642
+ for (const wl of firstWrapped) {
643
+ this.feedUserLine(concat(pen.fg(t.text).bg(bg)(wl)));
280
644
  }
281
- else {
282
- ask();
645
+ }
646
+ else {
647
+ // First line is a quote (unusual but handle it)
648
+ const pad = Math.max(0, termW - label.length);
649
+ this.chatView.appendStyledToFeed(concat(pen.fg(t.accent).bg(bg)(label + " ".repeat(pad))));
650
+ // Re-add to render as quote
651
+ rendered.unshift(first);
652
+ }
653
+ }
654
+ // Render remaining lines
655
+ for (const entry of rendered) {
656
+ if (entry.type === "quote") {
657
+ const prefix = "│ ";
658
+ const wrapWidth = termW - prefix.length;
659
+ const wrapped = this.wrapLine(entry.content, wrapWidth);
660
+ for (const wl of wrapped) {
661
+ const pad = Math.max(0, termW - prefix.length - wl.length);
662
+ this.chatView.appendStyledToFeed(concat(pen.fg(t.textDim).bg(bg)(prefix), pen.fg(t.textMuted).bg(bg)(wl + " ".repeat(pad))));
283
663
  }
284
- });
285
- };
286
- ask();
287
- });
664
+ }
665
+ else {
666
+ const wrapWidth = termW;
667
+ const wrapped = this.wrapLine(entry.content, wrapWidth);
668
+ for (const wl of wrapped) {
669
+ this.feedUserLine(concat(pen.fg(t.text).bg(bg)(wl)));
670
+ }
671
+ }
672
+ }
673
+ this.app.refresh();
674
+ return;
675
+ }
676
+ const termWidth = process.stdout.columns || 100;
677
+ const maxWidth = Math.min(termWidth - 4, 80);
678
+ const lines = text.split("\n");
679
+ console.log();
680
+ for (const line of lines) {
681
+ // Truncate long lines
682
+ const display = line.length > maxWidth ? `${line.slice(0, maxWidth - 1)}…` : line;
683
+ const padded = display + " ".repeat(Math.max(0, maxWidth - stripAnsi(display).length));
684
+ console.log(` ${chalk.bgGray.white(` ${padded} `)}`);
685
+ }
686
+ console.log();
288
687
  }
289
- // ─── Display helpers ──────────────────────────────────────────────
290
688
  /**
291
- * Render the box logo with up to 4 info lines on the right side.
689
+ * Route text input to the right teammate and queue it for execution.
690
+ * Returns immediately — the task runs in the background via drainQueue.
292
691
  */
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
692
  /**
302
- * Print agent raw output, stripping the trailing JSON protocol block.
693
+ * Write a line to the chat feed.
694
+ * Accepts a plain string or a StyledSpan for colored output.
303
695
  */
304
- printAgentOutput(rawOutput) {
305
- const raw = rawOutput ?? "";
306
- if (!raw)
696
+ feedLine(text = "") {
697
+ if (this.chatView) {
698
+ if (typeof text === "string") {
699
+ this.chatView.appendToFeed(text);
700
+ }
701
+ else {
702
+ this.chatView.appendStyledToFeed(text);
703
+ }
307
704
  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
705
  }
312
- console.log();
706
+ // Fallback: convert StyledSpan to plain text for console
707
+ if (typeof text !== "string") {
708
+ console.log(text.map((s) => s.text).join(""));
709
+ }
710
+ else {
711
+ console.log(text);
712
+ }
313
713
  }
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);
714
+ /** Render markdown text to the feed using the consolonia markdown widget. */
715
+ feedMarkdown(source) {
716
+ const t = theme();
717
+ const width = process.stdout.columns || 80;
718
+ const lines = renderMarkdown(source, {
719
+ width: width - 3, // -2 for indent, -1 for scrollbar
720
+ indent: " ",
721
+ theme: {
722
+ text: { fg: t.textMuted },
723
+ bold: { fg: t.text, bold: true },
724
+ italic: { fg: t.textMuted, italic: true },
725
+ boldItalic: { fg: t.text, bold: true, italic: true },
726
+ code: { fg: t.accentDim },
727
+ h1: { fg: t.accent, bold: true },
728
+ h2: { fg: t.accent, bold: true },
729
+ h3: { fg: t.accent },
730
+ codeBlockChrome: { fg: t.textDim },
731
+ codeBlock: { fg: t.success },
732
+ blockquote: { fg: t.textMuted, italic: true },
733
+ listMarker: { fg: t.accent },
734
+ tableBorder: { fg: t.textDim },
735
+ tableHeader: { fg: t.text, bold: true },
736
+ hr: { fg: t.textDim },
737
+ link: { fg: t.accent, underline: true },
738
+ linkUrl: { fg: t.textMuted },
739
+ strikethrough: { fg: t.textMuted, strikethrough: true },
740
+ checkbox: { fg: t.accent },
741
+ },
742
+ });
743
+ for (const line of lines) {
744
+ // Convert markdown Line (Seg[]) to StyledSpan, preserving all style flags
745
+ const styledSpan = line.map((seg) => ({
746
+ text: seg.text,
747
+ style: seg.style,
748
+ }));
749
+ styledSpan.__brand = "StyledSpan";
750
+ this.feedLine(styledSpan);
323
751
  }
324
- return result;
325
752
  }
326
- clearWordwheel() {
327
- this.dropdown.clear();
753
+ /** Render handoff blocks with approve/reject actions. */
754
+ /** Helper to create a branded StyledSpan from segments. */
755
+ makeSpan(...segs) {
756
+ const s = segs;
757
+ s.__brand = "StyledSpan";
758
+ return s;
328
759
  }
329
- writeWordwheel(lines) {
330
- this.dropdown.render(lines);
760
+ /** Word-wrap a string to fit within maxWidth. */
761
+ wordWrap(text, maxWidth) {
762
+ const words = text.split(" ");
763
+ const lines = [];
764
+ let current = "";
765
+ for (const word of words) {
766
+ if (current.length === 0) {
767
+ current = word;
768
+ }
769
+ else if (current.length + 1 + word.length <= maxWidth) {
770
+ current += ` ${word}`;
771
+ }
772
+ else {
773
+ lines.push(current);
774
+ current = word;
775
+ }
776
+ }
777
+ if (current)
778
+ lines.push(current);
779
+ return lines.length > 0 ? lines : [""];
331
780
  }
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
- };
781
+ renderHandoffs(_from, handoffs) {
782
+ const t = theme();
783
+ const names = this.orchestrator.listTeammates();
784
+ const avail = (process.stdout.columns || 80) - 4; // -4 for " │ " + " │"
785
+ const boxW = Math.max(40, Math.round(avail * 0.6));
786
+ const innerW = boxW - 4; // space inside │ _ content _ │
787
+ for (let i = 0; i < handoffs.length; i++) {
788
+ const h = handoffs[i];
789
+ const isValid = names.includes(h.to);
790
+ const handoffId = `handoff-${Date.now()}-${i}`;
791
+ const chrome = isValid ? t.accentDim : t.error;
792
+ // Top border with label
793
+ this.feedLine();
794
+ const label = ` handoff → @${h.to} `;
795
+ const topFill = Math.max(0, boxW - 2 - label.length);
796
+ this.feedLine(this.makeSpan({
797
+ text: ` ┌${label}${"─".repeat(topFill)}┐`,
798
+ style: { fg: chrome },
799
+ }));
800
+ // Task body — word-wrap each paragraph line
801
+ for (const rawLine of h.task.split("\n")) {
802
+ const wrapped = rawLine.length === 0 ? [""] : this.wordWrap(rawLine, innerW);
803
+ for (const wl of wrapped) {
804
+ const pad = Math.max(0, innerW - wl.length);
805
+ this.feedLine(this.makeSpan({ text: " │ ", style: { fg: chrome } }, { text: wl + " ".repeat(pad), style: { fg: t.textMuted } }, { text: " │", style: { fg: chrome } }));
806
+ }
807
+ }
808
+ // Bottom border
809
+ this.feedLine(this.makeSpan({
810
+ text: ` └${"─".repeat(Math.max(0, boxW - 2))}┘`,
811
+ style: { fg: chrome },
812
+ }));
813
+ if (!isValid) {
814
+ this.feedLine(tp.error(` ✖ Unknown teammate: @${h.to}`));
815
+ }
816
+ else if (this.autoApproveHandoffs) {
817
+ this.taskQueue.push({ type: "agent", teammate: h.to, task: h.task });
818
+ this.feedLine(tp.muted(" automatically approved"));
819
+ this.kickDrain();
820
+ }
821
+ else if (this.chatView) {
822
+ const actionIdx = this.chatView.feedLineCount;
823
+ this.chatView.appendActionList([
824
+ {
825
+ id: `approve-${handoffId}`,
826
+ normalStyle: this.makeSpan({
827
+ text: " [approve]",
828
+ style: { fg: t.textDim },
829
+ }),
830
+ hoverStyle: this.makeSpan({
831
+ text: " [approve]",
832
+ style: { fg: t.accent },
833
+ }),
834
+ },
835
+ {
836
+ id: `reject-${handoffId}`,
837
+ normalStyle: this.makeSpan({
838
+ text: " [reject]",
839
+ style: { fg: t.textDim },
840
+ }),
841
+ hoverStyle: this.makeSpan({
842
+ text: " [reject]",
843
+ style: { fg: t.accent },
844
+ }),
845
+ },
846
+ ]);
847
+ this.pendingHandoffs.push({
848
+ id: handoffId,
849
+ envelope: h,
850
+ approveIdx: actionIdx,
851
+ rejectIdx: actionIdx,
852
+ });
853
+ }
854
+ }
855
+ // Show global approval options as dropdown when there are pending handoffs
856
+ this.showHandoffDropdown();
857
+ this.refreshView();
858
+ }
859
+ /** Show/hide the handoff approval dropdown based on pending handoffs. */
860
+ showHandoffDropdown() {
861
+ if (!this.chatView)
862
+ return;
863
+ if (this.pendingHandoffs.length > 0) {
864
+ const items = [];
865
+ if (this.pendingHandoffs.length === 1) {
866
+ items.push({
867
+ label: "approve",
868
+ description: `approve handoff to @${this.pendingHandoffs[0].envelope.to}`,
869
+ completion: "/approve",
870
+ });
871
+ }
872
+ else {
873
+ items.push({
874
+ label: "approve",
875
+ description: `approve ${this.pendingHandoffs.length} handoffs`,
876
+ completion: "/approve",
877
+ });
878
+ }
879
+ items.push({
880
+ label: "always approve",
881
+ description: "auto-approve future handoffs",
882
+ completion: "/always-approve",
883
+ });
884
+ if (this.pendingHandoffs.length === 1) {
885
+ items.push({
886
+ label: "reject",
887
+ description: `reject handoff to @${this.pendingHandoffs[0].envelope.to}`,
888
+ completion: "/reject",
889
+ });
890
+ }
891
+ else {
892
+ items.push({
893
+ label: "reject",
894
+ description: `reject ${this.pendingHandoffs.length} handoffs`,
895
+ completion: "/reject",
896
+ });
897
+ }
898
+ this.chatView.showDropdown(items);
899
+ }
900
+ else {
901
+ this.chatView.hideDropdown();
902
+ }
903
+ this.refreshView();
904
+ }
905
+ /** Handle handoff approve/reject actions. */
906
+ handleHandoffAction(actionId) {
907
+ const approveMatch = actionId.match(/^approve-(.+)$/);
908
+ if (approveMatch) {
909
+ const hId = approveMatch[1];
910
+ const idx = this.pendingHandoffs.findIndex((h) => h.id === hId);
911
+ if (idx >= 0 && this.chatView) {
912
+ const h = this.pendingHandoffs.splice(idx, 1)[0];
913
+ this.taskQueue.push({
914
+ type: "agent",
915
+ teammate: h.envelope.to,
916
+ task: h.envelope.task,
917
+ });
918
+ this.chatView.updateFeedLine(h.approveIdx, this.makeSpan({ text: " approved", style: { fg: theme().success } }));
919
+ this.kickDrain();
920
+ this.showHandoffDropdown();
921
+ }
922
+ return;
923
+ }
924
+ const rejectMatch = actionId.match(/^reject-(.+)$/);
925
+ if (rejectMatch) {
926
+ const hId = rejectMatch[1];
927
+ const idx = this.pendingHandoffs.findIndex((h) => h.id === hId);
928
+ if (idx >= 0 && this.chatView) {
929
+ const h = this.pendingHandoffs.splice(idx, 1)[0];
930
+ this.chatView.updateFeedLine(h.approveIdx, this.makeSpan({ text: " rejected", style: { fg: theme().error } }));
931
+ this.showHandoffDropdown();
932
+ }
933
+ return;
934
+ }
935
+ }
936
+ /** Handle bulk handoff actions. */
937
+ handleBulkHandoff(action) {
938
+ if (!this.chatView)
939
+ return;
940
+ const t = theme();
941
+ const isApprove = action === "Approve all" || action === "Always approve";
942
+ if (action === "Always approve") {
943
+ this.autoApproveHandoffs = true;
944
+ }
945
+ for (const h of this.pendingHandoffs) {
946
+ if (isApprove) {
947
+ this.taskQueue.push({
948
+ type: "agent",
949
+ teammate: h.envelope.to,
950
+ task: h.envelope.task,
951
+ });
952
+ const label = action === "Always approve"
953
+ ? " automatically approved"
954
+ : " approved";
955
+ this.chatView.updateFeedLine(h.approveIdx, this.makeSpan({ text: label, style: { fg: t.success } }));
956
+ }
957
+ else {
958
+ this.chatView.updateFeedLine(h.approveIdx, this.makeSpan({ text: " rejected", style: { fg: t.error } }));
959
+ }
960
+ }
961
+ this.pendingHandoffs = [];
962
+ if (isApprove)
963
+ this.kickDrain();
964
+ this.showHandoffDropdown();
965
+ }
966
+ // ─── Retro Phase 2: proposal approval ─────────────────────────
967
+ /** Parse retro proposals from agent output and render approval UI. */
968
+ handleRetroResult(result) {
969
+ const raw = result.rawOutput ?? "";
970
+ const proposals = this.parseRetroProposals(raw);
971
+ if (proposals.length === 0)
972
+ return;
973
+ const t = theme();
974
+ const teammate = result.teammate;
975
+ const retroId = `retro-${Date.now()}`;
976
+ this.feedLine();
977
+ this.feedLine(concat(tp.accent(` ${proposals.length} SOUL.md proposal${proposals.length > 1 ? "s" : ""}`), tp.muted(" — approve or reject each:")));
978
+ for (let i = 0; i < proposals.length; i++) {
979
+ const p = proposals[i];
980
+ const pId = `${retroId}-${i}`;
981
+ this.feedLine();
982
+ this.feedLine(tp.text(` Proposal ${i + 1}: ${p.title}`));
983
+ this.feedLine(tp.muted(` Section: ${p.section}`));
984
+ if (p.before === "(new entry)") {
985
+ this.feedLine(tp.muted(" Before: (new entry)"));
986
+ }
987
+ else {
988
+ this.feedLine(tp.muted(` Before: ${p.before}`));
989
+ }
990
+ this.feedLine(concat(tp.muted(" After: "), tp.text(p.after)));
991
+ this.feedLine(tp.muted(` Why: ${p.why}`));
992
+ if (this.chatView) {
993
+ const actionIdx = this.chatView.feedLineCount;
994
+ this.chatView.appendActionList([
995
+ {
996
+ id: `retro-approve-${pId}`,
997
+ normalStyle: this.makeSpan({
998
+ text: " [approve]",
999
+ style: { fg: t.textDim },
1000
+ }),
1001
+ hoverStyle: this.makeSpan({
1002
+ text: " [approve]",
1003
+ style: { fg: t.accent },
1004
+ }),
1005
+ },
1006
+ {
1007
+ id: `retro-reject-${pId}`,
1008
+ normalStyle: this.makeSpan({
1009
+ text: " [reject]",
1010
+ style: { fg: t.textDim },
1011
+ }),
1012
+ hoverStyle: this.makeSpan({
1013
+ text: " [reject]",
1014
+ style: { fg: t.accent },
1015
+ }),
1016
+ },
1017
+ ]);
1018
+ this.pendingRetroProposals.push({
1019
+ id: pId,
1020
+ teammate,
1021
+ index: i + 1,
1022
+ title: p.title,
1023
+ section: p.section,
1024
+ before: p.before,
1025
+ after: p.after,
1026
+ why: p.why,
1027
+ actionIdx,
1028
+ });
1029
+ }
1030
+ }
1031
+ this.feedLine();
1032
+ this.showRetroDropdown();
1033
+ this.refreshView();
1034
+ }
1035
+ /** Parse Proposal N blocks from retro output. */
1036
+ parseRetroProposals(text) {
1037
+ const proposals = [];
1038
+ // Match **Proposal N: title** blocks
1039
+ const proposalPattern = /\*\*Proposal\s+\d+[:.]\s*(.+?)\*\*/gi;
1040
+ let match;
1041
+ const positions = [];
1042
+ while ((match = proposalPattern.exec(text)) !== null) {
1043
+ positions.push({ title: match[1].trim(), start: match.index });
1044
+ }
1045
+ for (let i = 0; i < positions.length; i++) {
1046
+ const end = i + 1 < positions.length ? positions[i + 1].start : text.length;
1047
+ const block = text.slice(positions[i].start, end);
1048
+ const section = this.extractField(block, "Section") || "Unknown";
1049
+ const before = this.extractField(block, "Before") || "(new entry)";
1050
+ const after = this.extractField(block, "After") || "";
1051
+ const why = this.extractField(block, "Why") || "";
1052
+ if (after) {
1053
+ proposals.push({
1054
+ title: positions[i].title,
1055
+ section,
1056
+ before,
1057
+ after,
1058
+ why,
1059
+ });
1060
+ }
1061
+ }
1062
+ return proposals;
1063
+ }
1064
+ /** Extract a **Field:** value from a proposal block. */
1065
+ extractField(block, field) {
1066
+ // Match "- **Field:** value" or "**Field:** value" across potential line breaks
1067
+ const pattern = new RegExp(`\\*\\*${field}:\\*\\*\\s*(.+?)(?=\\n\\s*[-*]\\s*\\*\\*|\\n\\s*\\n|$)`, "is");
1068
+ const m = block.match(pattern);
1069
+ if (!m)
1070
+ return "";
1071
+ // Clean up: remove backticks and trim
1072
+ return m[1].trim().replace(/^`+|`+$/g, "");
1073
+ }
1074
+ /** Show/hide the retro approval dropdown based on pending proposals. */
1075
+ showRetroDropdown() {
1076
+ if (!this.chatView)
1077
+ return;
1078
+ if (this.pendingRetroProposals.length > 0 &&
1079
+ this.pendingHandoffs.length === 0) {
1080
+ const n = this.pendingRetroProposals.length;
1081
+ const items = [];
1082
+ items.push({
1083
+ label: "approve all",
1084
+ description: `approve ${n} SOUL.md proposal${n > 1 ? "s" : ""}`,
1085
+ completion: "/approve-retro",
1086
+ });
1087
+ items.push({
1088
+ label: "reject all",
1089
+ description: `reject ${n} SOUL.md proposal${n > 1 ? "s" : ""}`,
1090
+ completion: "/reject-retro",
1091
+ });
1092
+ this.chatView.showDropdown(items);
1093
+ }
1094
+ else if (this.pendingHandoffs.length === 0) {
1095
+ this.chatView.hideDropdown();
1096
+ }
1097
+ this.refreshView();
1098
+ }
1099
+ /** Handle retro approve/reject actions (individual clicks). */
1100
+ handleRetroAction(actionId) {
1101
+ const approveMatch = actionId.match(/^retro-approve-(.+)$/);
1102
+ if (approveMatch) {
1103
+ const pId = approveMatch[1];
1104
+ const idx = this.pendingRetroProposals.findIndex((p) => p.id === pId);
1105
+ if (idx >= 0 && this.chatView) {
1106
+ const p = this.pendingRetroProposals.splice(idx, 1)[0];
1107
+ this.chatView.updateFeedLine(p.actionIdx, this.makeSpan({
1108
+ text: " approved",
1109
+ style: { fg: theme().success },
1110
+ }));
1111
+ this.queueRetroApply(p.teammate, [p]);
1112
+ this.showRetroDropdown();
1113
+ }
1114
+ return;
1115
+ }
1116
+ const rejectMatch = actionId.match(/^retro-reject-(.+)$/);
1117
+ if (rejectMatch) {
1118
+ const pId = rejectMatch[1];
1119
+ const idx = this.pendingRetroProposals.findIndex((p) => p.id === pId);
1120
+ if (idx >= 0 && this.chatView) {
1121
+ const p = this.pendingRetroProposals.splice(idx, 1)[0];
1122
+ this.chatView.updateFeedLine(p.actionIdx, this.makeSpan({ text: " rejected", style: { fg: theme().error } }));
1123
+ this.showRetroDropdown();
1124
+ }
1125
+ return;
1126
+ }
1127
+ }
1128
+ /** Handle bulk retro approve/reject. */
1129
+ handleBulkRetro(action) {
1130
+ if (!this.chatView)
1131
+ return;
1132
+ const t = theme();
1133
+ const isApprove = action === "Approve all";
1134
+ const grouped = new Map();
1135
+ for (const p of this.pendingRetroProposals) {
1136
+ if (isApprove) {
1137
+ this.chatView.updateFeedLine(p.actionIdx, this.makeSpan({ text: " approved", style: { fg: t.success } }));
1138
+ const list = grouped.get(p.teammate) || [];
1139
+ list.push(p);
1140
+ grouped.set(p.teammate, list);
1141
+ }
1142
+ else {
1143
+ this.chatView.updateFeedLine(p.actionIdx, this.makeSpan({ text: " rejected", style: { fg: t.error } }));
1144
+ }
1145
+ }
1146
+ if (isApprove) {
1147
+ for (const [teammate, proposals] of grouped) {
1148
+ this.queueRetroApply(teammate, proposals);
1149
+ }
1150
+ }
1151
+ this.pendingRetroProposals = [];
1152
+ this.showRetroDropdown();
1153
+ }
1154
+ /** Queue a follow-up task for the teammate to apply approved SOUL.md changes. */
1155
+ queueRetroApply(teammate, proposals) {
1156
+ const changes = proposals
1157
+ .map((p) => `- **Proposal ${p.index}: ${p.title}**\n - Section: ${p.section}\n - Before: ${p.before}\n - After: ${p.after}`)
1158
+ .join("\n\n");
1159
+ const applyPrompt = `The user approved the following SOUL.md changes from your retrospective. Apply them now.
1160
+
1161
+ **Edit your SOUL.md file** (\`.teammates/${teammate}/SOUL.md\`) to incorporate these changes:
1162
+
1163
+ ${changes}
1164
+
1165
+ After editing SOUL.md, record a brief summary of the retro outcome in your daily log: which proposals were approved and what changed.
1166
+
1167
+ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily log.`;
1168
+ this.taskQueue.push({ type: "agent", teammate, task: applyPrompt });
1169
+ this.feedLine(concat(tp.muted(" Queued SOUL.md update for "), tp.accent(`@${teammate}`)));
1170
+ this.refreshView();
1171
+ this.kickDrain();
1172
+ }
1173
+ /** Refresh the ChatView app if active. */
1174
+ refreshView() {
1175
+ if (this.app)
1176
+ this.app.refresh();
1177
+ }
1178
+ queueTask(input) {
1179
+ const allNames = this.orchestrator.listTeammates();
1180
+ // Check for @everyone — queue to all teammates except the coding agent
1181
+ const everyoneMatch = input.match(/^@everyone\s+([\s\S]+)$/i);
1182
+ if (everyoneMatch) {
1183
+ const task = everyoneMatch[1];
1184
+ const names = allNames.filter((n) => n !== this.adapterName);
1185
+ for (const teammate of names) {
1186
+ this.taskQueue.push({ type: "agent", teammate, task });
1187
+ }
1188
+ const bg = this._userBg;
1189
+ const t = theme();
1190
+ this.feedUserLine(concat(pen.fg(t.textMuted).bg(bg)(" → "), pen.fg(t.accent).bg(bg)(names.map((n) => `@${n}`).join(", "))));
1191
+ this.feedLine();
1192
+ this.refreshView();
1193
+ this.kickDrain();
1194
+ return;
1195
+ }
1196
+ // Collect all @mentioned teammates anywhere in the input
1197
+ const mentionRegex = /@(\S+)/g;
1198
+ let m;
1199
+ const mentioned = [];
1200
+ while ((m = mentionRegex.exec(input)) !== null) {
1201
+ const name = m[1];
1202
+ if (allNames.includes(name) && !mentioned.includes(name)) {
1203
+ mentioned.push(name);
1204
+ }
1205
+ }
1206
+ if (mentioned.length > 0) {
1207
+ // Queue a copy of the full message to every mentioned teammate
1208
+ for (const teammate of mentioned) {
1209
+ this.taskQueue.push({ type: "agent", teammate, task: input });
1210
+ }
1211
+ const bg = this._userBg;
1212
+ const t = theme();
1213
+ this.feedUserLine(concat(pen.fg(t.textMuted).bg(bg)(" → "), pen.fg(t.accent).bg(bg)(mentioned.map((n) => `@${n}`).join(", "))));
1214
+ this.feedLine();
1215
+ this.refreshView();
1216
+ this.kickDrain();
1217
+ return;
1218
+ }
1219
+ // No mentions — auto-route: resolve teammate synchronously if possible, else use default
1220
+ let match = this.orchestrator.route(input);
1221
+ if (!match) {
1222
+ // Fall back to adapter name — avoid blocking for agent routing
1223
+ match = this.adapterName;
1224
+ }
1225
+ {
1226
+ const bg = this._userBg;
1227
+ const t = theme();
1228
+ this.feedUserLine(concat(pen.fg(t.textMuted).bg(bg)(" → "), pen.fg(t.accent).bg(bg)(`@${match}`)));
1229
+ }
1230
+ this.feedLine();
1231
+ this.refreshView();
1232
+ this.taskQueue.push({ type: "agent", teammate: match, task: input });
1233
+ this.kickDrain();
1234
+ }
1235
+ /** Start draining per-agent queues in parallel. Each agent gets its own drain loop. */
1236
+ kickDrain() {
1237
+ // Find agents that have queued tasks but no active drain
1238
+ const agentsWithWork = new Set();
1239
+ for (const entry of this.taskQueue) {
1240
+ agentsWithWork.add(entry.teammate);
1241
+ }
1242
+ for (const agent of agentsWithWork) {
1243
+ if (!this.agentDrainLocks.has(agent)) {
1244
+ const lock = this.drainAgentQueue(agent).finally(() => {
1245
+ this.agentDrainLocks.delete(agent);
1246
+ });
1247
+ this.agentDrainLocks.set(agent, lock);
1248
+ }
1249
+ }
1250
+ }
1251
+ // ─── Onboarding ───────────────────────────────────────────────────
1252
+ /**
1253
+ * Interactive prompt when no .teammates/ directory is found.
1254
+ * Returns the new .teammates/ path, or null if user chose to exit.
1255
+ */
1256
+ async promptOnboarding(adapter) {
1257
+ const cwd = process.cwd();
1258
+ const teammatesDir = join(cwd, ".teammates");
1259
+ const termWidth = process.stdout.columns || 100;
1260
+ console.log();
1261
+ this.printLogo([
1262
+ chalk.bold("Teammates") + chalk.gray(` v${PKG_VERSION}`),
1263
+ chalk.yellow("No .teammates/ directory found"),
1264
+ chalk.gray(cwd),
1265
+ ]);
1266
+ console.log();
1267
+ console.log(chalk.gray("─".repeat(termWidth)));
1268
+ console.log();
1269
+ console.log(chalk.white(" Set up teammates for this project?\n"));
1270
+ console.log(chalk.cyan(" 1") +
1271
+ chalk.gray(") ") +
1272
+ chalk.white("New team") +
1273
+ chalk.gray(" — analyze this codebase and create teammates from scratch"));
1274
+ console.log(chalk.cyan(" 2") +
1275
+ chalk.gray(") ") +
1276
+ chalk.white("Import team") +
1277
+ chalk.gray(" — copy teammates from another project"));
1278
+ console.log(chalk.cyan(" 3") +
1279
+ chalk.gray(") ") +
1280
+ chalk.white("Solo mode") +
1281
+ chalk.gray(` — use ${this.adapterName} without teammates`));
1282
+ console.log(chalk.cyan(" 4") + chalk.gray(") ") + chalk.white("Exit"));
1283
+ console.log();
1284
+ const choice = await this.askChoice("Pick an option (1/2/3/4): ", [
1285
+ "1",
1286
+ "2",
1287
+ "3",
1288
+ "4",
1289
+ ]);
1290
+ if (choice === "4") {
1291
+ console.log(chalk.gray(" Goodbye."));
1292
+ return null;
1293
+ }
1294
+ if (choice === "3") {
1295
+ await mkdir(teammatesDir, { recursive: true });
1296
+ console.log();
1297
+ console.log(chalk.green(" ✔") + chalk.gray(` Created ${teammatesDir}`));
1298
+ console.log(chalk.gray(` Running in solo mode — all tasks go to ${this.adapterName}.`));
1299
+ console.log(chalk.gray(" Run /init later to set up teammates."));
1300
+ console.log();
1301
+ return teammatesDir;
1302
+ }
1303
+ if (choice === "2") {
1304
+ // Import from another project
1305
+ await mkdir(teammatesDir, { recursive: true });
1306
+ await this.runImport(cwd);
1307
+ return teammatesDir;
1308
+ }
1309
+ // choice === "1": Run onboarding via the agent
1310
+ await mkdir(teammatesDir, { recursive: true });
1311
+ await this.runOnboardingAgent(adapter, cwd);
1312
+ return teammatesDir;
1313
+ }
1314
+ /**
1315
+ * Run the onboarding agent to analyze the codebase and create teammates.
1316
+ * Used by both promptOnboarding (pre-orchestrator) and cmdInit (post-orchestrator).
1317
+ */
1318
+ async runOnboardingAgent(adapter, projectDir) {
1319
+ console.log();
1320
+ console.log(chalk.blue(" Starting onboarding...") +
1321
+ chalk.gray(` ${this.adapterName} will analyze your codebase and create .teammates/`));
1322
+ console.log();
1323
+ // Copy framework files from bundled template
1324
+ const teammatesDir = join(projectDir, ".teammates");
1325
+ const copied = await copyTemplateFiles(teammatesDir);
1326
+ if (copied.length > 0) {
1327
+ console.log(chalk.green(" ✔") +
1328
+ chalk.gray(` Copied template files: ${copied.join(", ")}`));
1329
+ console.log();
1330
+ }
1331
+ const onboardingPrompt = await getOnboardingPrompt(projectDir);
1332
+ const tempConfig = {
1333
+ name: this.adapterName,
1334
+ role: "Onboarding agent",
1335
+ soul: "",
1336
+ wisdom: "",
1337
+ dailyLogs: [],
1338
+ weeklyLogs: [],
1339
+ ownership: { primary: [], secondary: [] },
1340
+ routingKeywords: [],
1341
+ };
1342
+ const sessionId = await adapter.startSession(tempConfig);
1343
+ const spinner = ora({
1344
+ text: chalk.blue(this.adapterName) +
1345
+ chalk.gray(" is analyzing your codebase..."),
1346
+ spinner: "dots",
1347
+ }).start();
1348
+ try {
1349
+ const result = await adapter.executeTask(sessionId, tempConfig, onboardingPrompt);
1350
+ spinner.stop();
1351
+ this.printAgentOutput(result.rawOutput);
1352
+ if (result.success) {
1353
+ console.log(chalk.green(" ✔ Onboarding complete!"));
1354
+ }
1355
+ else {
1356
+ console.log(chalk.yellow(` ⚠ Onboarding finished with issues: ${result.summary}`));
1357
+ }
1358
+ }
1359
+ catch (err) {
1360
+ spinner.fail(chalk.red(`Onboarding failed: ${err.message}`));
1361
+ }
1362
+ if (adapter.destroySession) {
1363
+ await adapter.destroySession(sessionId);
1364
+ }
1365
+ // Verify .teammates/ now has content
1366
+ try {
1367
+ const entries = await readdir(teammatesDir);
1368
+ if (!entries.some((e) => !e.startsWith("."))) {
1369
+ console.log(chalk.yellow(" ⚠ .teammates/ was created but appears empty."));
1370
+ console.log(chalk.gray(" You may need to run the onboarding agent again or set up manually."));
1371
+ }
1372
+ }
1373
+ catch {
1374
+ /* dir might not exist if onboarding failed badly */
1375
+ }
1376
+ console.log();
1377
+ }
1378
+ /**
1379
+ * Import teammates from another project's .teammates/ directory.
1380
+ * Prompts for a path, copies teammate folders + framework files,
1381
+ * then optionally runs the agent to adapt ownership for this codebase.
1382
+ */
1383
+ async runImport(projectDir) {
1384
+ console.log();
1385
+ console.log(chalk.white(" Enter the path to another project") +
1386
+ chalk.gray(" (the project root or its .teammates/ directory):"));
1387
+ console.log();
1388
+ const rawPath = await this.askInput("Path: ");
1389
+ if (!rawPath) {
1390
+ console.log(chalk.yellow(" No path provided. Aborting import."));
1391
+ return;
1392
+ }
1393
+ // Resolve the source — accept either project root or .teammates/ directly
1394
+ const resolved = resolve(rawPath);
1395
+ let sourceDir;
1396
+ try {
1397
+ const s = await stat(join(resolved, ".teammates"));
1398
+ if (s.isDirectory()) {
1399
+ sourceDir = join(resolved, ".teammates");
1400
+ }
1401
+ else {
1402
+ sourceDir = resolved;
1403
+ }
1404
+ }
1405
+ catch {
1406
+ sourceDir = resolved;
1407
+ }
1408
+ const teammatesDir = join(projectDir, ".teammates");
1409
+ console.log();
1410
+ try {
1411
+ const { teammates, files } = await importTeammates(sourceDir, teammatesDir);
1412
+ if (teammates.length === 0) {
1413
+ console.log(chalk.yellow(" No teammates found at ") + chalk.white(sourceDir));
1414
+ console.log(chalk.gray(" The directory should contain teammate folders (each with a SOUL.md)."));
1415
+ return;
1416
+ }
1417
+ console.log(chalk.green(" ✔") +
1418
+ chalk.white(` Imported ${teammates.length} teammate${teammates.length > 1 ? "s" : ""}: `) +
1419
+ chalk.cyan(teammates.join(", ")));
1420
+ console.log(chalk.gray(` (${files.length} files copied)`));
1421
+ console.log();
1422
+ // Ask if user wants the agent to adapt teammates to this codebase
1423
+ console.log(chalk.white(" Adapt teammates to this codebase?"));
1424
+ console.log(chalk.gray(" The agent will update ownership patterns, file paths, and boundaries."));
1425
+ console.log(chalk.gray(" You can also do this later with /init."));
1426
+ console.log();
1427
+ const adapt = await this.askChoice("Adapt now? (y/n): ", ["y", "n"]);
1428
+ if (adapt === "y") {
1429
+ await this.runAdaptationAgent(this.adapter, projectDir, teammates);
1430
+ }
1431
+ else {
1432
+ console.log(chalk.gray(" Skipped adaptation. Run /init to adapt later."));
1433
+ }
1434
+ }
1435
+ catch (err) {
1436
+ console.log(chalk.red(` Import failed: ${err.message}`));
1437
+ }
1438
+ console.log();
1439
+ }
1440
+ /**
1441
+ * Run the agent to adapt imported teammates' ownership/boundaries
1442
+ * to the current codebase. Queues one task per teammate so the user
1443
+ * can review and approve each adaptation individually.
1444
+ */
1445
+ async runAdaptationAgent(adapter, projectDir, teammateNames) {
1446
+ const teammatesDir = join(projectDir, ".teammates");
1447
+ console.log();
1448
+ console.log(chalk.blue(" Queuing adaptation tasks...") +
1449
+ chalk.gray(` ${this.adapterName} will adapt each teammate individually`));
1450
+ console.log();
1451
+ for (const name of teammateNames) {
1452
+ const prompt = await buildAdaptationPrompt(teammatesDir, name);
1453
+ const tempConfig = {
1454
+ name: this.adapterName,
1455
+ role: "Adaptation agent",
1456
+ soul: "",
1457
+ wisdom: "",
1458
+ dailyLogs: [],
1459
+ weeklyLogs: [],
1460
+ ownership: { primary: [], secondary: [] },
1461
+ routingKeywords: [],
1462
+ };
1463
+ const sessionId = await adapter.startSession(tempConfig);
1464
+ const spinner = ora({
1465
+ text: chalk.blue(this.adapterName) +
1466
+ chalk.gray(` is adapting @${name} to this codebase...`),
1467
+ spinner: "dots",
1468
+ }).start();
1469
+ try {
1470
+ const result = await adapter.executeTask(sessionId, tempConfig, prompt);
1471
+ spinner.stop();
1472
+ this.printAgentOutput(result.rawOutput);
1473
+ if (result.success) {
1474
+ console.log(chalk.green(` ✔ @${name} adaptation complete!`));
1475
+ }
1476
+ else {
1477
+ console.log(chalk.yellow(` ⚠ @${name} adaptation finished with issues: ${result.summary}`));
1478
+ }
1479
+ }
1480
+ catch (err) {
1481
+ spinner.fail(chalk.red(`@${name} adaptation failed: ${err.message}`));
1482
+ }
1483
+ if (adapter.destroySession) {
1484
+ await adapter.destroySession(sessionId);
1485
+ }
1486
+ console.log();
1487
+ }
1488
+ }
1489
+ /**
1490
+ * Simple blocking prompt — reads one line from stdin and validates.
1491
+ */
1492
+ askChoice(prompt, valid) {
1493
+ return new Promise((resolve) => {
1494
+ const rl = createInterface({
1495
+ input: process.stdin,
1496
+ output: process.stdout,
1497
+ });
1498
+ const ask = () => {
1499
+ rl.question(chalk.cyan(" ") + prompt, (answer) => {
1500
+ const trimmed = answer.trim();
1501
+ if (valid.includes(trimmed)) {
1502
+ rl.close();
1503
+ resolve(trimmed);
1504
+ }
1505
+ else {
1506
+ ask();
1507
+ }
1508
+ });
1509
+ };
1510
+ ask();
1511
+ });
1512
+ }
1513
+ askInput(prompt) {
1514
+ return new Promise((resolve) => {
1515
+ const rl = createInterface({
1516
+ input: process.stdin,
1517
+ output: process.stdout,
1518
+ });
1519
+ rl.question(chalk.cyan(" ") + prompt, (answer) => {
1520
+ rl.close();
1521
+ resolve(answer.trim());
1522
+ });
1523
+ });
1524
+ }
1525
+ /**
1526
+ * Check whether USER.md needs to be created or is still template placeholders.
1527
+ */
1528
+ needsUserSetup(teammatesDir) {
1529
+ const userMdPath = join(teammatesDir, "USER.md");
1530
+ try {
1531
+ const content = readFileSync(userMdPath, "utf-8");
1532
+ // Template placeholders contain "<your name>" — treat as not set up
1533
+ return !content.trim() || content.includes("<your name>");
1534
+ }
1535
+ catch {
1536
+ // File doesn't exist
1537
+ return true;
1538
+ }
1539
+ }
1540
+ /**
1541
+ * Run the user interview inside the ChatView using the Interview widget.
1542
+ * Hides the normal input prompt until the interview completes.
1543
+ */
1544
+ startUserInterview(teammatesDir, bannerWidget) {
1545
+ if (!this.chatView)
1546
+ return;
1547
+ const t = theme();
1548
+ const interview = new Interview({
1549
+ title: "Quick intro — helps teammates tailor their work to you.",
1550
+ subtitle: "(press Enter to skip any question)",
1551
+ questions: [
1552
+ { key: "name", prompt: "Your name" },
1553
+ {
1554
+ key: "role",
1555
+ prompt: "Your role",
1556
+ placeholder: "e.g., senior backend engineer",
1557
+ },
1558
+ {
1559
+ key: "experience",
1560
+ prompt: "Relevant experience",
1561
+ placeholder: "e.g., 10 years Go, new to React",
1562
+ },
1563
+ {
1564
+ key: "preferences",
1565
+ prompt: "How you like to work",
1566
+ placeholder: "e.g., terse responses, explain reasoning",
1567
+ },
1568
+ {
1569
+ key: "context",
1570
+ prompt: "Anything else",
1571
+ placeholder: "e.g., solo dev, working on a rewrite",
1572
+ },
1573
+ ],
1574
+ titleStyle: { fg: t.text },
1575
+ subtitleStyle: { fg: t.textDim, italic: true },
1576
+ promptStyle: { fg: t.accent },
1577
+ answeredStyle: { fg: t.textMuted, italic: true },
1578
+ inputStyle: { fg: t.text },
1579
+ cursorStyle: { fg: t.cursorFg, bg: t.cursorBg },
1580
+ placeholderStyle: { fg: t.textDim, italic: true },
1581
+ });
1582
+ this.chatView.setInputOverride(interview);
1583
+ if (this.app)
1584
+ this.app.refresh();
1585
+ interview.on("complete", (answers) => {
1586
+ // Write USER.md
1587
+ const userMdPath = join(teammatesDir, "USER.md");
1588
+ const lines = ["# User\n"];
1589
+ lines.push(`- **Name:** ${answers.name || "_not provided_"}`);
1590
+ lines.push(`- **Role:** ${answers.role || "_not provided_"}`);
1591
+ lines.push(`- **Experience:** ${answers.experience || "_not provided_"}`);
1592
+ lines.push(`- **Preferences:** ${answers.preferences || "_not provided_"}`);
1593
+ lines.push(`- **Context:** ${answers.context || "_not provided_"}`);
1594
+ writeFileSync(userMdPath, `${lines.join("\n")}\n`, "utf-8");
1595
+ // Remove override and restore normal input
1596
+ if (this.chatView) {
1597
+ this.chatView.setInputOverride(null);
1598
+ this.chatView.appendStyledToFeed(concat(tp.success(" ✔ "), tp.dim("Saved USER.md — update anytime with /user")));
1599
+ }
1600
+ // Release the banner hold so commands animate in
1601
+ if (bannerWidget)
1602
+ bannerWidget.releaseHold();
1603
+ if (this.app)
1604
+ this.app.refresh();
1605
+ });
1606
+ }
1607
+ // ─── Display helpers ──────────────────────────────────────────────
1608
+ /**
1609
+ * Render the box logo with up to 4 info lines on the right side.
1610
+ */
1611
+ printLogo(infoLines) {
1612
+ const [top, bot] = buildTitle("teammates");
1613
+ console.log(` ${chalk.cyan(top)}`);
1614
+ console.log(` ${chalk.cyan(bot)}`);
1615
+ if (infoLines.length > 0) {
1616
+ console.log();
1617
+ for (const line of infoLines) {
1618
+ console.log(` ${line}`);
1619
+ }
1620
+ }
1621
+ }
1622
+ /**
1623
+ * Print agent raw output, stripping the trailing JSON protocol block.
1624
+ */
1625
+ printAgentOutput(rawOutput) {
1626
+ const raw = rawOutput ?? "";
1627
+ if (!raw)
1628
+ return;
1629
+ const cleaned = raw
1630
+ .replace(/```json\s*\n\s*\{[\s\S]*?\}\s*\n\s*```\s*$/, "")
1631
+ .trim();
1632
+ if (cleaned) {
1633
+ this.feedMarkdown(cleaned);
1634
+ }
1635
+ this.feedLine();
1636
+ }
1637
+ // ─── Wordwheel ─────────────────────────────────────────────────────
1638
+ getUniqueCommands() {
1639
+ const seen = new Set();
1640
+ const result = [];
1641
+ for (const [, cmd] of this.commands) {
1642
+ if (seen.has(cmd.name))
1643
+ continue;
1644
+ seen.add(cmd.name);
1645
+ result.push(cmd);
1646
+ }
1647
+ return result;
1648
+ }
1649
+ clearWordwheel() {
1650
+ if (this.chatView) {
1651
+ this.chatView.hideDropdown();
1652
+ // Don't refreshView here — caller will either showDropdown + refresh,
1653
+ // or the next App render pass will pick up the cleared state.
1654
+ }
1655
+ else {
1656
+ this.input.clearDropdown();
1657
+ }
1658
+ }
1659
+ writeWordwheel(lines) {
1660
+ if (this.chatView) {
1661
+ // Lines are pre-formatted for PromptInput — convert to DropdownItems
1662
+ // This path is used for static usage hints; wordwheel items use showDropdown directly
1663
+ this.chatView.showDropdown(lines.map((l) => ({
1664
+ label: stripAnsi(l).trim(),
1665
+ description: "",
1666
+ completion: "",
1667
+ })));
1668
+ this.refreshView();
1669
+ }
1670
+ else {
1671
+ this.input.setDropdown(lines);
1672
+ }
1673
+ }
1674
+ /**
1675
+ * Which argument positions are teammate-name completable per command.
1676
+ * Key = command name, value = set of 0-based arg positions that take a teammate.
1677
+ */
1678
+ static TEAMMATE_ARG_POSITIONS = {
1679
+ assign: new Set([0]),
1680
+ handoff: new Set([0, 1]),
1681
+ compact: new Set([0]),
1682
+ debug: new Set([0]),
1683
+ retro: new Set([0]),
1684
+ };
341
1685
  /** Build param-completion items for the current line, if any. */
342
1686
  getParamItems(cmdName, argsBefore, partial) {
343
1687
  // Service-name completions for /install
@@ -348,70 +1692,117 @@ class TeammatesREPL {
348
1692
  .map(([name, svc]) => ({
349
1693
  label: name,
350
1694
  description: svc.description,
351
- completion: "/install " + name + " ",
1695
+ completion: `/install ${name} `,
352
1696
  }));
353
1697
  }
354
1698
  const positions = TeammatesREPL.TEAMMATE_ARG_POSITIONS[cmdName];
355
1699
  if (!positions)
356
1700
  return [];
357
1701
  // Count how many complete args precede the current partial
358
- const completedArgs = argsBefore.trim() ? argsBefore.trim().split(/\s+/).length : 0;
1702
+ const completedArgs = argsBefore.trim()
1703
+ ? argsBefore.trim().split(/\s+/).length
1704
+ : 0;
359
1705
  if (!positions.has(completedArgs))
360
1706
  return [];
361
1707
  const teammates = this.orchestrator.listTeammates();
362
1708
  const lower = partial.toLowerCase();
363
- return teammates
364
- .filter((n) => n.toLowerCase().startsWith(lower))
365
- .map((name) => {
1709
+ const items = [];
1710
+ // Add "everyone" option at the top (only for first arg position)
1711
+ if (completedArgs === 0 && "everyone".startsWith(lower)) {
1712
+ const linePrefix = `/${cmdName} ${argsBefore ? argsBefore : ""}`;
1713
+ items.push({
1714
+ label: "everyone",
1715
+ description: "all teammates",
1716
+ completion: `${linePrefix}everyone `,
1717
+ });
1718
+ }
1719
+ for (const name of teammates) {
1720
+ if (!name.toLowerCase().startsWith(lower))
1721
+ continue;
366
1722
  const t = this.orchestrator.getRegistry().get(name);
367
- const linePrefix = "/" + cmdName + " " + (argsBefore ? argsBefore : "");
368
- return {
1723
+ const linePrefix = `/${cmdName} ${argsBefore ? argsBefore : ""}`;
1724
+ items.push({
369
1725
  label: name,
370
1726
  description: t?.role ?? "",
371
- completion: linePrefix + name + " ",
372
- };
373
- });
1727
+ completion: `${linePrefix + name} `,
1728
+ });
1729
+ }
1730
+ return items;
374
1731
  }
375
1732
  /**
376
- * Find the @mention token the cursor is currently inside, if any.
377
- * Returns { before, partial, atPos } or null.
1733
+ * Return dim placeholder hint text for the current input value.
1734
+ * e.g. typing "/log" shows " <teammate>", typing "/log b" shows nothing.
378
1735
  */
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)
1736
+ getCommandHint(value) {
1737
+ const trimmed = value.trimStart();
1738
+ if (!trimmed.startsWith("/"))
384
1739
  return null;
385
- // '@' must be at start of line or preceded by whitespace
386
- if (atPos > 0 && !/\s/.test(line[atPos - 1]))
1740
+ // Extract command name and what's been typed after it
1741
+ const spaceIdx = trimmed.indexOf(" ");
1742
+ const cmdName = spaceIdx < 0 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx);
1743
+ const cmd = this.commands.get(cmdName);
1744
+ if (!cmd)
387
1745
  return null;
388
- const partial = left.slice(atPos + 1);
389
- // Partial must be a single token (no spaces)
390
- if (/\s/.test(partial))
1746
+ // Extract placeholder tokens from usage (e.g. "/log [teammate]" → ["[teammate]"])
1747
+ const usageParts = cmd.usage.split(/\s+/).slice(1); // drop the "/command" part
1748
+ if (usageParts.length === 0)
391
1749
  return null;
392
- return { before: line.slice(0, atPos), partial, atPos };
1750
+ // Count how many args the user has typed after the command
1751
+ const afterCmd = spaceIdx < 0 ? "" : trimmed.slice(spaceIdx + 1);
1752
+ const typedArgs = afterCmd
1753
+ .trim()
1754
+ .split(/\s+/)
1755
+ .filter((s) => s.length > 0);
1756
+ // Show remaining placeholders
1757
+ const remaining = usageParts.slice(typedArgs.length);
1758
+ if (remaining.length === 0)
1759
+ return null;
1760
+ // Add a leading space if the value doesn't already end with one
1761
+ const pad = value.endsWith(" ") ? "" : " ";
1762
+ return pad + remaining.join(" ");
1763
+ }
1764
+ /**
1765
+ * Find the @mention token the cursor is currently inside, if any.
1766
+ * Returns { before, partial, atPos } or null.
1767
+ */
1768
+ findAtMention(line, cursor) {
1769
+ return findAtMention(line, cursor);
393
1770
  }
394
1771
  /** Build @mention teammate completion items. */
395
1772
  getAtMentionItems(line, before, partial, atPos) {
396
1773
  const teammates = this.orchestrator.listTeammates();
397
1774
  const lower = partial.toLowerCase();
398
1775
  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
- });
1776
+ const items = [];
1777
+ // @everyone alias
1778
+ if ("everyone".startsWith(lower)) {
1779
+ items.push({
1780
+ label: "@everyone",
1781
+ description: "Send to all teammates",
1782
+ completion: `${before}@everyone ${after.replace(/^\s+/, "")}`,
1783
+ });
1784
+ }
1785
+ for (const name of teammates) {
1786
+ if (name.toLowerCase().startsWith(lower)) {
1787
+ const t = this.orchestrator.getRegistry().get(name);
1788
+ items.push({
1789
+ label: `@${name}`,
1790
+ description: t?.role ?? "",
1791
+ completion: `${before}@${name} ${after.replace(/^\s+/, "")}`,
1792
+ });
1793
+ }
1794
+ }
1795
+ return items;
409
1796
  }
410
1797
  /** Recompute matches and draw the wordwheel. */
411
1798
  updateWordwheel() {
412
1799
  this.clearWordwheel();
413
- const line = this.rl.line ?? "";
414
- const cursor = this.rl.cursor ?? line.length;
1800
+ const line = this.chatView
1801
+ ? this.chatView.inputValue
1802
+ : this.input.line;
1803
+ const cursor = this.chatView
1804
+ ? this.chatView.inputValue.length
1805
+ : this.input.cursor;
415
1806
  // ── @mention anywhere in the line ──────────────────────────────
416
1807
  const mention = this.findAtMention(line, cursor);
417
1808
  if (mention) {
@@ -453,12 +1844,9 @@ class TeammatesREPL {
453
1844
  this.renderItems();
454
1845
  }
455
1846
  else {
456
- // No param completions — show static usage hint
1847
+ // No param completions — hide dropdown
1848
+ this.wordwheelItems = [];
457
1849
  this.wordwheelIndex = -1;
458
- this.writeWordwheel([
459
- ` ${chalk.cyan(cmd.usage)}`,
460
- ` ${chalk.gray(cmd.description)}`,
461
- ]);
462
1850
  }
463
1851
  return;
464
1852
  }
@@ -467,11 +1855,14 @@ class TeammatesREPL {
467
1855
  this.wordwheelItems = this.getUniqueCommands()
468
1856
  .filter((c) => c.name.startsWith(partial) ||
469
1857
  c.aliases.some((a) => a.startsWith(partial)))
470
- .map((c) => ({
471
- label: "/" + c.name,
472
- description: c.description,
473
- completion: "/" + c.name + " ",
474
- }));
1858
+ .map((c) => {
1859
+ const hasParams = /^\/\S+\s+.+$/.test(c.usage);
1860
+ return {
1861
+ label: `/${c.name}`,
1862
+ description: c.description,
1863
+ completion: hasParams ? `/${c.name} ` : `/${c.name}`,
1864
+ };
1865
+ });
475
1866
  if (this.wordwheelItems.length === 0) {
476
1867
  this.wordwheelIndex = -1;
477
1868
  return;
@@ -483,14 +1874,30 @@ class TeammatesREPL {
483
1874
  }
484
1875
  /** Render the current wordwheelItems list with selection highlight. */
485
1876
  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);
1877
+ if (this.chatView) {
1878
+ this.chatView.showDropdown(this.wordwheelItems);
1879
+ // Sync selection index
1880
+ if (this.wordwheelIndex >= 0) {
1881
+ while (this.chatView.dropdownIndex < this.wordwheelIndex)
1882
+ this.chatView.dropdownDown();
1883
+ while (this.chatView.dropdownIndex > this.wordwheelIndex)
1884
+ this.chatView.dropdownUp();
491
1885
  }
492
- return prefix + chalk.cyan(label) + " " + chalk.gray(item.description);
493
- }));
1886
+ this.refreshView();
1887
+ }
1888
+ else {
1889
+ this.writeWordwheel(this.wordwheelItems.map((item, i) => {
1890
+ const prefix = i === this.wordwheelIndex ? chalk.cyan("▸ ") : " ";
1891
+ const label = item.label.padEnd(14);
1892
+ if (i === this.wordwheelIndex) {
1893
+ return (prefix +
1894
+ chalk.cyanBright.bold(label) +
1895
+ " " +
1896
+ chalk.white(item.description));
1897
+ }
1898
+ return `${prefix + chalk.cyan(label)} ${chalk.gray(item.description)}`;
1899
+ }));
1900
+ }
494
1901
  }
495
1902
  /** Accept the currently highlighted item into the input line. */
496
1903
  acceptWordwheelSelection() {
@@ -498,9 +1905,12 @@ class TeammatesREPL {
498
1905
  if (!item)
499
1906
  return;
500
1907
  this.clearWordwheel();
501
- this.rl.line = item.completion;
502
- this.rl.cursor = item.completion.length;
503
- this.rl._refreshLine();
1908
+ if (this.chatView) {
1909
+ this.chatView.inputValue = item.completion;
1910
+ }
1911
+ else {
1912
+ this.input.setLine(item.completion);
1913
+ }
504
1914
  this.wordwheelItems = [];
505
1915
  this.wordwheelIndex = -1;
506
1916
  // Re-render for next param or usage hint
@@ -517,6 +1927,9 @@ class TeammatesREPL {
517
1927
  if (!teammatesDir)
518
1928
  return; // user chose to exit
519
1929
  }
1930
+ // Check if USER.md needs setup — we'll run the interview inside the
1931
+ // ChatView after the UI loads (not before).
1932
+ const pendingUserInterview = this.needsUserSetup(teammatesDir);
520
1933
  // Init orchestrator
521
1934
  this.teammatesDir = teammatesDir;
522
1935
  this.orchestrator = new Orchestrator({
@@ -531,37 +1944,30 @@ class TeammatesREPL {
531
1944
  name: this.adapterName,
532
1945
  role: `General-purpose coding agent (${this.adapterName})`,
533
1946
  soul: "",
534
- memories: "",
1947
+ wisdom: "",
535
1948
  dailyLogs: [],
1949
+ weeklyLogs: [],
536
1950
  ownership: { primary: [], secondary: [] },
1951
+ routingKeywords: [],
537
1952
  });
538
1953
  // Add status entry (init() already ran, so we add it manually)
539
1954
  this.orchestrator.getAllStatuses().set(this.adapterName, { state: "idle" });
540
1955
  // Populate roster on the adapter so prompts include team info
541
1956
  if ("roster" in this.adapter) {
542
1957
  const registry = this.orchestrator.getRegistry();
543
- this.adapter.roster = this.orchestrator.listTeammates().map((name) => {
1958
+ this.adapter.roster = this.orchestrator
1959
+ .listTeammates()
1960
+ .map((name) => {
544
1961
  const t = registry.get(name);
545
1962
  return { name: t.name, role: t.role, ownership: t.ownership };
546
1963
  });
547
1964
  }
548
- // Detect installed services and tell the adapter
1965
+ // Detect installed services from services.json and tell the adapter
549
1966
  if ("services" in this.adapter) {
550
1967
  const services = [];
551
- // Check if any teammate has a .index/ directory (recall is indexed)
552
1968
  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) {
1969
+ const svcJson = JSON.parse(readFileSync(join(this.teammatesDir, "services.json"), "utf-8"));
1970
+ if (svcJson && "recall" in svcJson) {
565
1971
  services.push({
566
1972
  name: "recall",
567
1973
  description: "Local semantic search across teammate memories and daily logs. Use this to find relevant context before starting a task.",
@@ -569,311 +1975,542 @@ class TeammatesREPL {
569
1975
  });
570
1976
  }
571
1977
  }
572
- catch { /* can't read teammates dir */ }
1978
+ catch {
1979
+ /* no services.json or invalid */
1980
+ }
573
1981
  this.adapter.services = services;
574
1982
  }
1983
+ // Start recall watch mode if recall is installed
1984
+ this.startRecallWatch();
1985
+ // Background maintenance: compact stale dailies + sync recall indexes
1986
+ this.startupMaintenance().catch(() => { });
575
1987
  // Register commands
576
1988
  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();
1989
+ // Create PromptInput consolonia-based replacement for readline.
1990
+ // Uses raw stdin + InputProcessor for proper escape/paste/mouse parsing.
1991
+ // Kept as a fallback for pre-onboarding prompts; the main REPL uses ChatView.
1992
+ this.input = new PromptInput({
1993
+ prompt: chalk.gray("> "),
1994
+ borderStyle: (s) => chalk.gray(s),
1995
+ colorize: (value) => {
1996
+ const validNames = new Set([
1997
+ ...this.orchestrator.listTeammates(),
1998
+ this.adapterName,
1999
+ ]);
2000
+ return value
2001
+ .replace(/@(\w+)/g, (match, name) => validNames.has(name) ? chalk.blue(match) : match)
2002
+ .replace(/^\/\w+/, (m) => chalk.blue(m));
585
2003
  },
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") {
2004
+ hint: (value) => this.getCommandHint(value),
2005
+ onUpDown: (dir) => {
2006
+ if (this.wordwheelItems.length === 0)
2007
+ return false;
2008
+ if (dir === "up") {
639
2009
  this.wordwheelIndex = Math.max(this.wordwheelIndex - 1, -1);
640
- this.renderItems(); // calls dropdown.render() → _refreshLine()
641
- return;
642
2010
  }
643
- if (key.name === "tab" && this.wordwheelIndex >= 0) {
644
- this.acceptWordwheelSelection();
645
- return;
2011
+ else {
2012
+ this.wordwheelIndex = Math.min(this.wordwheelIndex + 1, this.wordwheelItems.length - 1);
646
2013
  }
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) {
2014
+ this.renderItems();
2015
+ return true;
2016
+ },
2017
+ beforeSubmit: (currentValue) => {
2018
+ if (this.wordwheelItems.length > 0 && this.wordwheelIndex >= 0) {
653
2019
  const item = this.wordwheelItems[this.wordwheelIndex];
654
2020
  if (item) {
655
- this.rl.line = item.completion;
656
- this.rl.cursor = item.completion.length;
2021
+ this.clearWordwheel();
2022
+ this.wordwheelItems = [];
2023
+ this.wordwheelIndex = -1;
2024
+ return item.completion;
657
2025
  }
658
2026
  }
659
- this.dropdown.clear();
2027
+ this.clearWordwheel();
660
2028
  this.wordwheelItems = [];
661
2029
  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;
2030
+ return currentValue;
2031
+ },
2032
+ });
2033
+ // ── Build animated banner for ChatView ─────────────────────────────
2034
+ const names = this.orchestrator.listTeammates();
2035
+ const reg = this.orchestrator.getRegistry();
2036
+ let hasRecall = false;
2037
+ try {
2038
+ const svcJson = JSON.parse(readFileSync(join(this.teammatesDir, "services.json"), "utf-8"));
2039
+ hasRecall = !!(svcJson && "recall" in svcJson);
2040
+ }
2041
+ catch {
2042
+ /* no services.json */
2043
+ }
2044
+ const bannerWidget = new AnimatedBanner({
2045
+ adapterName: this.adapterName,
2046
+ teammateCount: names.length,
2047
+ cwd: process.cwd(),
2048
+ recallInstalled: hasRecall,
2049
+ teammates: names.map((name) => {
2050
+ const t = reg.get(name);
2051
+ return { name, role: t?.role ?? "" };
2052
+ }),
2053
+ });
2054
+ // ── Create ChatView and Consolonia App ────────────────────────────
2055
+ const t = theme();
2056
+ this.chatView = new ChatView({
2057
+ bannerWidget,
2058
+ prompt: "> ",
2059
+ promptStyle: { fg: t.prompt },
2060
+ inputStyle: { fg: t.textMuted },
2061
+ cursorStyle: { fg: t.cursorFg, bg: t.cursorBg },
2062
+ placeholder: " @mention or type a task...",
2063
+ placeholderStyle: { fg: t.textDim, italic: true },
2064
+ inputColorize: (value) => {
2065
+ const styles = new Array(value.length).fill(null);
2066
+ const accentStyle = { fg: theme().accent };
2067
+ const dimStyle = { fg: theme().textDim };
2068
+ // Colorize /commands (only at start of input)
2069
+ const cmdPattern = /^\/[\w-]+/;
2070
+ let m = cmdPattern.exec(value);
2071
+ if (m) {
2072
+ for (let i = m.index; i < m.index + m[0].length; i++) {
2073
+ styles[i] = accentStyle;
2074
+ }
2075
+ }
2076
+ // Colorize @mentions only if they reference a valid teammate or the coding agent
2077
+ const validNames = new Set([
2078
+ ...this.orchestrator.listTeammates(),
2079
+ this.adapterName,
2080
+ ]);
2081
+ const mentionPattern = /@(\w+)/g;
2082
+ while ((m = mentionPattern.exec(value)) !== null) {
2083
+ if (validNames.has(m[1])) {
2084
+ for (let i = m.index; i < m.index + m[0].length; i++) {
2085
+ styles[i] = accentStyle;
2086
+ }
2087
+ }
2088
+ }
2089
+ // Colorize [placeholder] blocks as dim
2090
+ const placeholders = /\[[^[\]]+\]/g;
2091
+ while ((m = placeholders.exec(value)) !== null) {
2092
+ for (let i = m.index; i < m.index + m[0].length; i++) {
2093
+ styles[i] = dimStyle;
2094
+ }
2095
+ }
2096
+ return styles;
2097
+ },
2098
+ inputDeleteSize: (value, cursor, direction) => {
2099
+ // Delete entire [placeholder] blocks as a unit (paste placeholders, quoted reply, etc.)
2100
+ const placeholder = /\[[^[\]]+\]/g;
2101
+ let m;
2102
+ while ((m = placeholder.exec(value)) !== null) {
2103
+ const start = m.index;
2104
+ const end = start + m[0].length;
2105
+ if (direction === "backward" && cursor > start && cursor <= end) {
2106
+ return cursor - start;
2107
+ }
2108
+ if (direction === "forward" && cursor >= start && cursor < end) {
2109
+ return end - cursor;
2110
+ }
2111
+ }
2112
+ return 1;
2113
+ },
2114
+ inputHint: (value) => this.getCommandHint(value),
2115
+ inputHintStyle: { fg: t.textDim },
2116
+ maxInputHeight: 5,
2117
+ separatorStyle: { fg: t.separator },
2118
+ progressStyle: { fg: t.progress, italic: true },
2119
+ dropdownHighlightStyle: { fg: t.accent },
2120
+ dropdownStyle: { fg: t.textMuted },
2121
+ footer: concat(tp.accent(" Teammates"), tp.dim(` v${PKG_VERSION}`)),
2122
+ footerStyle: { fg: t.textDim },
2123
+ });
2124
+ this.defaultFooter = concat(tp.accent(" Teammates"), tp.dim(` v${PKG_VERSION}`));
2125
+ // Wire ChatView events for input handling
2126
+ this.chatView.on("submit", (rawLine) => {
2127
+ this.handleSubmit(rawLine).catch((err) => {
2128
+ this.feedLine(tp.error(`Unhandled error: ${err.message}`));
2129
+ this.refreshView();
2130
+ });
2131
+ });
2132
+ this.chatView.on("change", () => {
2133
+ // Clear quoted reply if user backspaced over the placeholder
2134
+ if (this._pendingQuotedReply &&
2135
+ this.chatView &&
2136
+ !this.chatView.inputValue.includes("[quoted reply]")) {
2137
+ this._pendingQuotedReply = null;
666
2138
  }
667
- // Any other key — clear dropdown, let readline handle keystroke,
668
- // then recompute and render the new dropdown.
669
- this.dropdown.clear();
670
2139
  this.wordwheelItems = [];
671
2140
  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
2141
  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";
2142
+ // Reset ESC / Ctrl+C pending state on any text change
2143
+ if (this.escPending) {
2144
+ this.escPending = false;
2145
+ if (this.escTimer) {
2146
+ clearTimeout(this.escTimer);
2147
+ this.escTimer = null;
725
2148
  }
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));
2149
+ this.chatView.setFooter(this.defaultFooter);
2150
+ this.refreshView();
2151
+ }
2152
+ if (this.ctrlcPending) {
2153
+ this.ctrlcPending = false;
2154
+ if (this.ctrlcTimer) {
2155
+ clearTimeout(this.ctrlcTimer);
2156
+ this.ctrlcTimer = null;
738
2157
  }
739
- if (lineCount > 5) {
740
- console.log(chalk.gray(` │ ... ${lineCount - 5} more lines`));
2158
+ this.chatView.setFooter(this.defaultFooter);
2159
+ this.refreshView();
2160
+ }
2161
+ });
2162
+ this.chatView.on("tab", () => {
2163
+ if (this.wordwheelItems.length > 0) {
2164
+ if (this.wordwheelIndex < 0)
2165
+ this.wordwheelIndex = 0;
2166
+ this.acceptWordwheelSelection();
2167
+ }
2168
+ });
2169
+ this.chatView.on("cancel", () => {
2170
+ this.clearWordwheel();
2171
+ this.wordwheelItems = [];
2172
+ this.wordwheelIndex = -1;
2173
+ if (this.escPending) {
2174
+ // Second ESC — clear input and restore footer
2175
+ this.escPending = false;
2176
+ if (this.escTimer) {
2177
+ clearTimeout(this.escTimer);
2178
+ this.escTimer = null;
741
2179
  }
742
- console.log(chalk.gray(` └`));
2180
+ this.chatView.inputValue = "";
2181
+ this.chatView.setFooter(this.defaultFooter);
2182
+ this.pastedTexts.clear();
2183
+ this.refreshView();
743
2184
  }
744
- if (!input || this.dispatching) {
745
- this.rl.prompt();
2185
+ else if (this.chatView.inputValue.length > 0) {
2186
+ // First ESC with text — show hint in footer, auto-expire after 2s
2187
+ this.escPending = true;
2188
+ const termW = process.stdout.columns || 80;
2189
+ const hint = "ESC again to clear";
2190
+ const pad = Math.max(0, termW - hint.length - 1);
2191
+ this.chatView.setFooter(concat(tp.dim(" ".repeat(pad)), tp.muted(hint)));
2192
+ this.refreshView();
2193
+ this.escTimer = setTimeout(() => {
2194
+ this.escTimer = null;
2195
+ if (this.escPending) {
2196
+ this.escPending = false;
2197
+ this.chatView.setFooter(this.defaultFooter);
2198
+ this.refreshView();
2199
+ }
2200
+ }, 2000);
2201
+ }
2202
+ });
2203
+ this.chatView.on("paste", (text) => {
2204
+ this.handlePaste(text);
2205
+ });
2206
+ this.chatView.on("ctrlc", () => {
2207
+ if (this.ctrlcPending) {
2208
+ // Second Ctrl+C — exit
2209
+ this.ctrlcPending = false;
2210
+ if (this.ctrlcTimer) {
2211
+ clearTimeout(this.ctrlcTimer);
2212
+ this.ctrlcTimer = null;
2213
+ }
2214
+ this.chatView.setFooter(this.defaultFooter);
2215
+ this.stopRecallWatch();
2216
+ if (this.app)
2217
+ this.app.stop();
2218
+ this.orchestrator.shutdown().then(() => process.exit(0));
746
2219
  return;
747
2220
  }
748
- if (!input.startsWith("/")) {
749
- this.conversationHistory.push({ role: "user", text: input });
2221
+ // First Ctrl+C — show hint in footer, auto-expire after 2s
2222
+ this.ctrlcPending = true;
2223
+ const termW = process.stdout.columns || 80;
2224
+ const hint = "Ctrl+C again to exit";
2225
+ const pad = Math.max(0, termW - hint.length - 1);
2226
+ this.chatView.setFooter(concat(tp.dim(" ".repeat(pad)), tp.muted(hint)));
2227
+ this.refreshView();
2228
+ this.ctrlcTimer = setTimeout(() => {
2229
+ this.ctrlcTimer = null;
2230
+ if (this.ctrlcPending) {
2231
+ this.ctrlcPending = false;
2232
+ this.chatView.setFooter(this.defaultFooter);
2233
+ this.refreshView();
2234
+ }
2235
+ }, 2000);
2236
+ });
2237
+ this.chatView.on("action", (id) => {
2238
+ if (id === "copy") {
2239
+ this.doCopy(this.lastCleanedOutput || undefined);
2240
+ }
2241
+ else if (id.startsWith("retro-approve-") ||
2242
+ id.startsWith("retro-reject-")) {
2243
+ this.handleRetroAction(id);
2244
+ }
2245
+ else if (id.startsWith("approve-") || id.startsWith("reject-")) {
2246
+ this.handleHandoffAction(id);
2247
+ }
2248
+ else if (id.startsWith("reply-")) {
2249
+ const ctx = this._replyContexts.get(id);
2250
+ if (ctx && this.chatView) {
2251
+ this.chatView.inputValue = `@${ctx.teammate} [quoted reply] `;
2252
+ this._pendingQuotedReply = ctx.message;
2253
+ this.refreshView();
2254
+ }
2255
+ }
2256
+ });
2257
+ this.chatView.on("link", (url) => {
2258
+ const cmd = process.platform === "darwin"
2259
+ ? "open"
2260
+ : process.platform === "win32"
2261
+ ? "start"
2262
+ : "xdg-open";
2263
+ execCb(`${cmd} ${JSON.stringify(url)}`);
2264
+ });
2265
+ this.app = new App({
2266
+ root: this.chatView,
2267
+ alternateScreen: true,
2268
+ mouse: true,
2269
+ });
2270
+ // Run the app — this takes over the terminal.
2271
+ // Start the banner animation after the first frame renders.
2272
+ bannerWidget.onDirty = () => this.app?.refresh();
2273
+ const runPromise = this.app.run();
2274
+ // Hold the banner animation before commands if we need to run the interview
2275
+ if (pendingUserInterview) {
2276
+ bannerWidget.hold();
2277
+ }
2278
+ bannerWidget.start();
2279
+ // Run user interview inside the ChatView if USER.md needs setup
2280
+ if (pendingUserInterview) {
2281
+ this.startUserInterview(teammatesDir, bannerWidget);
2282
+ }
2283
+ await runPromise;
2284
+ }
2285
+ /**
2286
+ * Handle paste events from ChatView.
2287
+ * For multi-line or large pastes, store the text and replace
2288
+ * the input with a compact placeholder that gets expanded on submit.
2289
+ */
2290
+ /** Image extensions for drag & drop detection. */
2291
+ // IMAGE_EXTS is now imported from ./cli-utils.js
2292
+ handlePaste(text) {
2293
+ if (!this.chatView)
2294
+ return;
2295
+ // Check if the pasted text is a file path to an image (drag & drop)
2296
+ const trimmed = text.trim().replace(/^["']|["']$/g, ""); // strip quotes from drag & drop paths
2297
+ if (this.isImagePath(trimmed)) {
2298
+ const current = this.chatView.inputValue;
2299
+ const clean = text.replace(/[\r\n]/g, "");
2300
+ const idx = current.indexOf(clean);
2301
+ if (idx >= 0) {
2302
+ const fileName = trimmed.split(/[/\\]/).pop() || trimmed;
2303
+ const n = ++this.pasteCounter;
2304
+ this.pastedTexts.set(n, `[Image: source: ${trimmed}]`);
2305
+ const placeholder = `[Image ${fileName}]`;
2306
+ const newVal = current.slice(0, idx) +
2307
+ placeholder +
2308
+ current.slice(idx + clean.length);
2309
+ this.chatView.inputValue = newVal;
2310
+ this.refreshView();
2311
+ }
2312
+ return;
2313
+ }
2314
+ const lines = text.split(/\r?\n/).length;
2315
+ const sizeKB = (text.length / 1024).toFixed(1);
2316
+ // Only use placeholder for multi-line or large pastes
2317
+ if (lines <= 1 && text.length < 200)
2318
+ return;
2319
+ const n = ++this.pasteCounter;
2320
+ this.pastedTexts.set(n, text);
2321
+ // Replace the pasted text in the input with a placeholder.
2322
+ // The paste was already inserted by TextInput, so we need to
2323
+ // remove it and insert the placeholder instead.
2324
+ const current = this.chatView.inputValue;
2325
+ // The pasted text (with newlines stripped) was inserted at the cursor.
2326
+ // Find it and replace with placeholder.
2327
+ const clean = text.replace(/[\r\n]/g, "");
2328
+ const idx = current.indexOf(clean);
2329
+ if (idx >= 0) {
2330
+ const placeholder = `[Pasted text #${n} +${lines} lines, ${sizeKB}KB]`;
2331
+ const newVal = current.slice(0, idx) + placeholder + current.slice(idx + clean.length);
2332
+ this.chatView.inputValue = newVal;
2333
+ }
2334
+ this.refreshView();
2335
+ }
2336
+ /** Check if a string looks like a path to an image file. */
2337
+ isImagePath(text) {
2338
+ return isImagePath(text);
2339
+ }
2340
+ /** Handle line submission from ChatView. */
2341
+ async handleSubmit(rawLine) {
2342
+ this.clearWordwheel();
2343
+ this.wordwheelItems = [];
2344
+ this.wordwheelIndex = -1;
2345
+ // Expand paste placeholders with actual content
2346
+ let input = rawLine.replace(/\[Pasted text #(\d+) \+\d+ lines, [\d.]+KB\]\s*/g, (_match, num) => {
2347
+ const n = parseInt(num, 10);
2348
+ const text = this.pastedTexts.get(n);
2349
+ if (text) {
2350
+ this.pastedTexts.delete(n);
2351
+ return `${text}\n`;
2352
+ }
2353
+ return "";
2354
+ });
2355
+ // Expand [Image filename] placeholders with stored image source paths
2356
+ input = input
2357
+ .replace(/\[Image [^\]]+\]/g, (match) => {
2358
+ // Find the matching pastedText entry by checking stored values
2359
+ for (const [n, stored] of this.pastedTexts) {
2360
+ if (stored.startsWith("[Image: source:")) {
2361
+ this.pastedTexts.delete(n);
2362
+ return stored;
2363
+ }
750
2364
  }
2365
+ return match;
2366
+ })
2367
+ .trim();
2368
+ // Expand [quoted reply] placeholder with blockquoted message
2369
+ if (this._pendingQuotedReply && input.includes("[quoted reply]")) {
2370
+ const quoted = this._pendingQuotedReply
2371
+ .split("\n")
2372
+ .map((l) => `> ${l}`)
2373
+ .join("\n");
2374
+ const before = input.slice(0, input.indexOf("[quoted reply]")).trimEnd();
2375
+ const after = input
2376
+ .slice(input.indexOf("[quoted reply]") + "[quoted reply]".length)
2377
+ .trimStart();
2378
+ const parts = [before, quoted];
2379
+ if (after)
2380
+ parts.push(after);
2381
+ input = parts.join("\n");
2382
+ this._pendingQuotedReply = null;
2383
+ }
2384
+ else {
2385
+ this._pendingQuotedReply = null;
2386
+ }
2387
+ if (!input)
2388
+ return;
2389
+ // Handoff actions
2390
+ if (input === "/approve") {
2391
+ this.handleBulkHandoff("Approve all");
2392
+ return;
2393
+ }
2394
+ if (input === "/always-approve") {
2395
+ this.handleBulkHandoff("Always approve");
2396
+ return;
2397
+ }
2398
+ if (input === "/reject") {
2399
+ this.handleBulkHandoff("Reject all");
2400
+ return;
2401
+ }
2402
+ if (input === "/approve-retro") {
2403
+ this.handleBulkRetro("Approve all");
2404
+ return;
2405
+ }
2406
+ if (input === "/reject-retro") {
2407
+ this.handleBulkRetro("Reject all");
2408
+ return;
2409
+ }
2410
+ // Slash commands
2411
+ if (input.startsWith("/")) {
751
2412
  this.dispatching = true;
752
2413
  try {
753
2414
  await this.dispatch(input);
754
2415
  }
755
2416
  catch (err) {
756
- console.log(chalk.red(`Error: ${err.message}`));
2417
+ this.feedLine(tp.error(`Error: ${err.message}`));
757
2418
  }
758
2419
  finally {
759
2420
  this.dispatching = false;
760
2421
  }
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
- });
2422
+ this.refreshView();
2423
+ return;
2424
+ }
2425
+ // Everything else gets queued
2426
+ this.conversationHistory.push({ role: "user", text: input });
2427
+ this.printUserMessage(input);
2428
+ this.queueTask(input);
2429
+ this.refreshView();
783
2430
  }
784
2431
  printBanner(teammates) {
785
2432
  const registry = this.orchestrator.getRegistry();
786
2433
  const termWidth = process.stdout.columns || 100;
787
- const divider = chalk.gray("─".repeat(termWidth));
788
- // Detect recall — check for .index/ inside any teammate folder
2434
+ // Detect recall from services.json
789
2435
  let recallInstalled = false;
790
2436
  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
- }
2437
+ const svcJson = JSON.parse(readFileSync(join(this.teammatesDir, "services.json"), "utf-8"));
2438
+ recallInstalled = !!(svcJson && "recall" in svcJson);
804
2439
  }
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
- ]);
2440
+ catch {
2441
+ /* no services.json or invalid */
2442
+ }
2443
+ this.feedLine();
2444
+ this.feedLine(concat(tp.bold(" Teammates"), tp.muted(` v${PKG_VERSION}`)));
2445
+ this.feedLine(concat(tp.text(` ${this.adapterName}`), tp.muted(` · ${teammates.length} teammate${teammates.length === 1 ? "" : "s"}`)));
2446
+ this.feedLine(` ${process.cwd()}`);
2447
+ this.feedLine(recallInstalled
2448
+ ? tp.success(" ● recall installed")
2449
+ : tp.warning(" ○ recall not installed"));
816
2450
  // Roster
817
- console.log();
2451
+ this.feedLine();
818
2452
  for (const name of teammates) {
819
2453
  const t = registry.get(name);
820
2454
  if (t) {
821
- console.log(chalk.gray(" ") +
822
- chalk.cyan("●") +
823
- chalk.cyan(` @${name}`.padEnd(14)) +
824
- chalk.gray(t.role));
2455
+ this.feedLine(concat(tp.muted(" "), tp.accent(`● @${name.padEnd(14)}`), tp.muted(t.role)));
825
2456
  }
826
2457
  }
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
- ];
2458
+ this.feedLine();
2459
+ this.feedLine(tp.muted("─".repeat(termWidth)));
2460
+ // Quick reference — 3 columns (different set for first run vs normal)
2461
+ let col1;
2462
+ let col2;
2463
+ let col3;
2464
+ if (teammates.length === 0) {
2465
+ // First run — no teammates yet
2466
+ col1 = [
2467
+ ["/init", "set up teammates"],
2468
+ ["/install", "add a service"],
2469
+ ];
2470
+ col2 = [
2471
+ ["/help", "all commands"],
2472
+ ["/exit", "exit session"],
2473
+ ];
2474
+ col3 = [
2475
+ ["", ""],
2476
+ ["", ""],
2477
+ ];
2478
+ }
2479
+ else {
2480
+ col1 = [
2481
+ ["@mention", "assign to teammate"],
2482
+ ["text", "auto-route task"],
2483
+ ["[image]", "drag & drop images"],
2484
+ ];
2485
+ col2 = [
2486
+ ["/status", "teammates & queue"],
2487
+ ["/compact", "compact memory"],
2488
+ ["/retro", "run retrospective"],
2489
+ ];
2490
+ col3 = [
2491
+ [
2492
+ recallInstalled ? "/copy" : "/install",
2493
+ recallInstalled ? "copy session text" : "add a service",
2494
+ ],
2495
+ ["/help", "all commands"],
2496
+ ["/exit", "exit session"],
2497
+ ];
2498
+ }
845
2499
  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}`);
2500
+ this.feedLine(concat(tp.accent(` ${col1[i][0]}`.padEnd(12)), tp.muted(col1[i][1].padEnd(22)), tp.accent(col2[i][0].padEnd(12)), tp.muted(col2[i][1].padEnd(22)), tp.accent(col3[i][0].padEnd(12)), tp.muted(col3[i][1])));
850
2501
  }
851
- console.log();
852
- console.log(divider);
2502
+ this.feedLine();
2503
+ this.refreshView();
853
2504
  }
854
2505
  registerCommands() {
855
2506
  const cmds = [
856
2507
  {
857
2508
  name: "status",
858
- aliases: ["s"],
2509
+ aliases: ["s", "queue", "qu"],
859
2510
  usage: "/status",
860
- description: "Show teammate roster and session status",
2511
+ description: "Show teammates, active tasks, and queue",
861
2512
  run: () => this.cmdStatus(),
862
2513
  },
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
2514
  {
878
2515
  name: "help",
879
2516
  aliases: ["h", "?"],
@@ -888,26 +2525,19 @@ class TeammatesREPL {
888
2525
  description: "Show raw agent output from the last task",
889
2526
  run: (args) => this.cmdDebug(args),
890
2527
  },
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
2528
  {
899
2529
  name: "cancel",
900
2530
  aliases: [],
901
- usage: "/cancel <n>",
2531
+ usage: "/cancel [n]",
902
2532
  description: "Cancel a queued task by number",
903
2533
  run: (args) => this.cmdCancel(args),
904
2534
  },
905
2535
  {
906
2536
  name: "init",
907
2537
  aliases: ["onboard", "setup"],
908
- usage: "/init",
909
- description: "Run onboarding to set up teammates for this project",
910
- run: () => this.cmdInit(),
2538
+ usage: "/init [from-path]",
2539
+ description: "Set up teammates (or import from another project)",
2540
+ run: (args) => this.cmdInit(args),
911
2541
  },
912
2542
  {
913
2543
  name: "clear",
@@ -919,17 +2549,62 @@ class TeammatesREPL {
919
2549
  {
920
2550
  name: "install",
921
2551
  aliases: [],
922
- usage: "/install <service>",
2552
+ usage: "/install [service]",
923
2553
  description: "Install a teammates service (e.g. recall)",
924
2554
  run: (args) => this.cmdInstall(args),
925
2555
  },
2556
+ {
2557
+ name: "compact",
2558
+ aliases: [],
2559
+ usage: "/compact [teammate]",
2560
+ description: "Compact daily logs into weekly/monthly summaries",
2561
+ run: (args) => this.cmdCompact(args),
2562
+ },
2563
+ {
2564
+ name: "retro",
2565
+ aliases: [],
2566
+ usage: "/retro [teammate]",
2567
+ description: "Run a structured self-retrospective for a teammate",
2568
+ run: (args) => this.cmdRetro(args),
2569
+ },
2570
+ {
2571
+ name: "copy",
2572
+ aliases: ["cp"],
2573
+ usage: "/copy",
2574
+ description: "Copy session text to clipboard",
2575
+ run: () => this.cmdCopy(),
2576
+ },
2577
+ {
2578
+ name: "user",
2579
+ aliases: [],
2580
+ usage: "/user [change]",
2581
+ description: "View or update USER.md",
2582
+ run: (args) => this.cmdUser(args),
2583
+ },
2584
+ {
2585
+ name: "btw",
2586
+ aliases: [],
2587
+ usage: "/btw [question]",
2588
+ description: "Ask a quick side question without interrupting the main conversation",
2589
+ run: (args) => this.cmdBtw(args),
2590
+ },
2591
+ {
2592
+ name: "theme",
2593
+ aliases: [],
2594
+ usage: "/theme",
2595
+ description: "Show current theme colors",
2596
+ run: () => this.cmdTheme(),
2597
+ },
926
2598
  {
927
2599
  name: "exit",
928
2600
  aliases: ["q", "quit"],
929
2601
  usage: "/exit",
930
2602
  description: "Exit the session",
931
2603
  run: async () => {
932
- console.log(chalk.gray("Shutting down..."));
2604
+ this.feedLine(tp.muted("Shutting down..."));
2605
+ this.stopRecallWatch();
2606
+ if (this.app)
2607
+ this.app.stop();
933
2608
  await this.orchestrator.shutdown();
934
2609
  process.exit(0);
935
2610
  },
@@ -943,471 +2618,395 @@ class TeammatesREPL {
943
2618
  }
944
2619
  }
945
2620
  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
- }
2621
+ // Dispatch only handles slash commands — text input is queued via queueTask()
2622
+ const spaceIdx = input.indexOf(" ");
2623
+ const cmdName = spaceIdx > 0 ? input.slice(1, spaceIdx) : input.slice(1);
2624
+ const cmdArgs = spaceIdx > 0 ? input.slice(spaceIdx + 1).trim() : "";
2625
+ const cmd = this.commands.get(cmdName);
2626
+ if (cmd) {
2627
+ await cmd.run(cmdArgs);
964
2628
  }
965
2629
  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);
2630
+ this.feedLine(tp.warning(`Unknown command: /${cmdName}`));
2631
+ this.feedLine(tp.muted("Type /help for available commands"));
991
2632
  }
992
2633
  }
993
2634
  // ─── Event handler ───────────────────────────────────────────────
994
2635
  handleEvent(event) {
995
- // When queue is draining in background, never use spinner — it blocks the prompt
996
- const useSpinner = !this.queueDraining;
997
2636
  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
- }
2637
+ case "task_assigned": {
2638
+ // Track this task and start the animated status bar
2639
+ const key = event.assignment.teammate;
2640
+ this.activeTasks.set(key, {
2641
+ teammate: event.assignment.teammate,
2642
+ task: event.assignment.task,
2643
+ });
2644
+ this.startStatusAnimation();
1010
2645
  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);
2646
+ }
2647
+ case "task_completed": {
2648
+ // Remove from active tasks
2649
+ this.activeTasks.delete(event.result.teammate);
2650
+ // Stop animation if no more active tasks
2651
+ if (this.activeTasks.size === 0) {
2652
+ this.stopStatusAnimation();
1033
2653
  }
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;
2654
+ if (!this.chatView)
2655
+ this.input.deactivateAndErase();
2656
+ const raw = event.result.rawOutput ?? "";
2657
+ // Strip protocol artifacts
2658
+ const cleaned = raw
2659
+ .replace(/^TO:\s*\S+\s*\n/im, "")
2660
+ .replace(/^#\s+.+\n*/m, "")
2661
+ .replace(/```handoff\s*\n@\w+\s*\n[\s\S]*?```/g, "")
2662
+ .replace(/```json\s*\n\s*\{[\s\S]*?\}\s*\n\s*```\s*$/g, "")
2663
+ .trim();
2664
+ const sizeKB = cleaned ? Buffer.byteLength(cleaned, "utf-8") / 1024 : 0;
2665
+ // Header: "teammate: subject"
2666
+ const subject = event.result.summary || "Task completed";
2667
+ this.feedLine(concat(tp.accent(`${event.result.teammate}: `), tp.text(subject)));
2668
+ this.lastCleanedOutput = cleaned;
2669
+ if (sizeKB > 5) {
2670
+ const tmpFile = join(tmpdir(), `teammates-${event.result.teammate}-${Date.now()}.md`);
2671
+ writeFileSync(tmpFile, cleaned, "utf-8");
2672
+ this.feedLine(tp.muted(` ${"─".repeat(40)}`));
2673
+ this.feedLine(tp.warning(` ⚠ Response is ${sizeKB.toFixed(1)}KB — saved to temp file:`));
2674
+ this.feedLine(tp.muted(` ${tmpFile}`));
2675
+ this.feedLine(tp.muted(` ${"─".repeat(40)}`));
1042
2676
  }
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;
2677
+ else if (cleaned) {
2678
+ this.feedMarkdown(cleaned);
1052
2679
  }
1053
2680
  else {
1054
- console.log(chalk.red(` ${event.teammate}: ${event.error}`));
2681
+ this.feedLine(tp.muted(" (no response text — the agent may have only performed tool actions)"));
2682
+ this.feedLine(tp.muted(` Use /debug ${event.result.teammate} to view full output`));
1055
2683
  }
2684
+ // Render handoffs
2685
+ const handoffs = event.result.handoffs;
2686
+ if (handoffs.length > 0) {
2687
+ this.renderHandoffs(event.result.teammate, handoffs);
2688
+ }
2689
+ // Clickable [reply] [copy] actions after the response
2690
+ if (this.chatView && cleaned) {
2691
+ const t = theme();
2692
+ const teammate = event.result.teammate;
2693
+ const replyId = `reply-${teammate}-${Date.now()}`;
2694
+ this._replyContexts.set(replyId, { teammate, message: cleaned });
2695
+ this.chatView.appendActionList([
2696
+ {
2697
+ id: replyId,
2698
+ normalStyle: this.makeSpan({
2699
+ text: " [reply]",
2700
+ style: { fg: t.textDim },
2701
+ }),
2702
+ hoverStyle: this.makeSpan({
2703
+ text: " [reply]",
2704
+ style: { fg: t.accent },
2705
+ }),
2706
+ },
2707
+ {
2708
+ id: "copy",
2709
+ normalStyle: this.makeSpan({
2710
+ text: " [copy]",
2711
+ style: { fg: t.textDim },
2712
+ }),
2713
+ hoverStyle: this.makeSpan({
2714
+ text: " [copy]",
2715
+ style: { fg: t.accent },
2716
+ }),
2717
+ },
2718
+ ]);
2719
+ }
2720
+ this.feedLine();
2721
+ // Auto-detect new teammates added during this task
2722
+ this.refreshTeammates();
2723
+ this.showPrompt();
1056
2724
  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
2725
  }
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);
2726
+ case "error":
2727
+ this.activeTasks.delete(event.teammate);
2728
+ if (this.activeTasks.size === 0)
2729
+ this.stopStatusAnimation();
2730
+ if (!this.chatView)
2731
+ this.input.deactivateAndErase();
2732
+ this.feedLine(tp.error(` ✖ ${event.teammate}: ${event.error}`));
2733
+ this.showPrompt();
2734
+ break;
1149
2735
  }
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
2736
  }
1156
2737
  async cmdStatus() {
1157
2738
  const statuses = this.orchestrator.getAllStatuses();
1158
2739
  const registry = this.orchestrator.getRegistry();
1159
- console.log();
1160
- console.log(chalk.bold(" Status"));
1161
- console.log(chalk.gray(" " + "─".repeat(60)));
2740
+ this.feedLine();
2741
+ this.feedLine(tp.bold(" Status"));
2742
+ this.feedLine(tp.muted(` ${"─".repeat(50)}`));
1162
2743
  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
2744
  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(", ")));
2745
+ const active = this.agentActive.get(name);
2746
+ const queued = this.taskQueue.filter((e) => e.teammate === name);
2747
+ // Teammate name + state
2748
+ const stateLabel = active ? "working" : status.state;
2749
+ const stateColor = stateLabel === "working"
2750
+ ? tp.info(` (${stateLabel})`)
2751
+ : tp.muted(` (${stateLabel})`);
2752
+ this.feedLine(concat(tp.accent(` @${name}`), stateColor));
2753
+ // Role
2754
+ if (t) {
2755
+ this.feedLine(tp.muted(` ${t.role}`));
2756
+ }
2757
+ // Active task
2758
+ if (active) {
2759
+ const taskText = active.task.length > 60
2760
+ ? `${active.task.slice(0, 57)}…`
2761
+ : active.task;
2762
+ this.feedLine(concat(tp.info(" ▸ "), tp.text(taskText)));
2763
+ }
2764
+ // Queued tasks
2765
+ for (let i = 0; i < queued.length; i++) {
2766
+ const taskText = queued[i].task.length > 60
2767
+ ? `${queued[i].task.slice(0, 57)}…`
2768
+ : queued[i].task;
2769
+ this.feedLine(concat(tp.muted(` ${i + 1}. `), tp.muted(taskText)));
2770
+ }
2771
+ // Last result
2772
+ if (!active && status.lastSummary) {
2773
+ const time = status.lastTimestamp
2774
+ ? ` ${relativeTime(status.lastTimestamp)}`
2775
+ : "";
2776
+ this.feedLine(tp.muted(` last: ${status.lastSummary.slice(0, 50)}${time}`));
1195
2777
  }
2778
+ this.feedLine();
1196
2779
  }
1197
- console.log();
2780
+ this.refreshView();
1198
2781
  }
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}`));
2782
+ async cmdDebug(argsStr) {
2783
+ const arg = argsStr.trim().replace(/^@/, "");
2784
+ // Resolve targets
2785
+ let targets;
2786
+ if (arg === "everyone") {
2787
+ targets = [];
2788
+ for (const [name, result] of this.lastResults) {
2789
+ if (name !== this.adapterName && result.rawOutput) {
2790
+ targets.push({ name, result });
2791
+ }
2792
+ }
2793
+ if (targets.length === 0) {
2794
+ this.feedLine(tp.muted(" No raw output available from any teammate."));
2795
+ this.refreshView();
1206
2796
  return;
1207
2797
  }
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
2798
  }
1216
2799
  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);
2800
+ const result = arg ? this.lastResults.get(arg) : this.lastResult;
2801
+ if (!result?.rawOutput) {
2802
+ this.feedLine(tp.muted(" No raw output available." +
2803
+ (arg ? "" : " Try: /debug <teammate>")));
2804
+ this.refreshView();
2805
+ return;
1230
2806
  }
2807
+ targets = [{ name: result.teammate, result }];
1231
2808
  }
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."));
2809
+ for (const { name, result } of targets) {
2810
+ this.feedLine();
2811
+ this.feedLine(tp.muted(` ── raw output from ${name} ──`));
2812
+ this.feedLine();
2813
+ this.feedMarkdown(result.rawOutput);
2814
+ this.feedLine();
2815
+ this.feedLine(tp.muted(" ── end raw output ──"));
1237
2816
  }
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;
2817
+ // [copy] action for the debug output
2818
+ if (this.chatView) {
2819
+ const t = theme();
2820
+ this.lastCleanedOutput = targets
2821
+ .map((t) => t.result.rawOutput)
2822
+ .join("\n\n");
2823
+ this.chatView.appendActionList([
2824
+ {
2825
+ id: "copy",
2826
+ normalStyle: this.makeSpan({
2827
+ text: " [copy]",
2828
+ style: { fg: t.textDim },
2829
+ }),
2830
+ hoverStyle: this.makeSpan({
2831
+ text: " [copy]",
2832
+ style: { fg: t.accent },
2833
+ }),
2834
+ },
2835
+ ]);
1248
2836
  }
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();
2837
+ this.feedLine();
2838
+ this.refreshView();
1256
2839
  }
1257
2840
  async cmdCancel(argsStr) {
1258
2841
  const n = parseInt(argsStr.trim(), 10);
1259
- if (isNaN(n) || n < 1 || n > this.taskQueue.length) {
2842
+ if (Number.isNaN(n) || n < 1 || n > this.taskQueue.length) {
1260
2843
  if (this.taskQueue.length === 0) {
1261
- console.log(chalk.gray(" Queue is empty."));
2844
+ this.feedLine(tp.muted(" Queue is empty."));
1262
2845
  }
1263
2846
  else {
1264
- console.log(chalk.yellow(` Usage: /cancel <1-${this.taskQueue.length}>`));
2847
+ this.feedLine(tp.warning(` Usage: /cancel <1-${this.taskQueue.length}>`));
1265
2848
  }
2849
+ this.refreshView();
1266
2850
  return;
1267
2851
  }
1268
2852
  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;
2853
+ this.feedLine(concat(tp.muted(" Cancelled: "), tp.accent(`@${removed.teammate}`), tp.muted(" — "), tp.text(removed.task.slice(0, 60))));
2854
+ this.refreshView();
2855
+ }
2856
+ /** Drain tasks for a single agent — runs in parallel with other agents. */
2857
+ async drainAgentQueue(agent) {
2858
+ while (true) {
2859
+ const idx = this.taskQueue.findIndex((e) => e.teammate === agent);
2860
+ if (idx < 0)
2861
+ break;
2862
+ const entry = this.taskQueue.splice(idx, 1)[0];
2863
+ this.agentActive.set(agent, entry);
2864
+ try {
2865
+ if (entry.type === "compact") {
2866
+ await this.runCompact(entry.teammate);
2867
+ }
2868
+ else {
2869
+ // btw tasks skip conversation context (side question, not part of main thread)
2870
+ const extraContext = entry.type === "btw" ? "" : this.buildConversationContext();
2871
+ const result = await this.orchestrator.assign({
2872
+ teammate: entry.teammate,
2873
+ task: entry.task,
2874
+ extraContext: extraContext || undefined,
2875
+ });
2876
+ // btw results are not stored in conversation history
2877
+ if (entry.type !== "btw") {
2878
+ this.storeResult(result);
2879
+ }
2880
+ if (entry.type === "retro") {
2881
+ this.handleRetroResult(result);
2882
+ }
2883
+ }
1280
2884
  }
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"));
2885
+ catch (err) {
2886
+ // Handle spawn failures, network errors, etc. gracefully
2887
+ this.activeTasks.delete(agent);
2888
+ if (this.activeTasks.size === 0)
2889
+ this.stopStatusAnimation();
2890
+ const msg = err?.message ?? String(err);
2891
+ this.feedLine(tp.error(` ✖ @${agent}: ${msg}`));
2892
+ this.refreshView();
1301
2893
  }
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; });
2894
+ this.agentActive.delete(agent);
1334
2895
  }
1335
2896
  }
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);
2897
+ async cmdInit(argsStr) {
2898
+ const cwd = process.cwd();
2899
+ const teammatesDir = join(cwd, ".teammates");
2900
+ await mkdir(teammatesDir, { recursive: true });
2901
+ const fromPath = argsStr.trim();
2902
+ if (fromPath) {
2903
+ // Import mode: /init <path-to-another-project>
2904
+ const resolved = resolve(fromPath);
2905
+ let sourceDir;
2906
+ try {
2907
+ const s = await stat(join(resolved, ".teammates"));
2908
+ if (s.isDirectory()) {
2909
+ sourceDir = join(resolved, ".teammates");
2910
+ }
2911
+ else {
2912
+ sourceDir = resolved;
2913
+ }
2914
+ }
2915
+ catch {
2916
+ sourceDir = resolved;
2917
+ }
2918
+ try {
2919
+ const { teammates, files } = await importTeammates(sourceDir, teammatesDir);
2920
+ if (teammates.length === 0) {
2921
+ this.feedLine(tp.warning(` No teammates found at ${sourceDir}`));
2922
+ this.refreshView();
2923
+ return;
2924
+ }
2925
+ this.feedLine(tp.success(` Imported ${teammates.length} teammate${teammates.length > 1 ? "s" : ""}: ${teammates.join(", ")} (${files.length} files)`));
2926
+ // Queue one adaptation task per teammate
2927
+ this.feedLine(tp.muted(` Queuing ${this.adapterName} to adapt each teammate individually...`));
2928
+ for (const name of teammates) {
2929
+ const prompt = await buildAdaptationPrompt(teammatesDir, name);
2930
+ this.taskQueue.push({
2931
+ type: "agent",
2932
+ teammate: this.adapterName,
2933
+ task: prompt,
1353
2934
  });
1354
- continue;
1355
2935
  }
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);
2936
+ this.kickDrain();
2937
+ }
2938
+ catch (err) {
2939
+ this.feedLine(tp.error(` Import failed: ${err.message}`));
1366
2940
  }
1367
- console.log(chalk.green(" ✔ Queue complete."));
1368
- this.rl.prompt();
1369
2941
  }
1370
- finally {
1371
- this.queueDraining = false;
2942
+ else {
2943
+ // Normal onboarding
2944
+ await this.runOnboardingAgent(this.adapter, cwd);
1372
2945
  }
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
2946
  // 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."));
2947
+ const added = await this.orchestrator.refresh();
2948
+ if (added.length > 0) {
2949
+ const registry = this.orchestrator.getRegistry();
2950
+ if ("roster" in this.adapter) {
2951
+ this.adapter.roster = this.orchestrator
2952
+ .listTeammates()
2953
+ .map((name) => {
2954
+ const t = registry.get(name);
2955
+ return { name: t.name, role: t.role, ownership: t.ownership };
2956
+ });
2957
+ }
2958
+ }
2959
+ this.feedLine(tp.muted(" Run /status to see the roster."));
2960
+ this.refreshView();
1381
2961
  }
1382
2962
  async cmdInstall(argsStr) {
1383
2963
  const serviceName = argsStr.trim().toLowerCase();
1384
2964
  if (!serviceName) {
1385
- console.log(chalk.bold("\n Available services:"));
2965
+ this.feedLine(tp.bold("\n Available services:"));
1386
2966
  for (const [name, svc] of Object.entries(SERVICE_REGISTRY)) {
1387
- console.log(` ${chalk.cyan(name.padEnd(16))}${chalk.gray(svc.description)}`);
2967
+ this.feedLine(concat(tp.accent(name.padEnd(16)), tp.muted(svc.description)));
1388
2968
  }
1389
- console.log();
2969
+ this.feedLine();
2970
+ this.refreshView();
1390
2971
  return;
1391
2972
  }
1392
2973
  const service = SERVICE_REGISTRY[serviceName];
1393
2974
  if (!service) {
1394
- console.log(chalk.red(` Unknown service: ${serviceName}`));
1395
- console.log(chalk.gray(` Available: ${Object.keys(SERVICE_REGISTRY).join(", ")}`));
2975
+ this.feedLine(tp.warning(` Unknown service: ${serviceName}`));
2976
+ this.feedLine(tp.muted(` Available: ${Object.keys(SERVICE_REGISTRY).join(", ")}`));
2977
+ this.refreshView();
1396
2978
  return;
1397
2979
  }
1398
2980
  // Install the package globally
1399
- const spinner = ora({
1400
- text: chalk.blue(serviceName) + chalk.gray(` installing ${service.package}...`),
1401
- spinner: "dots",
1402
- }).start();
2981
+ if (this.chatView) {
2982
+ this.chatView.setProgress(`Installing ${service.package}...`);
2983
+ this.refreshView();
2984
+ }
2985
+ let installSpinner = null;
2986
+ if (!this.chatView) {
2987
+ installSpinner = ora({
2988
+ text: chalk.blue(serviceName) +
2989
+ chalk.gray(` installing ${service.package}...`),
2990
+ spinner: "dots",
2991
+ }).start();
2992
+ }
1403
2993
  try {
1404
2994
  await execAsync(`npm install -g ${service.package}`, {
1405
2995
  timeout: 5 * 60 * 1000,
1406
2996
  });
1407
- spinner.stop();
2997
+ if (installSpinner)
2998
+ installSpinner.stop();
2999
+ if (this.chatView)
3000
+ this.chatView.setProgress(null);
1408
3001
  }
1409
3002
  catch (err) {
1410
- spinner.fail(chalk.red(`Install failed: ${err.message}`));
3003
+ if (installSpinner)
3004
+ installSpinner.fail(chalk.red(`Install failed: ${err.message}`));
3005
+ if (this.chatView) {
3006
+ this.chatView.setProgress(null);
3007
+ this.feedLine(tp.error(` ✖ Install failed: ${err.message}`));
3008
+ this.refreshView();
3009
+ }
1411
3010
  return;
1412
3011
  }
1413
3012
  // Verify the binary works
@@ -1416,57 +3015,499 @@ class TeammatesREPL {
1416
3015
  execSync(checkCmdStr, { stdio: "ignore" });
1417
3016
  }
1418
3017
  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.`));
3018
+ this.feedLine(tp.success(` ✔ ${serviceName} installed`));
3019
+ this.feedLine(tp.warning(` ⚠ Restart your terminal to add ${service.checkCmd[0]} to your PATH, then run /install ${serviceName} again to build the index.`));
3020
+ this.refreshView();
1421
3021
  return;
1422
3022
  }
1423
- console.log(chalk.green(` ✔ ${serviceName}`) + chalk.gray(" installed successfully"));
3023
+ this.feedLine(tp.success(` ✔ ${serviceName} installed successfully`));
3024
+ // Register in services.json
3025
+ const svcPath = join(this.teammatesDir, "services.json");
3026
+ let svcJson = {};
3027
+ try {
3028
+ svcJson = JSON.parse(readFileSync(svcPath, "utf-8"));
3029
+ }
3030
+ catch {
3031
+ /* new file */
3032
+ }
3033
+ if (!(serviceName in svcJson)) {
3034
+ svcJson[serviceName] = {};
3035
+ writeFileSync(svcPath, `${JSON.stringify(svcJson, null, 2)}\n`);
3036
+ this.feedLine(tp.muted(` Registered in services.json`));
3037
+ }
1424
3038
  // Build initial index if this service supports it
1425
3039
  if (service.indexCmd) {
1426
- const indexSpinner = ora({
1427
- text: chalk.blue(serviceName) + chalk.gray(` building index...`),
1428
- spinner: "dots",
1429
- }).start();
3040
+ if (this.chatView) {
3041
+ this.chatView.setProgress(`Building ${serviceName} index...`);
3042
+ this.refreshView();
3043
+ }
3044
+ let idxSpinner = null;
3045
+ if (!this.chatView) {
3046
+ idxSpinner = ora({
3047
+ text: chalk.blue(serviceName) + chalk.gray(` building index...`),
3048
+ spinner: "dots",
3049
+ }).start();
3050
+ }
1430
3051
  const indexCmdStr = service.indexCmd.join(" ");
1431
3052
  try {
1432
3053
  await execAsync(indexCmdStr, {
1433
3054
  cwd: resolve(this.teammatesDir, ".."),
1434
3055
  timeout: 5 * 60 * 1000,
1435
3056
  });
1436
- indexSpinner.succeed(chalk.blue(serviceName) + chalk.gray(" index built"));
3057
+ if (idxSpinner)
3058
+ idxSpinner.succeed(chalk.blue(serviceName) + chalk.gray(" index built"));
3059
+ if (this.chatView) {
3060
+ this.chatView.setProgress(null);
3061
+ this.feedLine(tp.success(` ✔ ${serviceName} index built`));
3062
+ }
1437
3063
  }
1438
3064
  catch (err) {
1439
- indexSpinner.warn(chalk.yellow(`Index build failed: ${err.message}`));
3065
+ if (idxSpinner)
3066
+ idxSpinner.warn(chalk.yellow(`Index build failed: ${err.message}`));
3067
+ if (this.chatView) {
3068
+ this.chatView.setProgress(null);
3069
+ this.feedLine(tp.warning(` ⚠ Index build failed: ${err.message}`));
3070
+ }
1440
3071
  }
1441
3072
  }
1442
3073
  // Ask the coding agent to wire the service into the project
1443
3074
  if (service.wireupTask) {
1444
- console.log();
1445
- console.log(chalk.gray(` Wiring up ${serviceName}...`));
3075
+ this.feedLine();
3076
+ this.feedLine(tp.muted(` Wiring up ${serviceName}...`));
3077
+ this.refreshView();
1446
3078
  const result = await this.orchestrator.assign({
1447
3079
  teammate: this.adapterName,
1448
3080
  task: service.wireupTask,
1449
3081
  });
1450
3082
  this.storeResult(result);
1451
3083
  }
3084
+ this.refreshView();
1452
3085
  }
1453
3086
  async cmdClear() {
1454
- // Reset all session state
1455
3087
  this.conversationHistory.length = 0;
1456
3088
  this.lastResult = null;
1457
3089
  this.lastResults.clear();
1458
3090
  this.taskQueue.length = 0;
1459
- this.queueActive = null;
3091
+ this.agentActive.clear();
1460
3092
  this.pastedTexts.clear();
3093
+ this.pendingRetroProposals = [];
1461
3094
  await this.orchestrator.reset();
1462
- // Clear terminal and reprint banner
1463
- process.stdout.write("\x1b[2J\x1b[H");
1464
- this.printBanner(this.orchestrator.listTeammates());
3095
+ if (this.chatView) {
3096
+ this.chatView.clear();
3097
+ this.refreshView();
3098
+ }
3099
+ else {
3100
+ process.stdout.write(esc.clearScreen + esc.moveTo(0, 0));
3101
+ this.printBanner(this.orchestrator.listTeammates());
3102
+ }
3103
+ }
3104
+ /**
3105
+ * Reload the registry from disk. If new teammates appeared,
3106
+ * announce them, update the adapter roster, and refresh statuses.
3107
+ */
3108
+ refreshTeammates() {
3109
+ this.orchestrator
3110
+ .refresh()
3111
+ .then((added) => {
3112
+ if (added.length === 0)
3113
+ return;
3114
+ const registry = this.orchestrator.getRegistry();
3115
+ // Update adapter roster so prompts include the new teammates
3116
+ if ("roster" in this.adapter) {
3117
+ this.adapter.roster = this.orchestrator
3118
+ .listTeammates()
3119
+ .map((name) => {
3120
+ const t = registry.get(name);
3121
+ return { name: t.name, role: t.role, ownership: t.ownership };
3122
+ });
3123
+ }
3124
+ // Announce
3125
+ for (const name of added) {
3126
+ const config = registry.get(name);
3127
+ const role = config?.role ?? "teammate";
3128
+ this.feedLine(concat(tp.success(` ✦ New teammate joined: `), tp.bold(name), tp.muted(` — ${role}`)));
3129
+ }
3130
+ this.refreshView();
3131
+ })
3132
+ .catch(() => { });
3133
+ }
3134
+ startRecallWatch() {
3135
+ // Only start if recall is installed (check services.json)
3136
+ try {
3137
+ const svcJson = JSON.parse(readFileSync(join(this.teammatesDir, "services.json"), "utf-8"));
3138
+ if (!svcJson || !("recall" in svcJson))
3139
+ return;
3140
+ }
3141
+ catch {
3142
+ return; // No services.json — recall not installed
3143
+ }
3144
+ try {
3145
+ this.recallWatchProcess = cpSpawn("teammates-recall", ["watch", "--dir", this.teammatesDir, "--json"], {
3146
+ stdio: ["ignore", "ignore", "ignore"],
3147
+ detached: false,
3148
+ });
3149
+ this.recallWatchProcess.on("error", () => {
3150
+ // Recall binary not found — silently ignore
3151
+ this.recallWatchProcess = null;
3152
+ });
3153
+ this.recallWatchProcess.on("exit", () => {
3154
+ this.recallWatchProcess = null;
3155
+ });
3156
+ }
3157
+ catch {
3158
+ this.recallWatchProcess = null;
3159
+ }
3160
+ }
3161
+ stopRecallWatch() {
3162
+ if (this.recallWatchProcess) {
3163
+ this.recallWatchProcess.kill("SIGTERM");
3164
+ this.recallWatchProcess = null;
3165
+ }
3166
+ }
3167
+ async cmdCompact(argsStr) {
3168
+ const arg = argsStr.trim();
3169
+ const allTeammates = this.orchestrator
3170
+ .listTeammates()
3171
+ .filter((n) => n !== this.adapterName);
3172
+ const names = !arg || arg === "everyone" ? allTeammates : [arg];
3173
+ // Validate all names first
3174
+ const valid = [];
3175
+ for (const name of names) {
3176
+ const teammateDir = join(this.teammatesDir, name);
3177
+ try {
3178
+ const s = await stat(teammateDir);
3179
+ if (!s.isDirectory()) {
3180
+ this.feedLine(tp.warning(` ${name}: not a directory, skipping`));
3181
+ continue;
3182
+ }
3183
+ valid.push(name);
3184
+ }
3185
+ catch {
3186
+ this.feedLine(tp.warning(` ${name}: no directory found, skipping`));
3187
+ }
3188
+ }
3189
+ if (valid.length === 0)
3190
+ return;
3191
+ // Queue a compact task for each teammate
3192
+ for (const name of valid) {
3193
+ this.taskQueue.push({
3194
+ type: "compact",
3195
+ teammate: name,
3196
+ task: "compact + index update",
3197
+ });
3198
+ }
3199
+ this.feedLine();
3200
+ this.feedLine(concat(tp.muted(" Queued compaction for "), tp.accent(valid.map((n) => `@${n}`).join(", ")), tp.muted(` (${valid.length} task${valid.length === 1 ? "" : "s"})`)));
3201
+ this.feedLine();
3202
+ this.refreshView();
3203
+ // Start draining
3204
+ this.kickDrain();
3205
+ }
3206
+ /** Run compaction + recall index update for a single teammate. */
3207
+ async runCompact(name) {
3208
+ const teammateDir = join(this.teammatesDir, name);
3209
+ if (this.chatView) {
3210
+ this.chatView.setProgress(`Compacting ${name}...`);
3211
+ this.refreshView();
3212
+ }
3213
+ let spinner = null;
3214
+ if (!this.chatView) {
3215
+ spinner = ora({ text: `Compacting ${name}...`, color: "cyan" }).start();
3216
+ }
3217
+ try {
3218
+ const result = await compactEpisodic(teammateDir, name);
3219
+ const parts = [];
3220
+ if (result.weekliesCreated.length > 0) {
3221
+ parts.push(`${result.weekliesCreated.length} weekly summaries created`);
3222
+ }
3223
+ if (result.monthliesCreated.length > 0) {
3224
+ parts.push(`${result.monthliesCreated.length} monthly summaries created`);
3225
+ }
3226
+ if (result.dailiesRemoved.length > 0) {
3227
+ parts.push(`${result.dailiesRemoved.length} daily logs compacted`);
3228
+ }
3229
+ if (result.weekliesRemoved.length > 0) {
3230
+ parts.push(`${result.weekliesRemoved.length} old weekly summaries archived`);
3231
+ }
3232
+ if (parts.length === 0) {
3233
+ if (spinner)
3234
+ spinner.info(`${name}: nothing to compact`);
3235
+ if (this.chatView)
3236
+ this.feedLine(tp.muted(` ℹ ${name}: nothing to compact`));
3237
+ }
3238
+ else {
3239
+ if (spinner)
3240
+ spinner.succeed(`${name}: ${parts.join(", ")}`);
3241
+ if (this.chatView)
3242
+ this.feedLine(tp.success(` ✔ ${name}: ${parts.join(", ")}`));
3243
+ }
3244
+ if (this.chatView)
3245
+ this.chatView.setProgress(null);
3246
+ // Trigger recall sync if installed
3247
+ try {
3248
+ const svcJson = JSON.parse(readFileSync(join(this.teammatesDir, "services.json"), "utf-8"));
3249
+ if (svcJson && "recall" in svcJson) {
3250
+ if (this.chatView) {
3251
+ this.chatView.setProgress(`Syncing ${name} index...`);
3252
+ this.refreshView();
3253
+ }
3254
+ let syncSpinner = null;
3255
+ if (!this.chatView) {
3256
+ syncSpinner = ora({
3257
+ text: `Syncing ${name} index...`,
3258
+ color: "cyan",
3259
+ }).start();
3260
+ }
3261
+ await execAsync(`teammates-recall sync --dir "${this.teammatesDir}"`);
3262
+ if (syncSpinner)
3263
+ syncSpinner.succeed(`${name}: index synced`);
3264
+ if (this.chatView) {
3265
+ this.chatView.setProgress(null);
3266
+ this.feedLine(tp.success(` ✔ ${name}: index synced`));
3267
+ }
3268
+ }
3269
+ }
3270
+ catch {
3271
+ /* recall not installed or sync failed — non-fatal */
3272
+ }
3273
+ }
3274
+ catch (err) {
3275
+ const msg = err instanceof Error ? err.message : String(err);
3276
+ if (spinner)
3277
+ spinner.fail(`${name}: ${msg}`);
3278
+ if (this.chatView) {
3279
+ this.chatView.setProgress(null);
3280
+ this.feedLine(tp.error(` ✖ ${name}: ${msg}`));
3281
+ }
3282
+ }
3283
+ this.refreshView();
3284
+ }
3285
+ async cmdRetro(argsStr) {
3286
+ const arg = argsStr.trim().replace(/^@/, "");
3287
+ // Resolve target list
3288
+ const allTeammates = this.orchestrator
3289
+ .listTeammates()
3290
+ .filter((n) => n !== this.adapterName);
3291
+ let targets;
3292
+ if (arg === "everyone") {
3293
+ targets = allTeammates;
3294
+ }
3295
+ else if (arg) {
3296
+ // Validate teammate exists
3297
+ const names = this.orchestrator.listTeammates();
3298
+ if (!names.includes(arg)) {
3299
+ this.feedLine(tp.warning(` Unknown teammate: @${arg}`));
3300
+ this.refreshView();
3301
+ return;
3302
+ }
3303
+ targets = [arg];
3304
+ }
3305
+ else if (this.lastResult) {
3306
+ targets = [this.lastResult.teammate];
3307
+ }
3308
+ else {
3309
+ this.feedLine(tp.warning(" No teammate specified and no recent task to infer from."));
3310
+ this.feedLine(tp.muted(" Usage: /retro <teammate>"));
3311
+ this.refreshView();
3312
+ return;
3313
+ }
3314
+ const retroPrompt = `Run a structured self-retrospective. Review your SOUL.md, WISDOM.md, your last 2-3 weekly summaries (or last 7 daily logs if no weeklies exist), and any typed memories in your memory/ folder.
3315
+
3316
+ Produce a response with these four sections:
3317
+
3318
+ ## 1. What's Working
3319
+ Things you do well, based on evidence from recent work. Patterns worth reinforcing or codifying into wisdom. Cite specific examples from daily logs or memories.
3320
+
3321
+ ## 2. What's Not Working
3322
+ Friction, recurring issues, or patterns that aren't serving the project. Be specific — cite examples from daily logs or memories if possible.
3323
+
3324
+ ## 3. Proposed SOUL.md Changes
3325
+ The core output. Each proposal is a **specific edit** to your SOUL.md. Use this exact format for each proposal:
3326
+
3327
+ **Proposal N: <short title>**
3328
+ - **Section:** <which SOUL.md section to change, e.g. Boundaries, Core Principles, Ownership>
3329
+ - **Before:** <the current text to replace, or "(new entry)" if adding>
3330
+ - **After:** <the exact replacement text>
3331
+ - **Why:** <evidence from recent work justifying the change>
3332
+
3333
+ Only propose changes to your own SOUL.md. If a change affects shared files, note that it needs a handoff.
3334
+
3335
+ ## 4. Questions for the Team
3336
+ Issues that can't be resolved unilaterally — they need input from other teammates or the user.
3337
+
3338
+ **Rules:**
3339
+ - This is a self-review of YOUR work. Do not evaluate other teammates.
3340
+ - Evidence over opinion — cite specific examples.
3341
+ - No busywork — if everything is working well, say "all good, no changes." That's a valid outcome.
3342
+ - Number each proposal (Proposal 1, Proposal 2, etc.) so the user can approve or reject individually.`;
3343
+ const label = targets.length > 1
3344
+ ? targets.map((n) => `@${n}`).join(", ")
3345
+ : `@${targets[0]}`;
3346
+ this.feedLine();
3347
+ this.feedLine(concat(tp.muted(" Queued retro for "), tp.accent(label)));
3348
+ this.feedLine();
3349
+ this.refreshView();
3350
+ for (const name of targets) {
3351
+ this.taskQueue.push({ type: "retro", teammate: name, task: retroPrompt });
3352
+ }
3353
+ this.kickDrain();
3354
+ }
3355
+ /**
3356
+ * Background startup maintenance:
3357
+ * 1. Scan all teammates for daily logs older than a week → compact them
3358
+ * 2. Sync recall indexes if recall is installed
3359
+ */
3360
+ /** Recursively delete files/directories older than maxAgeMs. Removes empty parent dirs. */
3361
+ async cleanOldTempFiles(dir, maxAgeMs) {
3362
+ const now = Date.now();
3363
+ const entries = await readdir(dir, { withFileTypes: true });
3364
+ for (const entry of entries) {
3365
+ const fullPath = join(dir, entry.name);
3366
+ if (entry.isDirectory()) {
3367
+ await this.cleanOldTempFiles(fullPath, maxAgeMs);
3368
+ // Remove dir if now empty
3369
+ const remaining = await readdir(fullPath).catch(() => [""]);
3370
+ if (remaining.length === 0)
3371
+ await rm(fullPath, { recursive: true }).catch(() => { });
3372
+ }
3373
+ else {
3374
+ const info = await stat(fullPath).catch(() => null);
3375
+ if (info && now - info.mtimeMs > maxAgeMs) {
3376
+ await unlink(fullPath).catch(() => { });
3377
+ }
3378
+ }
3379
+ }
3380
+ }
3381
+ async startupMaintenance() {
3382
+ // Clean up .teammates/.tmp files older than 1 week
3383
+ const tmpDir = join(this.teammatesDir, ".tmp");
3384
+ try {
3385
+ await this.cleanOldTempFiles(tmpDir, 7 * 24 * 60 * 60 * 1000);
3386
+ }
3387
+ catch {
3388
+ /* .tmp dir may not exist yet — non-fatal */
3389
+ }
3390
+ const teammates = this.orchestrator
3391
+ .listTeammates()
3392
+ .filter((n) => n !== this.adapterName);
3393
+ if (teammates.length === 0)
3394
+ return;
3395
+ // Check if recall is installed
3396
+ let recallInstalled = false;
3397
+ try {
3398
+ const svcJson = JSON.parse(readFileSync(join(this.teammatesDir, "services.json"), "utf-8"));
3399
+ recallInstalled = !!(svcJson && "recall" in svcJson);
3400
+ }
3401
+ catch {
3402
+ /* no services.json */
3403
+ }
3404
+ // 1. Check each teammate for stale daily logs (older than 7 days)
3405
+ const oneWeekAgo = new Date();
3406
+ oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
3407
+ const cutoff = oneWeekAgo.toISOString().slice(0, 10); // YYYY-MM-DD
3408
+ const needsCompact = [];
3409
+ for (const name of teammates) {
3410
+ const memoryDir = join(this.teammatesDir, name, "memory");
3411
+ try {
3412
+ const entries = await readdir(memoryDir);
3413
+ const hasStale = entries.some((e) => {
3414
+ if (!e.endsWith(".md"))
3415
+ return false;
3416
+ const stem = e.replace(".md", "");
3417
+ return /^\d{4}-\d{2}-\d{2}$/.test(stem) && stem < cutoff;
3418
+ });
3419
+ if (hasStale)
3420
+ needsCompact.push(name);
3421
+ }
3422
+ catch {
3423
+ /* no memory dir */
3424
+ }
3425
+ }
3426
+ if (needsCompact.length > 0) {
3427
+ this.feedLine(concat(tp.muted(" Compacting stale logs for "), tp.accent(needsCompact.map((n) => `@${n}`).join(", ")), tp.muted("...")));
3428
+ this.refreshView();
3429
+ for (const name of needsCompact) {
3430
+ await this.runCompact(name);
3431
+ }
3432
+ }
3433
+ // 2. Sync recall indexes if installed
3434
+ if (recallInstalled) {
3435
+ try {
3436
+ await execAsync(`teammates-recall sync --dir "${this.teammatesDir}"`);
3437
+ }
3438
+ catch {
3439
+ /* sync failed — non-fatal */
3440
+ }
3441
+ }
3442
+ }
3443
+ async cmdCopy() {
3444
+ this.doCopy(); // copies entire session
3445
+ }
3446
+ /** Build the full chat session as a markdown document. */
3447
+ buildSessionMarkdown() {
3448
+ if (this.conversationHistory.length === 0)
3449
+ return "";
3450
+ const lines = [];
3451
+ lines.push(`# Chat Session\n`);
3452
+ for (const entry of this.conversationHistory) {
3453
+ if (entry.role === "user") {
3454
+ lines.push(`**User:** ${entry.text}\n`);
3455
+ }
3456
+ else {
3457
+ // Strip protocol artifacts from the raw output
3458
+ const cleaned = entry.text
3459
+ .replace(/^TO:\s*\S+\s*\n/im, "")
3460
+ .replace(/```json\s*\n\s*\{[\s\S]*?\}\s*\n\s*```\s*$/g, "")
3461
+ .trim();
3462
+ lines.push(`**${entry.role}:**\n\n${cleaned}\n`);
3463
+ }
3464
+ lines.push("---\n");
3465
+ }
3466
+ return lines.join("\n");
3467
+ }
3468
+ doCopy(content) {
3469
+ // Build content: if none specified, export the entire chat session as markdown
3470
+ const text = content ?? this.buildSessionMarkdown();
3471
+ if (!text) {
3472
+ this.feedLine(tp.muted(" Nothing to copy."));
3473
+ this.refreshView();
3474
+ return;
3475
+ }
3476
+ try {
3477
+ const isWin = process.platform === "win32";
3478
+ const cmd = isWin
3479
+ ? "clip"
3480
+ : process.platform === "darwin"
3481
+ ? "pbcopy"
3482
+ : "xclip -selection clipboard";
3483
+ const child = execCb(cmd, () => { });
3484
+ child.stdin?.write(text);
3485
+ child.stdin?.end();
3486
+ // Show brief "Copied" message in the progress area
3487
+ if (this.chatView) {
3488
+ this.chatView.setProgress(concat(tp.success("✔ "), tp.muted("Copied to clipboard")));
3489
+ this.refreshView();
3490
+ setTimeout(() => {
3491
+ this.chatView.setProgress(null);
3492
+ this.refreshView();
3493
+ }, 1500);
3494
+ }
3495
+ }
3496
+ catch {
3497
+ if (this.chatView) {
3498
+ this.chatView.setProgress(concat(tp.error("✖ "), tp.muted("Failed to copy")));
3499
+ this.refreshView();
3500
+ setTimeout(() => {
3501
+ this.chatView.setProgress(null);
3502
+ this.refreshView();
3503
+ }, 1500);
3504
+ }
3505
+ }
1465
3506
  }
1466
3507
  async cmdHelp() {
1467
- console.log();
1468
- console.log(chalk.bold(" Commands"));
1469
- console.log(chalk.gray(" " + "─".repeat(50)));
3508
+ this.feedLine();
3509
+ this.feedLine(tp.bold(" Commands"));
3510
+ this.feedLine(tp.muted(` ${"─".repeat(50)}`));
1470
3511
  // De-duplicate (aliases map to same command)
1471
3512
  const seen = new Set();
1472
3513
  for (const [, cmd] of this.commands) {
@@ -1474,44 +3515,202 @@ class TeammatesREPL {
1474
3515
  continue;
1475
3516
  seen.add(cmd.name);
1476
3517
  const aliases = cmd.aliases.length > 0
1477
- ? chalk.gray(` (${cmd.aliases.map((a) => "/" + a).join(", ")})`)
3518
+ ? ` (${cmd.aliases.map((a) => `/${a}`).join(", ")})`
1478
3519
  : "";
1479
- console.log(` ${chalk.cyan(cmd.usage.padEnd(36))}${cmd.description}${aliases}`);
3520
+ this.feedLine(concat(tp.accent(` ${cmd.usage}`.padEnd(36)), pen(cmd.description), tp.muted(aliases)));
1480
3521
  }
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();
3522
+ this.feedLine();
3523
+ this.feedLine(concat(tp.muted(" Tip: "), tp.text("Type text without / to auto-route to the best teammate")));
3524
+ this.feedLine(concat(tp.muted(" Tip: "), tp.text("Press Tab to autocomplete commands and teammate names")));
3525
+ this.feedLine();
3526
+ this.refreshView();
3527
+ }
3528
+ async cmdUser(argsStr) {
3529
+ const userMdPath = join(this.teammatesDir, "USER.md");
3530
+ const change = argsStr.trim();
3531
+ if (!change) {
3532
+ // No args — print current USER.md
3533
+ let content;
3534
+ try {
3535
+ content = readFileSync(userMdPath, "utf-8");
3536
+ }
3537
+ catch {
3538
+ this.feedLine(tp.muted(" USER.md not found."));
3539
+ this.feedLine(tp.muted(" Run /init or create .teammates/USER.md manually."));
3540
+ this.refreshView();
3541
+ return;
3542
+ }
3543
+ if (!content.trim()) {
3544
+ this.feedLine(tp.muted(" USER.md is empty."));
3545
+ this.refreshView();
3546
+ return;
3547
+ }
3548
+ this.feedLine();
3549
+ this.feedLine(tp.muted(" ── USER.md ──"));
3550
+ this.feedLine();
3551
+ this.feedMarkdown(content);
3552
+ this.feedLine();
3553
+ this.feedLine(tp.muted(" ── end ──"));
3554
+ this.feedLine();
3555
+ this.refreshView();
3556
+ return;
3557
+ }
3558
+ // Has args — queue a task to the coding agent to apply the change
3559
+ const task = `Update the file ${userMdPath} with the following change:\n\n${change}\n\nKeep the existing content intact unless the change explicitly replaces something. This is the user's profile — be concise and accurate.`;
3560
+ this.taskQueue.push({ type: "agent", teammate: this.adapterName, task });
3561
+ this.feedLine(concat(tp.muted(" Queued USER.md update → "), tp.accent(`@${this.adapterName}`)));
3562
+ this.feedLine();
3563
+ this.refreshView();
3564
+ this.kickDrain();
3565
+ }
3566
+ async cmdBtw(argsStr) {
3567
+ const question = argsStr.trim();
3568
+ if (!question) {
3569
+ this.feedLine(tp.muted(" Usage: /btw <question>"));
3570
+ this.refreshView();
3571
+ return;
3572
+ }
3573
+ this.taskQueue.push({
3574
+ type: "btw",
3575
+ teammate: this.adapterName,
3576
+ task: question,
3577
+ });
3578
+ this.feedLine(concat(tp.muted(" Side question → "), tp.accent(`@${this.adapterName}`)));
3579
+ this.feedLine();
3580
+ this.refreshView();
3581
+ this.kickDrain();
3582
+ }
3583
+ async cmdTheme() {
3584
+ const t = theme();
3585
+ this.feedLine();
3586
+ this.feedLine(tp.bold(" Theme"));
3587
+ this.feedLine(tp.muted(` ${"─".repeat(50)}`));
3588
+ this.feedLine();
3589
+ // Helper: show a swatch + variable name + hex + example text
3590
+ const row = (name, c, example) => {
3591
+ const hex = colorToHex(c);
3592
+ this.feedLine(concat(pen.fg(c)(" ██"), tp.text(` ${name}`.padEnd(24)), tp.muted(hex.padEnd(12)), pen.fg(c)(example)));
3593
+ };
3594
+ this.feedLine(tp.muted(" Variable Hex Example"));
3595
+ this.feedLine(tp.muted(` ${"─".repeat(50)}`));
3596
+ // Brand / accent
3597
+ row("accent", t.accent, "@beacon /status ● teammate");
3598
+ row("accentBright", t.accentBright, "▸ highlighted item");
3599
+ row("accentDim", t.accentDim, "┌─── border ───┐");
3600
+ this.feedLine();
3601
+ // Foreground
3602
+ row("text", t.text, "Primary text content");
3603
+ row("textMuted", t.textMuted, "Description or secondary info");
3604
+ row("textDim", t.textDim, "─── separator ───");
3605
+ this.feedLine();
3606
+ // Status
3607
+ row("success", t.success, "✔ Task completed");
3608
+ row("warning", t.warning, "⚠ Pending handoff");
3609
+ row("error", t.error, "✖ Something went wrong");
3610
+ row("info", t.info, "⠋ Working on task...");
3611
+ this.feedLine();
3612
+ // Interactive
3613
+ row("prompt", t.prompt, "> ");
3614
+ row("input", t.input, "user typed text");
3615
+ row("separator", t.separator, "────────────────");
3616
+ row("progress", t.progress, "analyzing codebase...");
3617
+ row("dropdown", t.dropdown, "/status session overview");
3618
+ row("dropdownHighlight", t.dropdownHighlight, "▸ /help all commands");
3619
+ this.feedLine();
3620
+ // Cursor
3621
+ this.feedLine(concat(pen.fg(t.cursorFg).bg(t.cursorBg)(" ██"), tp.text(" cursorFg/cursorBg".padEnd(24)), tp.muted(`${colorToHex(t.cursorFg)}/${colorToHex(t.cursorBg)}`.padEnd(12)), pen.fg(t.cursorFg).bg(t.cursorBg)(" block cursor ")));
3622
+ this.feedLine();
3623
+ this.feedLine(tp.muted(" Base accent: #3A96DD"));
3624
+ this.feedLine();
3625
+ // ── Markdown preview ──────────────────────────────────────
3626
+ this.feedLine(tp.bold(" Markdown Preview"));
3627
+ this.feedLine(tp.muted(` ${"─".repeat(50)}`));
3628
+ this.feedLine();
3629
+ const mdSample = [
3630
+ "# Heading 1",
3631
+ "",
3632
+ "## Heading 2",
3633
+ "",
3634
+ "### Heading 3",
3635
+ "",
3636
+ "Regular text with **bold**, *italic*, and `inline code`.",
3637
+ "A [link](https://example.com) and ~~strikethrough~~.",
3638
+ "",
3639
+ "- Bullet item one",
3640
+ "- Bullet item with **bold**",
3641
+ " - Nested item",
3642
+ "",
3643
+ "1. Ordered first",
3644
+ "2. Ordered second",
3645
+ "",
3646
+ "> Blockquote text",
3647
+ "> across multiple lines",
3648
+ "",
3649
+ "```js",
3650
+ 'const greeting = "hello";',
3651
+ "async function main() {",
3652
+ ' await fetch("/api");',
3653
+ " return 42;",
3654
+ "}",
3655
+ "```",
3656
+ "",
3657
+ "```python",
3658
+ "def greet(name: str) -> None:",
3659
+ ' print(f"Hello, {name}")',
3660
+ "```",
3661
+ "",
3662
+ "```bash",
3663
+ 'echo "$HOME" | grep --color user',
3664
+ "if [ -f .env ]; then source .env; fi",
3665
+ "```",
3666
+ "",
3667
+ "```json",
3668
+ "{",
3669
+ ' "name": "teammates",',
3670
+ ' "version": "0.1.0",',
3671
+ ' "active": true',
3672
+ "}",
3673
+ "```",
3674
+ "",
3675
+ "| Language | Status |",
3676
+ "|------------|---------|",
3677
+ "| JavaScript | ✔ Ready |",
3678
+ "| Python | ✔ Ready |",
3679
+ "| C# | ✔ Ready |",
3680
+ "",
3681
+ "---",
3682
+ ].join("\n");
3683
+ this.feedMarkdown(mdSample);
3684
+ this.feedLine();
3685
+ this.refreshView();
1487
3686
  }
1488
3687
  }
1489
3688
  // ─── Usage (non-interactive) ─────────────────────────────────────────
1490
3689
  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
3690
+ console.log(`
3691
+ ${chalk.bold("@teammates/cli")} — Agent-agnostic teammate orchestrator
3692
+
3693
+ ${chalk.bold("Usage:")}
3694
+ teammates <agent> Launch session with an agent
3695
+ teammates claude Use Claude Code
3696
+ teammates codex Use OpenAI Codex
3697
+ teammates aider Use Aider
3698
+
3699
+ ${chalk.bold("Options:")}
3700
+ --model <model> Override the agent model
3701
+ --dir <path> Override .teammates/ location
3702
+
3703
+ ${chalk.bold("Agents:")}
3704
+ claude Claude Code CLI (requires 'claude' on PATH)
3705
+ codex OpenAI Codex CLI (requires 'codex' on PATH)
3706
+ aider Aider CLI (requires 'aider' on PATH)
3707
+ echo Test adapter — echoes prompts (no external agent)
3708
+
3709
+ ${chalk.bold("In-session:")}
3710
+ @teammate <task> Assign directly via @mention
3711
+ <text> Auto-route to the best teammate
3712
+ /status Session overview
3713
+ /help All commands
1515
3714
  `.trim());
1516
3715
  }
1517
3716
  // ─── Main ────────────────────────────────────────────────────────────