@fradser/pi-agent-teams 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui.ts CHANGED
@@ -1,22 +1,36 @@
1
- /** Passive team widget and interactive full-screen console (roster + board). */
1
+ /** Passive team widget and the /agent-teams management console
2
+ * (session teammates + persistent agent roles + board). */
2
3
 
3
- import { truncateToWidth, Key, matchesKey } from "@earendil-works/pi-tui";
4
- import { createPiThemeStyle, PI_SPINNER_FRAMES, PI_SPINNER_INTERVAL_MS } from "@fradser/pi-kit";
4
+ import { truncateToWidth, Key, matchesKey, fuzzyFilter } from "@earendil-works/pi-tui";
5
+ import {
6
+ createPiThemeStyle,
7
+ createSearchPicker,
8
+ modelLabel,
9
+ modelSearchText,
10
+ PI_SPINNER_FRAMES,
11
+ PI_SPINNER_INTERVAL_MS,
12
+ sortModels,
13
+ type SearchPicker,
14
+ } from "@fradser/pi-kit";
5
15
  import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
6
16
  import {
7
17
  clampConsoleScroll, consoleScrollRange, maxConsoleBody, scrollConsoleDetail, wrapConsoleDetail,
8
18
  } from "./console-viewport.ts";
9
19
  import { fitTeammateRow, formatTeammateLabel, runningTeammateActivity } from "./activity.ts";
10
- import { getState, getTeammate, listTasks, listTeammates, livingTeammates } from "./state.ts";
20
+ import { MODEL_INHERIT_ALIAS, discoverAgents, resolveAgent, type AgentDefinition } from "./agents.ts";
21
+ import { getState, getTeammate, getTeamDefaultModel, listTasks, listTeammates, livingTeammates, setTeamDefaultModel } from "./state.ts";
11
22
  import {
23
+ currentLeaderModelRef,
12
24
  ensureLivePoll,
13
25
  formatSilenceDuration,
26
+ resolveSpawnModel,
14
27
  runtimeDirPath,
15
28
  shutdownTeammate,
16
29
  STALL_NOTICE_MS,
17
30
  stallSilenceMs,
18
31
  } from "./team-machine.ts";
19
32
  import type { Teammate } from "./types.ts";
33
+ import { mapPickerKey } from "./picker-keys.ts";
20
34
  import { inboxPath } from "./statefile.ts";
21
35
  import * as fs from "node:fs";
22
36
  import * as path from "node:path";
@@ -36,7 +50,7 @@ function colorFor(name: string): (typeof TEAM_COLORS)[number] {
36
50
  }
37
51
 
