@fradser/pi-agent-teams 0.7.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/types.ts CHANGED
@@ -30,6 +30,8 @@ export interface Teammate {
30
30
  isolation: "worktree" | "none";
31
31
  /** Board task currently claimed by this teammate, if any. */
32
32
  currentTaskId?: string;
33
+ /** Effective launch model reference ("provider/model"); absent when Pi picks its default. */
34
+ model?: string;
33
35
  /** Live assistant text assembled from the RPC stream. */
34
36
  liveText?: string;
35
37
  /** Current child tool name, if a tool is executing. */
@@ -42,6 +44,8 @@ export interface Teammate {
42
44
  sequenceEnded?: boolean;
43
45
  /** When the harness last sent a claimable-task notice to this teammate. */
44
46
  lastNoticeAt?: number;
47
+ /** Claimable task ids already announced to this teammate; one notice per id until it re-arms. */
48
+ noticedTaskIds?: string[];
45
49
  usage?: WorkerUsage;
46
50
  error?: string;
47
51
  createdAt: number;
@@ -72,7 +76,8 @@ export interface BoardTask {
72
76
  description?: string;
73
77
  /** Task ids that must complete before this task is claimable. */
74
78
  dependsOn: string[];
75
- /** Deterministic completion gate; overrides the agent-role default. */
79
+ /** Completion gate: a review prompt a fresh one-shot reviewer answers with
80
+ * VERDICT: PASS or FAIL; overrides the agent-role default. */
76
81
  verify?: string;
77
82
  status: TaskStatus;
78
83
  claimedBy?: string;
@@ -165,8 +170,21 @@ export interface InboxMessage {
165
170
  /** Spawn one named resident teammate. */
166
171
  export const TeammateSpawnParams = Type.Object({
167
172
  name: Type.String({ minLength: 1, description: "Teammate name, unique among living teammates; used for messaging and claiming" }),
168
- agent: Type.String({ description: "Agent definition name (from user, project, or project-local scopes); its frontmatter pins model and optional worktree isolation" }),
173
+ agent: Type.String({ description: "Agent definition name; an inline definition may create this role in memory for the current session" }),
169
174
  prompt: Type.Optional(Type.String({ description: "Optional kickoff prompt delivered as the teammate's first turn; omit to let it wait for messages or board claims" })),
175
+ definition: Type.Optional(Type.Object({
176
+ description: Type.String({ description: "Routing contract for the generated role" }),
177
+ tools: Type.Optional(Type.Array(Type.String(), { description: "Pi tool ids for the generated role" })),
178
+ model: Type.Optional(Type.String({ description: 'Optional provider/model pin, or "inherit" to run on the leader\'s current model' })),
179
+ verify: Type.Optional(Type.String({ description: "Role-default completion gate: a review prompt a fresh reviewer answers with VERDICT: PASS or FAIL" })),
180
+ worktree: Type.Optional(Type.Boolean({ description: "Whether this role receives a dedicated Git worktree" })),
181
+ prompt: Type.String({ minLength: 1, description: "Role prompt for this generated teammate" }),
182
+ persist: Type.Optional(Type.Boolean({ description: "Persist only when the user explicitly asks to keep this role for future sessions" })),
183
+ persistScope: Type.Optional(Type.Union([
184
+ Type.Literal("project"),
185
+ Type.Literal("project-local"),
186
+ ], { description: "Persistence scope; defaults to project-local when persist is true" })),
187
+ }, { description: "Optional generated role definition; kept in memory unless explicitly persisted" })),
170
188
  });
171
189
 
172
190
  /** Shut down one living teammate. */
@@ -180,7 +198,7 @@ export const TaskCreateParams = Type.Object({
180
198
  description: Type.Optional(Type.String({ description: "Full task description for the claiming teammate" }),
181
199
  ),
182
200
  dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Task ids that must complete before this task is claimable" })),
183
- verify: Type.Optional(Type.String({ description: "Deterministic completion gate command; zero exit completes the task. Overrides any agent-role default verify." })),
201
+ verify: Type.Optional(Type.String({ description: "Completion gate: a review prompt a fresh reviewer answers with VERDICT: PASS or FAIL. Overrides any agent-role default verify." })),
184
202
  });
185
203
 
186
204
  /** Shared leader/worker read-only board view. */
@@ -218,10 +236,11 @@ export const TaskSubmitParams = Type.Object({
218
236
  export interface TeamState {
219
237
  teammates: Record<string, Teammate>;
220
238
  tasks: Record<string, BoardTask>;
239
+ /** Unified teammate model for this session; spawns without a role pin use it. */
240
+ defaultModel?: string;
221
241
  /** Single leader inbox for teammate reports and harness diagnostics. */
222
242
  leaderMailbox: MailboxMessage[];
223
243
  messageCounter: number;
224
- taskCounter: number;
225
244
  /** Byte offsets consumed by the parent from each teammate's outbox. */
226
245
  workerEventOffsets: Record<string, number>;
227
246
  /** Report event ids already applied, keyed by teammate spawn. */
package/src/ui.ts CHANGED
@@ -1,24 +1,36 @@
1
1
  /** Passive team widget and the /agent-teams management console
2
2
  * (session teammates + persistent agent roles + board). */
3
3
 
4
- import { truncateToWidth, Key, matchesKey } from "@earendil-works/pi-tui";
5
- 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";
6
15
  import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
7
16
  import {
8
17
  clampConsoleScroll, consoleScrollRange, maxConsoleBody, scrollConsoleDetail, wrapConsoleDetail,
9
18
  } from "./console-viewport.ts";
10
19
  import { fitTeammateRow, formatTeammateLabel, runningTeammateActivity } from "./activity.ts";
11
- import { discoverAgents, resolveAgent, type AgentDefinition } from "./agents.ts";
12
- 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";
13
22
  import {
23
+ currentLeaderModelRef,
14
24
  ensureLivePoll,
15
25
  formatSilenceDuration,
26
+ resolveSpawnModel,
16
27
  runtimeDirPath,
17
28
  shutdownTeammate,
18
29
  STALL_NOTICE_MS,
19
30
  stallSilenceMs,
20
31
  } from "./team-machine.ts";
21
32
  import type { Teammate } from "./types.ts";
33
+ import { mapPickerKey } from "./picker-keys.ts";
22
34
  import { inboxPath } from "./statefile.ts";
23
35
  import * as fs from "node:fs";
24
36
  import * as path from "node:path";
@@ -173,7 +185,8 @@ function indent(text: string): string[] {
173
185
  return text.split("\n").map((line) => ` ${line}`);
174
186
  }
175
187
 
176
- function displaySource(sourcePath: string): string {
188
+ function displaySource(sourcePath: string | undefined): string {
189
+ if (!sourcePath) return "(in-memory session role)";
177
190
  const relative = path.relative(process.cwd(), sourcePath);
178
191
  return relative.startsWith("..") ? sourcePath : `./${relative}`;
179
192
  }
@@ -188,6 +201,7 @@ function buildTeammateDetail(name: string): string[] {
188
201
  "== teammate ==",
189
202
  ` Status: ${teammate.status}${teammate.currentTaskId ? ` | Task: ${teammate.currentTaskId}` : ""}`,
190
203
  ` Spawn: ${teammate.pid > 0 ? `pid ${teammate.pid}` : "pid unknown"} | Isolation: ${teammate.isolation}`,
204
+ ...(teammate.model ? [` Launch model: ${teammate.model}`] : []),
191
205
  ` Created: ${new Date(teammate.createdAt).toLocaleString()}`,
192
206
  ];
193
207
  if (teammate.stoppedAt) lines.push(` Stopped: ${new Date(teammate.stoppedAt).toLocaleString()}`);
@@ -242,13 +256,13 @@ function buildRoleDetail(name: string): string[] {
242
256
  const def = resolveAgent(name);
243
257
  if (!def) return ["(agent definition not found)"];
244
258
  return [
245
- `@${def.name} (${def.scope})${def.gitManaged ? " [git-managed]" : " [local]"}`,
259
+ `@${def.name} (${def.scope})${def.scope === "session" ? " [in-memory]" : def.gitManaged ? " [git-managed]" : " [local]"}`,
246
260
  "",
247
261
  "== role ==",
248
262
  ` Source: ${displaySource(def.source)}`,
249
263
  ` Description: ${def.description || "(none)"}`,
250
264
  ` Tools: ${def.tools.join(", ") || "(role defaults)"}`,
251
- ` Model: ${def.model ?? "(session default)"}`,
265
+ ` Model: ${describeSpawnModel(def.model)}`,
252
266
  ` Verify: ${def.verify ?? "(none)"}`,
253
267
  ` Worktree: ${def.worktree ? "true" : "false"}`,
254
268
  ` Living instances: ${livingTeammates().filter((teammate) => teammate.agent === def.name).length}`,
@@ -258,6 +272,17 @@ function buildRoleDetail(name: string): string[] {
258
272
  ];
259
273
  }
260
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
+
261
286
  // ── Full-screen console ───────────────────────────────────────────
262
287
 
263
288
  type ConsolePage = "roster" | "board";
@@ -292,11 +317,15 @@ function wheelDelta(data: string): number | undefined {
292
317
  const LIST_CHROME_LINES = 5;
293
318
 
294
319
  /** Full-screen Team Console — owns input via ctx.ui.custom. Pages: roster /
295
- * board; each row opens a scrolling detail view. */
296
- 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> {
297
326
  return ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
298
327
  let page: ConsolePage = "roster";
299
- let mode: "list" | "detail" = "list";
328
+ let mode: "list" | "detail" | "picker" = "list";
300
329
  let selectedRoster = 0;
301
330
  let selectedBoard = 0;
302
331
  let listOffset = 0;
@@ -341,6 +370,107 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
341
370
 
342
371
  const style = createPiThemeStyle(theme);
343
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
+
344
474
  const currentRows = (): ConsoleRow[] =>
345
475
  page === "roster"
346
476
  // The console is the management surface: idle and stopped stay
@@ -364,7 +494,7 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
364
494
  const headerLine = (): string => {
365
495
  const alive = livingTeammates();
366
496
  const tasks = listTasks();
367
- 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 · ${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}`;
368
498
  };
369
499
 
370
500
  interface ContentLine {
@@ -396,7 +526,8 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
396
526
  const name = theme.bold(theme.fg(colorFor(key), `@${key}`));
397
527
  const live = livingTeammates().filter((teammate) => teammate.agent === key && teammate.status !== "stopped").length;
398
528
  const liveTag = live > 0 ? style.success(`[${live} live]`) : style.dim("[0 live]");
399
- return `${marker}${name} ${style.dim(def.scope)} ${style.dim(displaySource(def.source))} ${liveTag}`;
529
+ const provenance = def.scope === "session" ? "in-memory" : displaySource(def.source);
530
+ return `${marker}${name} ${style.dim(def.scope)} ${style.dim(provenance)} ${liveTag}`;
400
531
  };
401
532
 
402
533
  const taskRowText = (key: string, selected: boolean): string => {
@@ -431,7 +562,7 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
431
562
  }
432
563
  teammates.forEach((row, index) => lines.push({ text: teammateRowText(row.key, index === selection), select: index }));
433
564
  lines.push({ text: "", select: -1 });
434
- lines.push({ text: style.dim("== agent roles (persistent definitions) =="), select: -1 });
565
+ lines.push({ text: style.dim("== agent roles (persistent and session definitions) =="), select: -1 });
435
566
  if (roles.length === 0) {
436
567
  lines.push({ text: style.dim("No agent definitions discovered."), select: -1 });
437
568
  }
@@ -455,7 +586,7 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
455
586
  const footer = confirmName
456
587
  ? theme.fg("warning", `Shut down @${confirmName}? Its claimed task returns to the board · y yes / n no`)
457
588
  : style.dim(page === "roster"
458
- ? "↑↓ select · enter open · tab board · x shutdown · esc/q close"
589
+ ? "↑↓ select · enter open · tab board · x shutdown · m model · esc/q close"
459
590
  : "↑↓ select · enter open · tab roster · esc/q close");
460
591
  return [
461
592
  border,
@@ -501,8 +632,12 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
501
632
  };
502
633
 
503
634
  return {
504
- render: (width) => (mode === "list" ? renderList(width) : renderDetail(width)),
635
+ render: (width) => (mode === "picker" ? renderPicker(width) : mode === "detail" ? renderDetail(width) : renderList(width)),
505
636
  handleInput: (data: string) => {
637
+ if (mode === "picker") {
638
+ handlePickerInput(data);
639
+ return;
640
+ }
506
641
  if (mode !== "list") {
507
642
  handleDetailInput(data);
508
643
  return;
@@ -546,6 +681,14 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
546
681
  }
547
682
  return;
548
683
  }
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
+ }
690
+ return;
691
+ }
549
692
  const wheel = wheelDelta(data);
550
693
  if (wheel !== undefined) {
551
694
  scrollList(wheel * 3);