@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/.memory/team-centric-design.md +1 -1
- package/README.md +33 -13
- package/package.json +3 -3
- package/references/agent-roles.md +77 -0
- package/src/agents.ts +141 -15
- package/src/follow-up-queue.ts +1 -0
- package/src/guidance.ts +46 -20
- package/src/index.ts +6 -4
- package/src/picker-keys.ts +30 -0
- package/src/state.ts +42 -5
- package/src/team-machine.ts +406 -54
- package/src/tools.ts +79 -93
- package/src/types.ts +23 -4
- package/src/ui.ts +369 -69
- package/src/worker.ts +3 -1
- package/agents/observer.md +0 -10
- package/agents/reviewer.md +0 -13
- package/agents/specialist.md +0 -10
- package/agents/worker.md +0 -11
package/src/ui.ts
CHANGED
|
@@ -1,22 +1,36 @@
|
|
|
1
|
-
/** Passive team widget and
|
|
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 {
|
|
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 {
|
|
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(
|
|
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 /
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
280
|
-
|
|
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
|
-
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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 === "
|
|
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
|
-
|
|
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"
|
|
679
|
+
if (page === "roster" && row?.kind === "teammate" && getTeammate(row.key)?.status !== "stopped") {
|
|
680
|
+
confirmName = row.key;
|
|
681
|
+
}
|
|
395
682
|
return;
|
|
396
683
|
}
|
|
397
|
-
if (
|
|
398
|
-
|
|
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
|
|
435
|
-
if (
|
|
436
|
-
|
|
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:
|
|
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.");
|
package/agents/observer.md
DELETED
|
@@ -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.
|
package/agents/reviewer.md
DELETED
|
@@ -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.
|
package/agents/specialist.md
DELETED
|
@@ -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.
|