38
52
  function ensureSpinner(): void {
39
- const working = livingTeammates().some((t) => t.status === "working" || t.status === "starting");
53
+ const working = livingTeammates().some(isWorking);
40
54
  if (working && !spinnerTimer) {
41
55
  spinnerTimer = setInterval(() => {
42
56
  spinnerFrame = (spinnerFrame + 1) % PI_SPINNER_FRAMES.length;
@@ -72,7 +86,7 @@ export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string
72
86
  placement: "belowEditor",
73
87
  render: (width: number) => {
74
88
  // Only WORKING teammates appear above the input box; idle and
75
- // stopped teammates stay in the /teammate console instead.
89
+ // stopped teammates stay in the /agent-teams console instead.
76
90
  const working = livingTeammates().filter(isWorking);
77
91
  if (working.length === 0) return [];
78
92
  const lines: string[] = [];
@@ -171,6 +185,12 @@ function indent(text: string): string[] {
171
185
  return text.split("\n").map((line) => ` ${line}`);
172
186
  }
173
187
 
188
+ function displaySource(sourcePath: string | undefined): string {
189
+ if (!sourcePath) return "(in-memory session role)";
190
+ const relative = path.relative(process.cwd(), sourcePath);
191
+ return relative.startsWith("..") ? sourcePath : `./${relative}`;
192
+ }
193
+
174
194
  function buildTeammateDetail(name: string): string[] {
175
195
  const teammate = getTeammate(name);
176
196
  if (!teammate) return ["(teammate removed from the roster)"];
@@ -181,6 +201,7 @@ function buildTeammateDetail(name: string): string[] {
181
201
  "== teammate ==",
182
202
  ` Status: ${teammate.status}${teammate.currentTaskId ? ` | Task: ${teammate.currentTaskId}` : ""}`,
183
203
  ` Spawn: ${teammate.pid > 0 ? `pid ${teammate.pid}` : "pid unknown"} | Isolation: ${teammate.isolation}`,
204
+ ...(teammate.model ? [` Launch model: ${teammate.model}`] : []),
184
205
  ` Created: ${new Date(teammate.createdAt).toLocaleString()}`,
185
206
  ];
186
207
  if (teammate.stoppedAt) lines.push(` Stopped: ${new Date(teammate.stoppedAt).toLocaleString()}`);
@@ -231,6 +252,37 @@ function buildTaskDetail(taskId: string): string[] {
231
252
  ];
232
253
  }
233
254
 
255
+ function buildRoleDetail(name: string): string[] {
256
+ const def = resolveAgent(name);
257
+ if (!def) return ["(agent definition not found)"];
258
+ return [
259
+ `@${def.name} (${def.scope})${def.scope === "session" ? " [in-memory]" : def.gitManaged ? " [git-managed]" : " [local]"}`,
260
+ "",
261
+ "== role ==",
262
+ ` Source: ${displaySource(def.source)}`,
263
+ ` Description: ${def.description || "(none)"}`,
264
+ ` Tools: ${def.tools.join(", ") || "(role defaults)"}`,
265
+ ` Model: ${describeSpawnModel(def.model)}`,
266
+ ` Verify: ${def.verify ?? "(none)"}`,
267
+ ` Worktree: ${def.worktree ? "true" : "false"}`,
268
+ ` Living instances: ${livingTeammates().filter((teammate) => teammate.agent === def.name).length}`,
269
+ "",
270
+ "== role prompt ==",
271
+ ...indent(cap(def.prompt)),
272
+ ];
273
+ }
274
+
275
+ /** Human-readable effective launch model for a role definition. */
276
+ function describeSpawnModel(pinned: string | undefined): string {
277
+ const resolved = resolveSpawnModel(pinned, getTeamDefaultModel(), currentLeaderModelRef());
278
+ if (pinned && pinned.trim().toLowerCase() !== MODEL_INHERIT_ALIAS) return pinned.trim();
279
+ if (resolved.model) {
280
+ const suffix = pinned ? "inherit -> " : "[team default] ";
281
+ return `${suffix}${resolved.model}`;
282
+ }
283
+ return pinned ? "inherit -> (unavailable at spawn)" : "(Pi default)";
284
+ }
285
+
234
286
  // ── Full-screen console ───────────────────────────────────────────
235
287
 
236
288
  type ConsolePage = "roster" | "board";
@@ -239,30 +291,74 @@ interface RosterRow {
239
291
  key: string;
240
292
  kind: "teammate";
241
293
  }
294
+ interface RoleRow {
295
+ key: string;
296
+ kind: "role";
297
+ }
242
298
  interface BoardRow {
243
299
  key: string;
244
300
  kind: "task";
245
301
  }
246
- type ConsoleRow = RosterRow | BoardRow;
302
+ type ConsoleRow = RosterRow | RoleRow | BoardRow;
303
+
304
+ /** SGR mouse-wheel events carry a button code; return signed line delta. */
305
+ function wheelDelta(data: string): number | undefined {
306
+ const match = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);
307
+ if (!match) return undefined;
308
+ const button = Number.parseInt(match[1], 10);
309
+ if ((button & 64) === 0) return undefined;
310
+ const direction = button & 3;
311
+ if (direction === 0) return -1;
312
+ if (direction === 1) return 1;
313
+ return undefined;
314
+ }
315
+
316
+ /** Chrome lines around the list viewport: border, header, spacer, footer, border. */
317
+ const LIST_CHROME_LINES = 5;
247
318
 
248
319
  /** Full-screen Team Console — owns input via ctx.ui.custom. Pages: roster /
249
- * board; each row opens a scrolling detail view. */
250
- export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void> {
320
+ * board; each row opens a scrolling detail view; m opens the searchable
321
+ * teammate-model picker. */
322
+ export function openTeamConsole(ctx: {
323
+ ui: ExtensionUIContext;
324
+ modelRegistry?: { getAvailable(): Array<{ provider: string; id: string; name?: string }> };
325
+ }): Promise<void> {
251
326
  return ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
252
327
  let page: ConsolePage = "roster";
253
- let mode: "list" | "detail" = "list";
328
+ let mode: "list" | "detail" | "picker" = "list";
254
329
  let selectedRoster = 0;
255
330
  let selectedBoard = 0;
256
- let detailTitle = "";
331
+ let listOffset = 0;
332
+ let detailKind: ConsoleRow["kind"] = "teammate";
333
+ let detailKey = "";
257
334
  let offset = 0;
335
+ let confirmName: string | undefined;
258
336
  let closed = false;
259
337
  let renderTimer: ReturnType<typeof setInterval> | undefined;
338
+
339
+ // Definitions are reread lazily so mid-session role creation shows up,
340
+ // but never once per animation frame.
341
+ let rolesCache: AgentDefinition[] = [];
342
+ let rolesCacheAt = 0;
343
+ const ROLES_CACHE_TTL_MS = 2000;
344
+ const getRoles = (): AgentDefinition[] => {
345
+ const now = Date.now();
346
+ if (now - rolesCacheAt > ROLES_CACHE_TTL_MS) {
347
+ rolesCache = [...discoverAgents().values()].sort((a, b) => a.name.localeCompare(b.name));
348
+ rolesCacheAt = now;
349
+ }
350
+ return rolesCache;
351
+ };
352
+
260
353
  const requestRender = () => {
261
354
  if (!closed) tui.requestRender();
262
355
  };
263
356
  const startLiveRefresh = () => {
264
357
  if (renderTimer) return;
265
- renderTimer = setInterval(requestRender, PI_SPINNER_INTERVAL_MS);
358
+ renderTimer = setInterval(() => {
359
+ // Redraw only while something can animate (spinner/activity/stall).
360
+ if (livingTeammates().some(isWorking)) requestRender();
361
+ }, PI_SPINNER_INTERVAL_MS);
266
362
  renderTimer.unref?.();
267
363
  };
268
364
  const stopLiveRefresh = () => {
@@ -274,10 +370,115 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
274
370
 
275
371
  const style = createPiThemeStyle(theme);
276
372
 
373
+ // ── Teammate-model picker (type-to-filter) ──────────────────
374
+ let picker: SearchPicker<{ provider: string; id: string; name?: string }> | undefined;
375
+ let pickerOffset = 0;
376
+
377
+ /** Pinned pseudo-entry so clearing never collides with typed search text. */
378
+ const CLEAR_TEAM_MODEL_ENTRY: { provider: string; id: string; name?: string } = {
379
+ provider: "",
380
+ id: "__clear-team-default__",
381
+ name: "Use Pi default",
382
+ };
383
+ const isClearEntry = (model: { id: string } | undefined): boolean =>
384
+ model?.id === CLEAR_TEAM_MODEL_ENTRY.id;
385
+
386
+ const openModelPicker = (): void => {
387
+ const models = sortModels([...(ctx.modelRegistry?.getAvailable() ?? [])]);
388
+ if (models.length === 0) return;
389
+ picker = createSearchPicker([CLEAR_TEAM_MODEL_ENTRY, ...models], {
390
+ filter: fuzzyFilter,
391
+ getText: (model) => (isClearEntry(model) ? "clear team default use pi default" : modelSearchText(model)),
392
+ });
393
+ pickerOffset = 0;
394
+ mode = "picker";
395
+ requestRender();
396
+ };
397
+
398
+ const closeModelPicker = (): void => {
399
+ picker = undefined;
400
+ mode = "list";
401
+ requestRender();
402
+ };
403
+
404
+ const PICKER_CHROME_LINES = 7;
405
+ const pickerViewport = (): number => Math.max(1, tui.terminal.rows - PICKER_CHROME_LINES);
406
+
407
+ const renderPicker = (width: number): string[] => {
408
+ if (!picker) return [];
409
+ const border = style.border("─".repeat(Math.max(1, width)));
410
+ const results = picker.results();
411
+ const selected = picker.selectedIndex();
412
+ if (selected < pickerOffset) pickerOffset = selected;
413
+ else if (selected >= pickerOffset + pickerViewport()) pickerOffset = selected - pickerViewport() + 1;
414
+ pickerOffset = clampConsoleScroll(pickerOffset, results.length, pickerViewport());
415
+ const leaderRef = currentLeaderModelRef();
416
+ const teamDefault = getTeamDefaultModel();
417
+ const rows = results.slice(pickerOffset, pickerOffset + pickerViewport()).map((model, index) => {
418
+ const absolute = pickerOffset + index;
419
+ const marker = absolute === selected ? style.accent("❯ ") : " ";
420
+ if (isClearEntry(model)) {
421
+ return truncateToWidth(`${marker}${theme.fg("warning", "✕ clear team default")} ${style.dim("· use Pi default")}`, Math.max(10, width - 1));
422
+ }
423
+ const tags = [
424
+ modelLabel(model) === leaderRef ? style.dim("(leader)") : "",
425
+ modelLabel(model) === teamDefault ? style.success("(default)") : "",
426
+ ].filter(Boolean).join(" ");
427
+ const display = model.name && model.name !== model.id ? style.dim(model.name) : "";
428
+ return truncateToWidth(`${marker}${theme.fg("customMessageText", modelLabel(model))} ${display} ${tags}`.replace(/\s+$/g, ""), Math.max(10, width - 1));
429
+ });
430
+ return [
431
+ border,
432
+ style.accent(truncateToWidth(`teammate model ${teamDefault ?? "auto (Pi default)"}`, width)),
433
+ "",
434
+ truncateToWidth(`${style.accent("❯ ")}${picker.query()}▏ ${style.dim(`${results.length} models`)}`, Math.max(10, width - 1)),
435
+ ...rows,
436
+ "",
437
+ truncateToWidth(style.dim("type to filter · ↑↓ select · enter set/clear · esc cancel"), Math.max(10, width - 1)),
438
+ border,
439
+ ];
440
+ };
441
+
442
+ function handlePickerInput(data: string): void {
443
+ if (!picker) {
444
+ mode = "list";
445
+ return;
446
+ }
447
+ const action = mapPickerKey(data);
448
+ if (!action) return;
449
+ if (action.kind === "cancel") {
450
+ closeModelPicker();
451
+ return;
452
+ }
453
+ if (action.kind === "confirm") {
454
+ const picked = picker.selected();
455
+ // An empty filtered list must not clobber the current default.
456
+ if (!picked) return;
457
+ if (isClearEntry(picked)) {
458
+ setTeamDefaultModel(undefined);
459
+ ctx.ui.notify("Teammate model cleared — Pi picks its default", "info");
460
+ } else {
461
+ setTeamDefaultModel(modelLabel(picked));
462
+ ctx.ui.notify(`Teammate model set to ${modelLabel(picked)} for this session`, "info");
463
+ }
464
+ closeModelPicker();
465
+ return;
466
+ }
467
+ if (action.kind === "up") picker.up();
468
+ else if (action.kind === "down") picker.down();
469
+ else if (action.kind === "backspace") picker.backspace();
470
+ else picker.type(action.text);
471
+ requestRender();
472
+ }
473
+
277
474
  const currentRows = (): ConsoleRow[] =>
278
475
  page === "roster"
279
- // The console is the full roster: idle and stopped stay visible here.
280
- ? listTeammates().map((t) => ({ key: t.name, kind: "teammate" as const }))
476
+ // The console is the management surface: idle and stopped stay
477
+ // visible, and persistent agent roles are listed after the session.
478
+ ? [
479
+ ...listTeammates().map((t) => ({ key: t.name, kind: "teammate" as const })),
480
+ ...getRoles().map((def) => ({ key: def.name, kind: "role" as const })),
481
+ ]
281
482
  : listTasks().map((task) => ({ key: task.id, kind: "task" as const }));
282
483
 
283
484
  const windowLines = (full: string[], width: number): { lines: string[]; range: string } => {
@@ -293,48 +494,110 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
293
494
  const headerLine = (): string => {
294
495
  const alive = livingTeammates();
295
496
  const tasks = listTasks();
296
- return `team ${alive.length} alive · board ${tasks.filter((task) => task.status === "pending").length}p/${tasks.filter((task) => task.status === "claimed").length}c/${tasks.filter((task) => task.status === "completed").length}d · ${page}`;
497
+ return `team ${alive.length} alive · ${alive.filter(isWorking).length} working · ${getRoles().length} roles · board ${tasks.filter((task) => task.status === "pending").length}p/${tasks.filter((task) => task.status === "claimed").length}c/${tasks.filter((task) => task.status === "completed").length}d · model ${getTeamDefaultModel() ?? "auto"} · ${page}`;
297
498
  };
298
499
 
299
- const renderList = (width: number): string[] => {
500
+ interface ContentLine {
501
+ text: string;
502
+ /** Selectable row index, or -1 for section chrome. */
503
+ select: number;
504
+ }
505
+
506
+ const teammateRowText = (key: string, selected: boolean): string => {
507
+ const teammate = getTeammate(key)!;
508
+ const marker = selected ? style.accent("❯ ") : " ";
509
+ const name = theme.bold(theme.fg(colorFor(teammate.name), `@${teammate.name}`));
510
+ if (isWorking(teammate)) {
511
+ const prefix = `${marker}${name} `;
512
+ const width = tui.terminal.columns;
513
+ const available = Math.max(0, width - prefix.length - PI_SPINNER_FRAMES[spinnerFrame].length - 2);
514
+ const status = theme.fg("warning", formatTeammateLabel(PI_SPINNER_FRAMES[spinnerFrame], runningTeammateActivity(teammate) + stallSuffix(teammate), available));
515
+ return `${prefix}${status}`;
516
+ }
517
+ if (teammate.status === "idle") {
518
+ return `${marker}${name} ${style.dim(`○ idle${teammate.currentTaskId ? ` · ${teammate.currentTaskId}` : ""}`)}`;
519
+ }
520
+ return `${marker}${name} ${theme.fg("warning", "■ stopped")}`;
521
+ };
522
+
523
+ const roleRowText = (key: string, selected: boolean): string => {
524
+ const def = getRoles().find((candidate) => candidate.name === key)!;
525
+ const marker = selected ? style.accent("❯ ") : " ";
526
+ const name = theme.bold(theme.fg(colorFor(key), `@${key}`));
527
+ const live = livingTeammates().filter((teammate) => teammate.agent === key && teammate.status !== "stopped").length;
528
+ const liveTag = live > 0 ? style.success(`[${live} live]`) : style.dim("[0 live]");
529
+ const provenance = def.scope === "session" ? "in-memory" : displaySource(def.source);
530
+ return `${marker}${name} ${style.dim(def.scope)} ${style.dim(provenance)} ${liveTag}`;
531
+ };
532
+
533
+ const taskRowText = (key: string, selected: boolean): string => {
534
+ const task = listTasks().find((candidate) => candidate.id === key)!;
535
+ const marker = selected ? style.accent("❯ ") : " ";
536
+ const label = theme.fg(colorFor(key), `[${key}]`);
537
+ const holder = task.claimedBy ? style.dim(` @${task.claimedBy}`) : "";
538
+ const statusText = task.status === "completed"
539
+ ? style.success("✓")
540
+ : task.status === "claimed"
541
+ ? theme.fg("warning", "◐ claimed")
542
+ : style.dim("○ pending");
543
+ return `${marker}${label} ${statusText} ${theme.fg("customMessageText", task.subject)}${holder}`;
544
+ };
545
+
546
+ const buildContent = (): ContentLine[] => {
300
547
  const rows = currentRows();
548
+ const lines: ContentLine[] = [];
549
+ if (page === "board") {
550
+ if (rows.length === 0) {
551
+ lines.push({ text: style.dim("The task board is empty. Create tasks with task_create."), select: -1 });
552
+ }
553
+ rows.forEach((row, index) => lines.push({ text: taskRowText(row.key, index === currentPageSelection(rows.length)), select: index }));
554
+ return lines;
555
+ }
556
+ const teammates = rows.filter((row): row is RosterRow => row.kind === "teammate");
557
+ const roles = rows.filter((row): row is RoleRow => row.kind === "role");
558
+ const selection = currentPageSelection(rows.length);
559
+ lines.push({ text: style.dim("== teammates (this session) =="), select: -1 });
560
+ if (teammates.length === 0) {
561
+ lines.push({ text: style.dim("No teammates this session. Ask the model to spawn one."), select: -1 });
562
+ }
563
+ teammates.forEach((row, index) => lines.push({ text: teammateRowText(row.key, index === selection), select: index }));
564
+ lines.push({ text: "", select: -1 });
565
+ lines.push({ text: style.dim("== agent roles (persistent and session definitions) =="), select: -1 });
566
+ if (roles.length === 0) {
567
+ lines.push({ text: style.dim("No agent definitions discovered."), select: -1 });
568
+ }
569
+ roles.forEach((row, index) => lines.push({ text: roleRowText(row.key, teammates.length + index === selection), select: teammates.length + index }));
570
+ return lines;
571
+ };
572
+
573
+ const listViewport = (): number => Math.max(1, tui.terminal.rows - LIST_CHROME_LINES);
574
+
575
+ const renderList = (width: number): string[] => {
301
576
  const border = style.border("─".repeat(Math.max(1, width)));
302
- const lines = [border, style.accent(truncateToWidth(headerLine(), width)), ""];
303
- if (rows.length === 0) {
304
- lines.push(style.dim(page === "roster"
305
- ? "No living teammates. Spawn one with teammate_spawn."
306
- : "The task board is empty. Create tasks with task_create."));
577
+ const content = buildContent();
578
+ const viewport = listViewport();
579
+ const selection = currentPageSelection(currentRows().length);
580
+ const selectedIndex = content.findIndex((line) => line.select === selection);
581
+ if (selectedIndex >= 0) {
582
+ if (selectedIndex < listOffset) listOffset = selectedIndex;
583
+ else if (selectedIndex >= listOffset + viewport) listOffset = selectedIndex - viewport + 1;
307
584
  }
308
- rows.forEach((row, index) => {
309
- const marker = index === currentPageSelection(rows.length) ? style.accent("❯ ") : " ";
310
- if (row.kind === "teammate") {
311
- const teammate = getTeammate(row.key)!;
312
- const name = theme.bold(theme.fg(colorFor(teammate.name), `@${teammate.name}`));
313
- let status: string;
314
- if (teammate.status === "working" || teammate.status === "starting") {
315
- const prefix = `${marker}${name} `;
316
- const available = Math.max(0, width - prefix.length - PI_SPINNER_FRAMES[spinnerFrame].length - 2);
317
- status = theme.fg("warning", formatTeammateLabel(PI_SPINNER_FRAMES[spinnerFrame], runningTeammateActivity(teammate) + stallSuffix(teammate), available));
318
- } else if (teammate.status === "idle") {
319
- status = style.dim(`○ idle${teammate.currentTaskId ? ` · ${teammate.currentTaskId}` : ""}`);
320
- } else {
321
- status = theme.fg("warning", "■ stopped");
322
- }
323
- lines.push(`${marker}${name} ${status}`);
324
- } else {
325
- const task = listTasks().find((candidate) => candidate.id === row.key)!;
326
- const label = theme.fg(colorFor(task.id), `[${task.id}]`);
327
- const holder = task.claimedBy ? style.dim(` @${task.claimedBy}`) : "";
328
- const statusText = task.status === "completed"
329
- ? style.success("✓")
330
- : task.status === "claimed"
331
- ? theme.fg("warning", "◐ claimed")
332
- : style.dim("○ pending");
333
- lines.push(truncateToWidth(`${marker}${label} ${statusText} ${theme.fg("customMessageText", task.subject)}${holder}`, Math.max(10, width - 1)));
334
- }
335
- });
336
- lines.push("", style.dim("↑↓ select · enter open · tab page · x shutdown · esc/q close"), border);
337
- return lines.map((l) => truncateToWidth(l, Math.max(10, width - 1)));
585
+ listOffset = clampConsoleScroll(listOffset, content.length, viewport);
586
+ const footer = confirmName
587
+ ? theme.fg("warning", `Shut down @${confirmName}? Its claimed task returns to the board · y yes / n no`)
588
+ : style.dim(page === "roster"
589
+ ? "↑↓ select · enter open · tab board · x shutdown · m model · esc/q close"
590
+ : "↑↓ select · enter open · tab roster · esc/q close");
591
+ return [
592
+ border,
593
+ style.accent(truncateToWidth(headerLine(), width)),
594
+ "",
595
+ ...content.slice(listOffset, listOffset + viewport)
596
+ .map((line) => truncateToWidth(line.text, Math.max(10, width - 1))),
597
+ "",
598
+ truncateToWidth(footer, Math.max(10, width - 1)),
599
+ border,
600
+ ];
338
601
  };
339
602
 
340
603
  const currentPageSelection = (rowCount: number): number => {
@@ -342,6 +605,9 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
342
605
  return rowCount === 0 ? 0 : Math.min(selection, rowCount - 1);
343
606
  };
344
607
 
608
+ const detailTitle = (): string =>
609
+ detailKind === "task" ? `[${detailKey}]` : detailKind === "role" ? `@${detailKey} · role` : `@${detailKey}`;
610
+
345
611
  const renderDetail = (width: number): string[] => {
346
612
  const border = style.border("─".repeat(Math.max(1, width)));
347
613
  const source = detailSource();
@@ -349,7 +615,7 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
349
615
  const footer = style.dim(` ${detail.range} · ↑↓ scroll · pgup/pgdn page · home/end jump · esc back · q close`);
350
616
  const lines = [
351
617
  border,
352
- style.accent(truncateToWidth(`agent-teams ${detailTitle}`, width)),
618
+ style.accent(truncateToWidth(`agent-teams ${detailTitle()}`, width)),
353
619
  "",
354
620
  ...detail.lines.map((line) => ` ${line}`),
355
621
  "",
@@ -359,21 +625,39 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
359
625
  return lines.map((line) => truncateToWidth(line, Math.max(10, width - 1)));
360
626
  };
361
627
 
362
- const detailSource = (): string[] =>
363
- detailTitle.startsWith("[")
364
- ? buildTaskDetail(detailTitle.slice(1, detailTitle.indexOf("]")))
365
- : buildTeammateDetail(detailTitle.replace(/^@/, ""));
628
+ const detailSource = (): string[] => {
629
+ if (detailKind === "task") return buildTaskDetail(detailKey);
630
+ if (detailKind === "role") return buildRoleDetail(detailKey);
631
+ return buildTeammateDetail(detailKey);
632
+ };
366
633
 
367
634
  return {
368
- render: (width) => (mode === "list" ? renderList(width) : renderDetail(width)),
635
+ render: (width) => (mode === "picker" ? renderPicker(width) : mode === "detail" ? renderDetail(width) : renderList(width)),
369
636
  handleInput: (data: string) => {
637
+ if (mode === "picker") {
638
+ handlePickerInput(data);
639
+ return;
640
+ }
370
641
  if (mode !== "list") {
371
642
  handleDetailInput(data);
372
643
  return;
373
644
  }
645
+ if (confirmName) {
646
+ if (data === "y" || data === "Y") {
647
+ const name = confirmName;
648
+ confirmName = undefined;
649
+ void shutdownFromConsole(ctx, name);
650
+ } else if (data === "n" || data === "N" || matchesKey(data, Key.escape)) {
651
+ confirmName = undefined;
652
+ } else if (data === "q" || data === "Q") {
653
+ closeConsole(done);
654
+ }
655
+ return;
656
+ }
374
657
  const rows = currentRows();
375
658
  if (matchesKey(data, Key.tab)) {
376
659
  page = page === "roster" ? "board" : "roster";
660
+ listOffset = 0;
377
661
  return;
378
662
  }
379
663
  if (matchesKey(data, Key.down)) bumpSelection(1, rows.length);
@@ -384,20 +668,37 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
384
668
  if (row) {
385
669
  mode = "detail";
386
670
  offset = 0;
387
- detailTitle = row.kind === "teammate" ? `@${row.key}` : `[${row.key}]`;
671
+ detailKind = row.kind;
672
+ detailKey = row.key;
388
673
  }
389
674
  return;
390
675
  }
391
676
  if (data === "x" || data === "X") {
392
677
  const selection = currentPageSelection(rows.length);
393
678
  const row = rows[selection];
394
- if (page === "roster" && row?.kind === "teammate") void shutdownFromConsole(ctx, row.key);
679
+ if (page === "roster" && row?.kind === "teammate" && getTeammate(row.key)?.status !== "stopped") {
680
+ confirmName = row.key;
681
+ }
395
682
  return;
396
683
  }
397
- if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
398
- closeConsole(done);
684
+ if (data === "m" || data === "M") {
685
+ if (ctx.modelRegistry && ctx.modelRegistry.getAvailable().length > 0) {
686
+ openModelPicker();
687
+ } else {
688
+ ctx.ui.notify("No models are available in the model registry.", "warning");
689
+ }
399
690
  return;
400
691
  }
692
+ const wheel = wheelDelta(data);
693
+ if (wheel !== undefined) {
694
+ scrollList(wheel * 3);
695
+ return;
696
+ }
697
+ if (matchesKey(data, Key.pageUp)) scrollList(-listViewport());
698
+ else if (matchesKey(data, Key.pageDown)) scrollList(listViewport());
699
+ else if (matchesKey(data, Key.home)) listOffset = 0;
700
+ else if (matchesKey(data, Key.end)) listOffset = clampConsoleScroll(Number.MAX_SAFE_INTEGER, buildContent().length, listViewport());
701
+ else if (matchesKey(data, Key.escape) || data === "q" || data === "Q") closeConsole(done);
401
702
  },
402
703
  invalidate: () => requestRender(),
403
704
  dispose: () => {
@@ -418,6 +719,10 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
418
719
  else selectedBoard = clampIndex(selectedBoard + delta, rowCount);
419
720
  }
420
721
 
722
+ function scrollList(delta: number): void {
723
+ listOffset = clampConsoleScroll(listOffset + delta, buildContent().length, listViewport());
724
+ }
725
+
421
726
  function handleDetailInput(data: string): void {
422
727
  const source = detailSource();
423
728
  const viewport = maxConsoleBody(tui.terminal.rows);
@@ -431,14 +736,9 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
431
736
  return;
432
737
  }
433
738
  const total = wrapConsoleDetail(source, tui.terminal.columns).length;
434
- const sgrWheel = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
435
- if (sgrWheel) {
436
- const button = Number.parseInt(sgrWheel[1], 10);
437
- if ((button & 64) !== 0) {
438
- const direction = button & 3;
439
- if (direction === 0) offset = scrollConsoleDetail(offset, -3, total, viewport);
440
- else if (direction === 1) offset = scrollConsoleDetail(offset, 3, total, viewport);
441
- }
739
+ const wheel = wheelDelta(data);
740
+ if (wheel !== undefined) {
741
+ offset = scrollConsoleDetail(offset, wheel * 3, total, viewport);
442
742
  return;
443
743
  }
444
744
  if (matchesKey(data, Key.up)) offset = scrollConsoleDetail(offset, -1, total, viewport);
package/src/worker.ts CHANGED
@@ -119,7 +119,9 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
119
119
  body: params.message,
120
120
  status: params.status,
121
121
  });
122
- return { content: [{ type: "text", text: "Queued message to leader." }], details: {} };
122
+ return { content: [{ type: "text", text: params.status
123
+ ? "Queued message to leader."
124
+ : 'Queued message to leader. Note: without status="completed" or status="failed" this report does not end your assignment.' }], details: {} };
123
125
  }
124
126
  if (params.status) throw new Error('status is valid only when to="leader".');
125
127
  if (params.to === binding.worker) throw new Error("You are already the recipient — no need to message yourself.");
@@ -1,10 +0,0 @@
1
- ---
2
- name: observer
3
- description: Read-only monitor of activity, logs, or processes; use when the task is to observe, watch, or report state rather than change it
4
- tools: read
5
- ---
6
- You are an observer agent. Observe and report on the assigned scope without
7
- changing anything. Take the required snapshots or readings, summarize what you
8
- observed, and report notable events or anomalies with timestamps and evidence
9
- via send_message(to="leader", message=...) with status="completed". You have no edit,
10
- write, or command tools.
@@ -1,13 +0,0 @@
1
- ---
2
- name: reviewer
3
- description: Read-only code or content reviewer; use after implementation or plan changes to check for correctness, regressions, and security
4
- tools: read,bash
5
- ---
6
- You are a reviewer agent. Read the relevant code and tests first, then review
7
- the assigned scope for correctness, regressions, and security problems. Do not
8
- edit files. Report only confirmed findings with severity, evidence, exact
9
- paths, and a minimal fix recommendation via send_message(to="leader",
10
- message=...) with status="completed". Your LAST message to the leader MUST
11
- carry status="completed" (or status="failed"); a review without a terminal
12
- status is an unfinished review. When no issue is confirmed, say so and list the checks
13
- you performed.
@@ -1,10 +0,0 @@
1
- ---
2
- name: specialist
3
- description: Read-only domain expert for a specific technical area (security, performance, data, platform); use when a task needs deep domain judgment rather than broad implementation
4
- tools: read,bash
5
- ---
6
- You are a specialist agent. Apply deep domain judgment to the assigned scope:
7
- investigate, analyze, and produce findings or recommendations with evidence via
8
- send_message(to="leader", message=...) with status="completed". Do not edit files.
9
- State your assumptions, the evidence you used, and the bounds of your expertise
10
- for this task.
package/agents/worker.md DELETED
@@ -1,11 +0,0 @@
1
- ---
2
- name: worker
3
- description: General executor that implements changes and verifies them; use when a task needs code or file modifications, or when no more specific agent fits
4
- tools: read,bash,edit,write
5
- ---
6
- You are a worker agent. Implement the assigned task end to end within your
7
- agent tool scope, then verify your own work by running the
8
- relevant tests or commands. Follow the task's ordered procedure. Report what
9
- you changed, what you verified, and any risks or follow-up work via
10
- send_message(to="leader", message=...) with status="completed". Do not touch
11
- files outside the assigned scope.