@fradser/pi-agent-teams 0.6.0 → 0.7.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/README.md CHANGED
@@ -36,9 +36,13 @@ Review the assigned scope for exploitable security problems. Do not edit files.
36
36
 
37
37
  Discovery precedence per name (later overrides earlier):
38
38
 
39
+ There are no built-in roles. When a needed role has no definition, the
40
+ leader creates one on demand: it derives the definition from the task using
41
+ the abstract role reference in `references/agent-roles.md` (definition
42
+ anatomy, archetype axes, invariants), writes the file, and then spawns.
43
+
39
44
  | Scope | Location | Git semantics |
40
45
  |---|---|---|
41
- | bundled | package `agents/` | shipped with the package |
42
46
  | user | `~/.pi/agent/agents/*.md` | system-level, never committed |
43
47
  | project | `<cwd>/.pi/agents/<name>.md` | **git-managed** — commit for the team |
44
48
  | project-local | `<cwd>/.pi/agents/<name>.local.md` | **local** — personal override, gitignore by convention |
@@ -90,7 +94,7 @@ send_message({ to, message, status? })
90
94
  - Peer mail: `to: "<teammate-name>"`; `status` is invalid.
91
95
  - Leader steering or direct assignment: `to: "<teammate-name>"`; a working recipient gets a control-stream steer, an idle recipient wakes with the message.
92
96
  - The first non-empty line of `message` becomes the console title.
93
- - Peer traffic never enters the leader model context; inspect it in `/teammate` instead.
97
+ - Peer traffic never enters the leader model context; inspect it in `/agent-teams` instead.
94
98
 
95
99
  Only the leader creates tasks. Idle teammates self-claim pending tasks whose dependencies are met. A task-level `verify` command overrides the claiming agent's frontmatter `verify`; zero exit completes the task, while failure returns stderr to the claimer for fix-and-resubmit.
96
100
 
@@ -105,7 +109,7 @@ Only the leader creates tasks. Idle teammates self-claim pending tasks whose dep
105
109
  | `task_list()` | Both | One shared board-view definition; leader view also includes the roster |
106
110
  | `task_claim(taskId?)` | Worker | Atomically self-claim a pending, unblocked task |
107
111
  | `task_submit(taskId, status, result?)` | Worker | Submit a claimed task outcome; completion passes through verify |
108
- | `/teammate` | User | Full-screen roster and board console, message/task details, shutdown |
112
+ | `/agent-teams` | User | Management console: session teammates, persistent agent roles, board, details, shutdown |
109
113
 
110
114
  That is **7 unique tool names**. There are no `teammate_run`, `teammate_fanout`, `teammate_cancel`, `teammate_retry`, or `teammate_message` tools.
111
115
 
@@ -127,17 +131,17 @@ Runtime state lives in `~/.pi/agent/teammate/<sessionKey>/` and dies with the se
127
131
  ```
128
132
  agent-teams/
129
133
  ├── index.ts — package-root extension entry point
130
- ├── agents/ — bundled declarative agent definitions
134
+ ├── references/ — role templates consulted when generating new agents
131
135
  ├── src/
132
136
  │ ├── index.ts — composition root and session lifecycle
133
- │ ├── tools.ts — leader tools and /teammate command
137
+ │ ├── tools.ts — leader tools and /agent-teams command
134
138
  │ ├── worker.ts — worker tools plus the shared task_list registration
135
139
  │ ├── team-machine.ts — resident lifecycle, mail routing, intents, verify, wake-ups
136
140
  │ ├── state.ts — roster, board, leader inbox state machine
137
141
  │ ├── spawner.ts — resident RPC process spawner (uncapped sequences)
138
142
  │ ├── agents.ts — agent discovery and frontmatter parsing
139
143
  │ ├── statefile.ts — runtime/board/mail IO and marker-file intents
140
- │ ├── ui.ts — passive widget and /teammate console
144
+ │ ├── ui.ts — passive widget and /agent-teams management console
141
145
  │ ├── guidance.ts — static leader and worker protocols
142
146
  │ └── follow-up-queue.ts — serialized automatic report delivery
143
147
  ├── features/ — BDD contract
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fradser/pi-agent-teams",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Multi-agent team system for Pi — resident teammates, shared task board, and peer-to-peer messaging",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -24,7 +24,7 @@
24
24
  "files": [
25
25
  "index.ts",
26
26
  "src",
27
- "agents",
27
+ "references",
28
28
  ".memory",
29
29
  "README.md"
30
30
  ],
@@ -39,6 +39,6 @@
39
39
  ]
40
40
  },
41
41
  "dependencies": {
42
- "@fradser/pi-kit": "0.2.0"
42
+ "@fradser/pi-kit": "0.3.0"
43
43
  }
44
44
  }
@@ -0,0 +1,77 @@
1
+ # Agent Role Reference
2
+
3
+ Abstract reference for generating new agent definitions on demand. This file
4
+ is NOT discoverable and CANNOT be spawned directly. Derive a concrete
5
+ definition from the anatomy, archetype axes, and invariants below, tailor it
6
+ to the task, and write it to `<cwd>/.pi/agents/<name>.md` (git-managed
7
+ project role) or `<name>.local.md` (personal override).
8
+
9
+ ## Definition anatomy
10
+
11
+ Frontmatter:
12
+
13
+ - `name` — unique id among discovered roles.
14
+ - `description` — the routing contract: when the leader should choose this
15
+ role, phrased as "use when ...". This is the most load-bearing field.
16
+ - `tools` — the minimal set; follows the mutability axis (see below).
17
+ - optional `model`, `verify`, `worktree`.
18
+
19
+ The Markdown body is the role prompt, built from five parts:
20
+
21
+ 1. Identity — "You are a `<role>` agent."
22
+ 2. Method — the procedure to execute, the criteria to judge against, or the
23
+ observation targets, depending on the judgment axis.
24
+ 3. Boundaries — what must never be touched or changed.
25
+ 4. Evidence — what every claim or finding must carry.
26
+ 5. Terminal report — delivery via `send_message(to="leader", message=...)`;
27
+ the last message to the leader MUST
28
+ carry status="completed" (or status="failed"); work without a terminal
29
+ status counts as unfinished work.
30
+
31
+ ## Archetype axes
32
+
33
+ Pick a position on two axes before writing anything:
34
+
35
+ | Axis | Values |
36
+ |---|---|
37
+ | Mutability | mutating (`edit`,`write` allowed) · read-only |
38
+ | Judgment | execute a given procedure · evaluate against criteria · apply domain expertise · observe and report |
39
+
40
+ Read-only plus each judgment value yields the four classical shapes:
41
+
42
+ | Shape | Typical tools | Body emphasis |
43
+ |---|---|---|
44
+ | executor | `read,bash,edit,write` | implement end to end, then self-verify by running tests or commands; report changes made, verification performed, remaining risks |
45
+ | reviewer | `read,bash` | only confirmed findings, with severity, evidence, exact paths, and a minimal fix recommendation; when nothing is found, say so and list the checks performed |
46
+ | specialist | `read,bash` | deep domain analysis; state assumptions, the evidence used, and the bounds of expertise for this task |
47
+ | observer | `read` | snapshots or readings; summarize observed state and anomalies with timestamps and evidence; change nothing |
48
+
49
+ These shapes are starting points, not a fixed menu: a generated role may sit
50
+ anywhere on the axes (for example a mutating specialist with a verify gate),
51
+ as long as every invariant below holds.
52
+
53
+ ## Invariants
54
+
55
+ Non-negotiable in every generated definition:
56
+
57
+ - Tool sets match the mutability axis: read-only roles never receive
58
+ `edit` or `write`.
59
+ - Every finding or claim carries evidence: exact paths, command output,
60
+ timestamps — never bare assertions.
61
+ - Scope is bounded: mutating roles state "do not touch files outside the
62
+ assigned scope"; read-only roles state the equivalent prohibition.
63
+ - Terminal-status discipline applies to all shapes alike.
64
+
65
+ ## Skeleton
66
+
67
+ ```markdown
68
+ ---
69
+ name: <role-name>
70
+ description: <one-line capability>; use when <routing condition>
71
+ tools: <minimal set from the mutability axis>
72
+ ---
73
+ You are a <role-name> agent. <Method: the procedure to follow, the criteria
74
+ to evaluate against, or the targets to observe>. <Boundary statement>.
75
+ Report <expected artifacts> with <evidence requirements> via
76
+ send_message(to="leader", message=...) with status="completed".
77
+ ```
package/src/agents.ts CHANGED
@@ -2,10 +2,12 @@
2
2
  * Declarative agent definitions: Markdown files with YAML-style frontmatter.
3
3
  *
4
4
  * Discovery scopes (later scopes override earlier ones for the same name):
5
- * 1. bundled — agents shipped with this package (agents/)
6
- * 2. user — ~/.pi/agent/agents/ (respects PI_CODING_AGENT_DIR)
7
- * 3. project — <cwd>/.pi/agents/<name>.md
8
- * 4. project-local — <cwd>/.pi/agents/<name>.local.md (personal override)
5
+ * 1. user — ~/.pi/agent/agents/ (respects PI_CODING_AGENT_DIR)
6
+ * 2. project — <cwd>/.pi/agents/<name>.md
7
+ * 3. project-local — <cwd>/.pi/agents/<name>.local.md (personal override)
8
+ *
9
+ * There are no built-in roles: references/agent-roles.md ships template
10
+ * shapes that the leader consults when generating a new definition on demand.
9
11
  *
10
12
  * Frontmatter fields:
11
13
  * name — unique agent id (required)
@@ -22,7 +24,7 @@ import * as path from "node:path";
22
24
  import { fileURLToPath } from "node:url";
23
25
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
24
26
 
25
- export type AgentScope = "bundled" | "user" | "project" | "project-local";
27
+ export type AgentScope = "user" | "project" | "project-local";
26
28
 
27
29
  export interface AgentDefinition {
28
30
  name: string;
@@ -44,7 +46,11 @@ export interface AgentDefinition {
44
46
  source: string;
45
47
  }
46
48
 
47
- const BUNDLED_AGENTS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "agents");
49
+ /** Shipped role templates: reference material for generating new definitions
50
+ * on demand — never discovered and never spawnable directly. */
51
+ export const AGENT_REFERENCE_PATH = path.resolve(
52
+ path.dirname(fileURLToPath(import.meta.url)), "..", "references", "agent-roles.md",
53
+ );
48
54
 
49
55
  /** Files named `xxx.local.md` in the project agents directory are personal
50
56
  * overrides: same teammate name as `xxx.md`, project-local scope, never git-
@@ -62,13 +68,40 @@ function parseFrontmatter(raw: string): { fields: Record<string, string | string
62
68
  const fields: Record<string, string | string[]> = {};
63
69
  const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
64
70
  if (!match) return { fields, body: raw.trim() };
65
- for (const line of match[1].split("\n")) {
71
+ const lines = match[1].split("\n");
72
+ for (let index = 0; index < lines.length; index++) {
73
+ const line = lines[index];
66
74
  const separator = line.indexOf(":");
67
75
  if (separator <= 0) continue;
68
76
  const key = line.slice(0, separator).trim();
69
77
  // Strip inline YAML comments (# ...) and surrounding whitespace/quotes.
70
78
  let value = line.slice(separator + 1).replace(/\s+#.*$/, "").trim();
71
79
  if (key === "tools") {
80
+ if (value === "") {
81
+ // YAML block sequence: "- item" lines, flush-left or indented,
82
+ // possibly interleaved with blank or comment-only lines.
83
+ const items: string[] = [];
84
+ index += 1;
85
+ while (index < lines.length) {
86
+ const trimmed = lines[index].trim();
87
+ if (trimmed === "" || trimmed.startsWith("#")) {
88
+ index += 1;
89
+ continue;
90
+ }
91
+ if (!/^-.+/.test(trimmed)) break;
92
+ const rawItem = trimmed.replace(/^-\s*/, "");
93
+ const item = rawItem
94
+ .replace(/\s+#.*$/, "")
95
+ .trim()
96
+ .replace(/^["']|["']$/g, "");
97
+ // "- # comment" is a comment-only entry, not a tool.
98
+ if (item && !rawItem.trimStart().startsWith("#")) items.push(item);
99
+ index += 1;
100
+ }
101
+ index -= 1; // The for-loop steps past the last consumed line.
102
+ fields.tools = items;
103
+ continue;
104
+ }
72
105
  const tools = value
73
106
  .replace(/^\[|\]$/g, "")
74
107
  .split(",")
@@ -133,11 +166,10 @@ function scopeIsGitManaged(scope: AgentScope): boolean {
133
166
 
134
167
  /**
135
168
  * Resolve all agent definitions visible from the given cwd, with later scopes
136
- * overriding earlier ones per name: project-local > project > user > bundled.
169
+ * overriding earlier ones per name: project-local > project > user.
137
170
  */
138
171
  export function discoverAgents(cwd?: string): Map<string, AgentDefinition> {
139
172
  const agents = new Map<string, AgentDefinition>();
140
- loadDir("bundled", BUNDLED_AGENTS_DIR, agents);
141
173
  loadDir("user", agentsDir("user", cwd), agents);
142
174
  loadDir("project", agentsDir("project", cwd), agents);
143
175
  return agents;
@@ -158,7 +190,7 @@ export function hasAgent(name: string, cwd?: string): boolean {
158
190
  */
159
191
  export function formatAgentGuidance(cwd?: string): string {
160
192
  const agents = discoverAgents(cwd);
161
- if (agents.size === 0) return "(none found in bundled, user, project, or project-local scopes)";
193
+ if (agents.size === 0) return "(none defined yet — create one on demand from the shipped templates)";
162
194
  const lines: string[] = [];
163
195
  for (const agent of agents.values()) {
164
196
  const extras = [
@@ -3,6 +3,7 @@ export const TEAMMATE_REPORT_MESSAGE_TYPE = "agent-teams-report";
3
3
  export interface FollowUpReport {
4
4
  teammate?: string;
5
5
  agent?: string;
6
+ spawnId?: string;
6
7
  body: string;
7
8
  finished?: boolean;
8
9
  runId?: string;
package/src/guidance.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { formatAgentGuidance } from "./agents.ts";
1
+ import { AGENT_REFERENCE_PATH, formatAgentGuidance } from "./agents.ts";
2
2
 
3
3
  export const WORKER_GUIDANCE = `
4
4
  ## Resident Teammate Protocol
@@ -39,22 +39,31 @@ delegation, synthesis, and the final user-facing answer. Teammates are named
39
39
  resident child processes with isolated contexts; they do not see this
40
40
  conversation unless you put the needed context in their prompts.
41
41
 
42
- ### Agents are declarative files
42
+ ### Agents are declarative files, created on demand
43
43
 
44
44
  Agents live in Markdown files with frontmatter (name, description, tools,
45
45
  optional model, optional verify, optional worktree); the body is the role
46
- prompt. Discovery precedence per name: project-local
47
- \`<cwd>/.pi/agents/<name>.local.md\` > project
48
- \`<cwd>/.pi/agents/<name>.md\` > user \`~/.pi/agent/agents\` > bundled package
49
- agents. Same-name project/project-local pairs deduplicate into one
50
- definition, with project-local winning; project definitions are git-managed,
51
- while project-local and user definitions are personal/non-git-managed. Model
52
- and worktree behavior are role attributes: define a role variant when they
53
- need to differ.
46
+ prompt. There are no built-in roles. Discovery precedence per name:
47
+ project-local \`<cwd>/.pi/agents/<name>.local.md\` > project
48
+ \`<cwd>/.pi/agents/<name>.md\` > user \`~/.pi/agent/agents\`. Same-name
49
+ project/project-local pairs deduplicate into one definition, with
50
+ project-local winning; project definitions are git-managed, while
51
+ project-local and user definitions are personal/non-git-managed. Model and
52
+ worktree behavior are role attributes: define a role variant when they need
53
+ to differ.
54
54
 
55
55
  Available agents:
56
56
  ${agents}
57
57
 
58
+ When an assignment or user request needs an agent whose name has no
59
+ definition yet, create it first: derive it from the shipped abstract role
60
+ reference at \`${AGENT_REFERENCE_PATH}\` — its definition anatomy, archetype
61
+ axes, and invariants (read it before inventing a novel role) — tailor it to
62
+ the task, and write
63
+ the definition to \`<cwd>/.pi/agents/<name>.md\` for a git-managed project
64
+ role or \`<name>.local.md\` for a personal override; discovery picks it up
65
+ immediately and spawning can proceed.
66
+
58
67
  ### Build a team in one step per teammate
59
68
 
60
69
  Use teammate_spawn(name, agent, optional kickoff prompt) to start a resident
@@ -63,13 +72,18 @@ inbox messages and claimable board tasks, and can message peers directly.
63
72
  A session-wide cap of 8 living teammates applies. An agent with
64
73
  worktree: true receives its own git worktree; its diff is captured at shutdown.
65
74
 
75
+ Users may also ask for these conversationally ("add a reviewer teammate",
76
+ "create a scribe role") — apply the same create-on-demand step above when
77
+ the role does not exist yet. The human-facing management surface is the
78
+ /agent-teams command; never simulate or describe menu flows to the user.
79
+
66
80
  ### Coordinate through one messaging primitive and the board
67
81
 
68
82
  send_message is the only messaging tool. Address a teammate by name to steer
69
83
  it or hand work to it; working teammates receive it immediately and idle
70
84
  teammates wake automatically. The reserved recipient name "leader" is only
71
85
  for worker reports, not for leader calls. Peer traffic never reaches your
72
- context — inspect it in /teammate instead.
86
+ context — inspect it in /agent-teams instead.
73
87
 
74
88
  Create shared work with task_create(subject, description?, dependsOn?,
75
89
  verify?). Idle teammates notice claimable work automatically and self-claim;
@@ -85,7 +99,7 @@ working teammate produces no output for a while; that notice is information,
85
99
  not a verdict — decide whether to keep waiting, steer again with send_message,
86
100
  or teammate_shutdown it. To carry wedged work forward, spawn a successor whose
87
101
  prompt composes context from the original kickoff, the teammate's past reports
88
- (leader mailbox or /teammate detail view), its board claims, and any live
102
+ (leader mailbox or /agent-teams detail view), its board claims, and any live
89
103
  transcript tail. The harness never reclaims, restarts, or replaces a teammate
90
104
  on its own.
91
105
 
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@ import { resetState } from "./state.ts";
14
14
  import { ensureTeamWidget, refreshTeamUI, stopUiTimers } from "./ui.ts";
15
15
  import { registerLeaderTools, registerTeamCommand } from "./tools.ts";
16
16
  import { registerWorkerCapabilities, workerBinding } from "./worker.ts";
17
- import { formatAgentMessagePrefix } from "@fradser/pi-kit";
17
+ import { formatAgentMessagePrefix, formatExpandHint } from "@fradser/pi-kit";
18
18
  import { FollowUpQueue, groupReportsByTeammate, TEAMMATE_REPORT_MESSAGE_TYPE, type FollowUpReport } from "./follow-up-queue.ts";
19
19
  import { Box, Markdown, Text } from "@earendil-works/pi-tui";
20
20
 
@@ -23,6 +23,9 @@ const STATE_DIR_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
23
23
  let leaderPi: ExtensionAPI | undefined;
24
24
  let leaderCtx: ExtensionContext | undefined;
25
25
  let followUpQueue: FollowUpQueue | undefined;
26
+ // One "Teammate finished" announcement per spawn incarnation; repeated
27
+ // terminal reports from the same resident stay ordinary report rows.
28
+ const announcedFinishKeys = new Set<string>();
26
29
 
27
30
  const REPORT_COLORS = ["success", "warning", "error", "mdLink"] as const;
28
31
  export const TEAMMATE_FINISHED_ENTRY_TYPE = "agent-teams-teammate-finished";
@@ -61,7 +64,7 @@ export default function (pi: ExtensionAPI) {
61
64
  }
62
65
  if (!expanded) {
63
66
  const groups = groupReportsByTeammate(reports);
64
- const hint = theme.fg("dim", ` · ${keyHint("app.tools.expand", "to expand")}`);
67
+ const hint = formatExpandHint(keyHint("app.tools.expand", "to expand"), theme);
65
68
  for (const group of groups) {
66
69
  const prefix = theme.fg("customMessageLabel", theme.bold(formatAgentMessagePrefix("from", group.reports.length)));
67
70
  const name = theme.fg(reportColor(group.teammate), `@${group.teammate}`);
@@ -88,7 +91,7 @@ export default function (pi: ExtensionAPI) {
88
91
  if (event.message.role !== "custom" || event.message.customType !== TEAMMATE_REPORT_MESSAGE_TYPE) return;
89
92
  const reports = extractReports(event.message.details);
90
93
  for (const report of reports) {
91
- if (!report.finished) continue;
94
+ if (!markTeammateFinished(announcedFinishKeys, report)) continue;
92
95
  pi.appendEntry(TEAMMATE_FINISHED_ENTRY_TYPE, {
93
96
  teammate: report.teammate ?? report.agent,
94
97
  agent: report.agent,
@@ -99,6 +102,7 @@ export default function (pi: ExtensionAPI) {
99
102
  pi.on("session_start", async (_event, ctx) => {
100
103
  resetState();
101
104
  followUpQueue?.reset();
105
+ announcedFinishKeys.clear();
102
106
  leaderCtx = ctx;
103
107
  followUpQueue = new FollowUpQueue({
104
108
  isIdle: () => Boolean(leaderCtx?.isIdle()),
@@ -158,3 +162,21 @@ function extractReports(details: unknown): FollowUpReport[] {
158
162
  if (typed && "teammate" in typed) return [typed as FollowUpReport];
159
163
  return [];
160
164
  }
165
+
166
+ /**
167
+ * Announce a finished entry only on the first terminal report of a spawn
168
+ * incarnation. Reports without a spawn identity key on the session scope so
169
+ * crash diagnostics always stay visible. Returns true when the caller should
170
+ * append the finished entry.
171
+ */
172
+ export function markTeammateFinished(
173
+ seen: Set<string>,
174
+ report: Pick<FollowUpReport, "teammate" | "agent" | "spawnId" | "finished">,
175
+ ): boolean {
176
+ if (!report.finished) return false;
177
+ const name = report.teammate ?? report.agent ?? "teammate";
178
+ const key = `${name}:${report.spawnId ?? "session"}`;
179
+ if (seen.has(key)) return false;
180
+ seen.add(key);
181
+ return true;
182
+ }
@@ -125,6 +125,8 @@ const pendingShutdowns = new Set<string>();
125
125
  const verifyingTasks = new Map<string, { worker: string; spawnId: string }>();
126
126
  /** Idle nudges already fired per teammate incarnation (one per transition). */
127
127
  const idleNudgesSent = new Set<string>();
128
+ /** Self-finalize requests delivered per teammate incarnation before escalating to the leader. */
129
+ const selfFinalizeAttempts = new Set<string>();
128
130
  const pendingDeliveries = new Map<string, InboxMessage[]>();
129
131
  const liveWorktrees = new Map<string, ReturnType<typeof createWorktree>>();
130
132
 
@@ -163,6 +165,7 @@ export function shutdownTeamMachine(): void {
163
165
  pendingShutdowns.clear();
164
166
  verifyingTasks.clear();
165
167
  idleNudgesSent.clear();
168
+ selfFinalizeAttempts.clear();
166
169
  pendingDeliveries.clear();
167
170
  }
168
171
 
@@ -408,7 +411,7 @@ function checkStalledTeammates(now = Date.now()): void {
408
411
  const silence = stallSilenceMs(teammate, now);
409
412
  if (silence === undefined) continue;
410
413
  if (silence < STALL_NOTICE_MS || teammate.stallNoticeSentAt !== undefined) continue;
411
- const body = `@${teammate.name} has produced no RPC output for ${formatSilenceDuration(silence)}. The child may be blocked in a provider or tool call; steer delivery is uncertain. Decide: keep waiting, steer again, or shut it down (and respawn a successor with context from the original kickoff, its mailbox reports, board claims, and the /teammate detail transcript).`;
414
+ const body = `@${teammate.name} has produced no RPC output for ${formatSilenceDuration(silence)}. The child may be blocked in a provider or tool call; steer delivery is uncertain. Decide: keep waiting, steer again, or shut it down (and respawn a successor with context from the original kickoff, its mailbox reports, board claims, and the /agent-teams detail transcript).`;
412
415
  updateTeammate(teammate.name, { stallNoticeSentAt: now });
413
416
  deliverToLeader({ from: "harness", subject: `Possible stall: @${teammate.name}`, body });
414
417
  sendUpdate({ teammate: teammate.name, agent: teammate.agent, body, finished: false });
@@ -428,9 +431,24 @@ export function hasUnfinalizedReport(name: string): boolean {
428
431
  /** One light reminder per idle transition when work looks unfinished. */
429
432
  function nudgeIfUnfinalized(name: string, spawnId: string): void {
430
433
  const key = `${name}:${spawnId}`;
431
- if (idleNudgesSent.has(key) || !hasUnfinalizedReport(name)) return;
434
+ // The terminal report may have been written to the outbox file but not yet
435
+ // drained into the leader mailbox by the next tick; drain before deciding.
436
+ drainTeammateOutboxes();
437
+ if (!hasUnfinalizedReport(name)) return;
438
+ if (!selfFinalizeAttempts.has(key)) {
439
+ // First miss: give the worker one chance to fix its own bookkeeping
440
+ // before bothering the leader. The inbox message wakes it on the next tick.
441
+ selfFinalizeAttempts.add(key);
442
+ deliverFeedback(
443
+ name,
444
+ "Assignment not finalized",
445
+ 'Your latest message to the leader carried no terminal status. Send send_message(to="leader", message=...) now with status="completed" or status="failed" summarizing your final result.',
446
+ );
447
+ return;
448
+ }
449
+ if (idleNudgesSent.has(key)) return;
432
450
  idleNudgesSent.add(key);
433
- const reminder = `@${name} is now idle but its last report was not marked status="completed" or "failed". Its conclusions may be stuck in the mailbox — ask it to finalize or inspect the /teammate console.`;
451
+ const reminder = `@${name} is now idle but its last report was not marked status="completed" or "failed". Its conclusions may be stuck in the mailbox — ask it to finalize or inspect the /agent-teams console.`;
434
452
  deliverToLeader({ from: "harness", subject: `Idle without terminal report: @${name}`, body: reminder });
435
453
  sendUpdate({ teammate: name, body: reminder, finished: false });
436
454
  }
@@ -464,6 +482,8 @@ export async function shutdownTeammate(name: string): Promise<{ ok: true; body:
464
482
  pendingShutdowns.delete(name);
465
483
  const released = releaseTasksOf(name, "Teammate was shut down.");
466
484
  updateTeammate(name, { status: "stopped", activeTool: undefined });
485
+ idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
486
+ selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
467
487
  publishStateSnapshot();
468
488
  notifyChange();
469
489
  return { ok: true, body: summarizeShutdown(name, released.length, 0, undefined) };
@@ -493,6 +513,7 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
493
513
  // A stopped teammate must not keep the poll loop or its queue alive.
494
514
  pendingDeliveries.delete(name);
495
515
  idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
516
+ selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
496
517
  updateTeammate(name, {
497
518
  status: "stopped",
498
519
  activeTool: undefined,
@@ -614,6 +635,7 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
614
635
  sendUpdate({
615
636
  teammate: teammate.name,
616
637
  agent: teammate.agent,
638
+ spawnId: teammate.spawnId,
617
639
  body: record.body,
618
640
  finished: true,
619
641
  });
package/src/tools.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { formatAgentMessagePrefix, formatAgentTaskName, formatToolEventLabel } from "@fradser/pi-kit";
1
+ import { keyHint, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { formatAgentMessagePrefix, formatAgentTaskName, formatExpandHint, formatToolEventLabel, safeDisplayText } from "@fradser/pi-kit";
3
3
  import {
4
4
  createBoardTask,
5
5
  formatSilenceDuration,
@@ -12,13 +12,8 @@ import { listTasks, livingTeammates } from "./state.ts";
12
12
  import { LEADER_RECIPIENT, SendMessageParams, TeammateShutdownParams, TeammateSpawnParams, TaskCreateParams } from "./types.ts";
13
13
  import { registerTaskListTool } from "./worker.ts";
14
14
  import { openTeamConsole, refreshTeamUI } from "./ui.ts";
15
+ import { discoverAgents } from "./agents.ts";
15
16
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
16
- import * as fs from "node:fs";
17
- import * as path from "node:path";
18
-
19
- function isValidAgentName(name: string): boolean {
20
- return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && !name.includes("..") && name !== "con";
21
- }
22
17
 
23
18
  function rosterSummary(): string {
24
19
  const alive = livingTeammates();
@@ -39,7 +34,7 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
39
34
  renderCall: () => new Text("", 0, 0),
40
35
  renderResult(result, _options, theme, context) {
41
36
  const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
42
- if ((result as { isError?: boolean }).isError) {
37
+ if (context.isError) {
43
38
  return new Text(theme.fg("error", text.split("\n")[0] || "Failed to spawn teammate."), 0, 0);
44
39
  }
45
40
  const params = context.args as { name: string; agent: string; prompt?: string };
@@ -71,17 +66,24 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
71
66
  parameters: TeammateShutdownParams,
72
67
  renderShell: "self",
73
68
  renderCall: () => new Text("", 0, 0),
74
- renderResult(result, _options, theme, context) {
69
+ renderResult(result, options, theme, context) {
75
70
  const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
76
- if ((result as { isError?: boolean }).isError) {
71
+ if (context.isError) {
77
72
  return new Text(theme.fg("error", text.split("\n")[0] || "Failed to shut down teammate."), 0, 0);
78
73
  }
79
74
  const name = String((context.args as { name?: string }).name ?? "");
80
- const line = `${theme.fg("toolTitle", theme.bold(formatToolEventLabel("event", `@${name} shut down`).trimEnd()))}`;
81
- return {
82
- render: (width: number) => [truncateToWidth(line, Math.max(1, width))],
83
- invalidate: () => {},
75
+ const title = theme.fg("toolTitle", theme.bold(formatToolEventLabel("event", `@${name} shut down`, "agent")));
76
+ const render = (width: number) => {
77
+ if (width <= 0) return [];
78
+ if (!options.expanded) {
79
+ return [truncateToWidth(`${title}${formatExpandHint(keyHint("app.tools.expand", "to expand"), theme)}`, width)];
80
+ }
81
+ return [
82
+ truncateToWidth(title, width),
83
+ ...text.split("\n").map((line) => truncateToWidth(theme.fg("customMessageText", safeDisplayText(line)), width)),
84
+ ];
84
85
  };
86
+ return { render, invalidate: () => {} };
85
87
  },
86
88
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
87
89
  const result = await shutdownTeammate(params.name);
@@ -97,10 +99,26 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
97
99
  label: "Send Message",
98
100
  description: "The only messaging primitive. Address a living teammate by name; working teammates receive a steer immediately and idle teammates wake with the message. status is reserved for worker reports to leader.",
99
101
  parameters: SendMessageParams,
100
- renderCall(args, theme) {
101
- const prefix = theme.fg("toolTitle", theme.bold(formatAgentMessagePrefix("to")));
102
- const recipient = theme.fg("accent", `@${String(args.to ?? "")}`);
103
- return new Text(`${prefix}${recipient}`, 0, 0);
102
+ // Canonical lifecycle rows (same as packages/monitor): empty call slot,
103
+ // ONE delivery row owned by renderResult.
104
+ renderShell: "self",
105
+ renderCall: () => new Text("", 0, 0),
106
+ renderResult(result, _options, theme, context) {
107
+ const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
108
+ if (context.isError) {
109
+ return new Text(theme.fg("error", text.split("\n")[0] || "Failed to send message."), 0, 0);
110
+ }
111
+ const to = String((context.args as { to?: string }).to ?? "");
112
+ const details = result.details as { queued?: boolean; stalledMs?: number } | undefined;
113
+ const suffix = details?.queued ? "queued" : "delivered";
114
+ let line = `${theme.fg("toolTitle", theme.bold(formatAgentMessagePrefix("to")))}${theme.fg("accent", `@${to}`)}${theme.fg("dim", ` · ${suffix}`)}`;
115
+ if (!details?.queued && details?.stalledMs !== undefined) {
116
+ line += theme.fg("dim", ` · stalled ${formatSilenceDuration(details.stalledMs)}`);
117
+ }
118
+ return {
119
+ render: (width: number) => width > 0 ? [truncateToWidth(line, width)] : [],
120
+ invalidate: () => {},
121
+ };
104
122
  },
105
123
  async execute(_toolCallId, params) {
106
124
  if (params.to === LEADER_RECIPIENT) throw new Error('The leader cannot send a message to itself.');
@@ -112,7 +130,12 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
112
130
  if (!result.queued && result.stalledMs !== undefined) {
113
131
  text += `\nWarning: @${params.to} has been stalled with no output for ${formatSilenceDuration(result.stalledMs)} — the control-stream write succeeded, but the teammate may be wedged. Consider teammate_shutdown if it stays silent.`;
114
132
  }
115
- return { content: [{ type: "text", text }], details: {} };
133
+ return {
134
+ content: [{ type: "text", text }],
135
+ details: result.stalledMs !== undefined && !result.queued
136
+ ? { queued: result.queued, stalledMs: result.stalledMs }
137
+ : { queued: result.queued },
138
+ };
116
139
  },
117
140
  });
118
141
 
@@ -122,6 +145,22 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
122
145
  label: "Create Task",
123
146
  description: "Create one shared board task. Resident teammates self-claim it when dependencies are met; an optional verify command gates completion.",
124
147
  parameters: TaskCreateParams,
148
+ // Canonical lifecycle rows (same as packages/monitor): empty call slot,
149
+ // ONE created row owned by renderResult.
150
+ renderShell: "self",
151
+ renderCall: () => new Text("", 0, 0),
152
+ renderResult(result, _options, theme, context) {
153
+ const text = result.content[0]?.type === "text" ? (result.content[0] as { type: string; text: string }).text : "";
154
+ if (context.isError) {
155
+ return new Text(theme.fg("error", text.split("\n")[0] || "Failed to create task."), 0, 0);
156
+ }
157
+ const subject = String((context.args as { subject?: string }).subject ?? "");
158
+ const line = theme.fg("toolTitle", theme.bold(formatToolEventLabel("created", subject, "board")));
159
+ return {
160
+ render: (width: number) => width > 0 ? [truncateToWidth(line, width)] : [],
161
+ invalidate: () => {},
162
+ };
163
+ },
125
164
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
126
165
  const tasks = listTasks();
127
166
  if (params.dependsOn?.some((dep) => !tasks.some((task) => task.id === dep))) {
@@ -137,81 +176,23 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
137
176
  registerTaskListTool(pi);
138
177
  }
139
178
 
179
+ function teamStatusSummary(): string {
180
+ const roles = [...discoverAgents().keys()].map((name) => `@${name}`);
181
+ const rolesLine = roles.length > 0
182
+ ? `${roles.length} persistent agent role${roles.length === 1 ? "" : "s"}: ${roles.join(", ")}`
183
+ : "No agent roles discovered.";
184
+ return `${rosterSummary()}\n${rolesLine}`;
185
+ }
186
+
140
187
  export function registerTeamCommand(pi: ExtensionAPI): void {
141
188
  pi.registerCommand("agent-teams", {
142
- description: "Agent Teams menu: console and create agents from session history",
189
+ description: "Agent Teams management console: session teammates and persistent agent roles",
143
190
  handler: async (_args, ctx) => {
144
- const choice = await ctx.ui.select("agent-teams", ["console", "project", "local"]);
145
- if (!choice) return;
146
- if (choice === "console") {
147
- publishStateSnapshot();
148
- if (ctx.mode !== "tui") { ctx.ui.notify(rosterSummary(), "info"); return; }
149
- await openTeamConsole(ctx);
150
- refreshTeamUI(ctx);
151
- return;
152
- }
153
- await createAgentFromHistory(ctx, choice === "local");
191
+ publishStateSnapshot();
192
+ if (ctx.mode !== "tui") { ctx.ui.notify(teamStatusSummary(), "info"); return; }
193
+ await openTeamConsole(ctx);
194
+ refreshTeamUI(ctx);
154
195
  },
155
196
  });
156
197
  }
157
198
 
158
- interface GeneratedAgent { name?: string; description?: string; tools?: string[]; prompt?: string; content?: string; }
159
-
160
- function parseGeneratedAgents(raw: string): GeneratedAgent[] {
161
- const cleaned = raw.trim().replace(/^```(?:json)?\s*|\s*```$/g, "");
162
- const start = cleaned.indexOf("[");
163
- const end = cleaned.lastIndexOf("]");
164
- if (start < 0 || end <= start) return [];
165
- try {
166
- const value: unknown = JSON.parse(cleaned.slice(start, end + 1));
167
- return Array.isArray(value) ? value.filter((item): item is GeneratedAgent => Boolean(item && typeof item === "object")) : [];
168
- } catch { return []; }
169
- }
170
-
171
- function renderAgentDefinition(agent: GeneratedAgent): string {
172
- const name = agent.name!.trim();
173
- const description = (agent.description || "Agent created from session history").replace(/[\r\n]+/g, " ").trim();
174
- const tools = Array.isArray(agent.tools) ? agent.tools.filter((tool) => typeof tool === "string" && tool.trim()).join(", ") : "";
175
- return `---\nname: ${name}\ndescription: ${description}${tools ? `\ntools: ${tools}` : ""}\n---\n\n${(agent.prompt || "").trim()}`;
176
- }
177
-
178
- async function createAgentFromHistory(ctx: ExtensionContext, local: boolean): Promise<void> {
179
- const branch = ctx.sessionManager?.getBranch?.() ?? [];
180
- const history = branch.map((entry: any) => {
181
- const text = typeof entry?.text === "string" ? entry.text : typeof entry?.content === "string" ? entry.content : "";
182
- return text ? text : Array.isArray(entry?.content) ? entry.content.map((part: any) => part?.text ?? "").join("") : "";
183
- }).filter(Boolean).slice(-20).join("\n\n");
184
- const generated = await generateAgentPrompt(ctx, history);
185
- if (!generated) return;
186
- const dir = path.join(ctx.cwd || process.cwd(), ".pi", "agents");
187
- fs.mkdirSync(dir, { recursive: true });
188
- const definitions = parseGeneratedAgents(generated)
189
- .filter((item) => typeof item.name === "string" && (item.content?.trim() || item.prompt?.trim()))
190
- .map((item) => ({ name: item.name!.trim(), content: item.content?.trim() || renderAgentDefinition(item) }));
191
- if (definitions.length === 0) { ctx.ui.notify("Model returned no usable agent definitions", "error"); return; }
192
- let created = 0;
193
- for (const definition of definitions) {
194
- if (!isValidAgentName(definition.name)) continue;
195
- const filename = `${definition.name}${local ? ".local" : ""}.md`;
196
- try { fs.writeFileSync(path.join(dir, filename), definition.content, { encoding: "utf8", flag: "wx" }); created++; } catch (error) {
197
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
198
- }
199
- }
200
- ctx.ui.notify(`Created ${created} agent definition${created === 1 ? "" : "s"}`, created ? "info" : "warning");
201
- }
202
-
203
- async function generateAgentPrompt(ctx: ExtensionContext, history: string): Promise<string | undefined> {
204
- const model = ctx.model;
205
- const registry = ctx.modelRegistry;
206
- if (!model || !registry?.complete) {
207
- ctx.ui.notify("No model available to generate an agent", "error");
208
- return undefined;
209
- }
210
- const response = await registry.complete(model, {
211
- systemPrompt: "Design a complementary resident coding agent team from session history. Return a JSON array of 2-4 complementary agent definitions. Each item must have name, description, tools, and prompt fields. Names use letters, digits, dots, dashes, underscores. Return JSON only, with no code fences.",
212
- messages: [{ role: "user", content: `Session history:\n${history || "(empty)"}`, timestamp: Date.now() }],
213
- }, { maxTokens: 2000, temperature: 0, cacheRetention: "none" });
214
- const content = response.content?.filter((part: any) => part.type === "text").map((part: any) => part.text).join("\\n").trim();
215
- if (!content) { ctx.ui.notify("Model returned no agent definition", "error"); return undefined; }
216
- return content;
217
- }
package/src/types.ts CHANGED
@@ -165,7 +165,7 @@ export interface InboxMessage {
165
165
  /** Spawn one named resident teammate. */
166
166
  export const TeammateSpawnParams = Type.Object({
167
167
  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 (bundled, user, or project scope); its frontmatter pins model and optional worktree isolation" }),
168
+ agent: Type.String({ description: "Agent definition name (from user, project, or project-local scopes); its frontmatter pins model and optional worktree isolation" }),
169
169
  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" })),
170
170
  });
171
171
 
package/src/ui.ts CHANGED
@@ -1,4 +1,5 @@
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
4
  import { truncateToWidth, Key, matchesKey } from "@earendil-works/pi-tui";
4
5
  import { createPiThemeStyle, PI_SPINNER_FRAMES, PI_SPINNER_INTERVAL_MS } from "@fradser/pi-kit";
@@ -7,6 +8,7 @@ import {
7
8
  clampConsoleScroll, consoleScrollRange, maxConsoleBody, scrollConsoleDetail, wrapConsoleDetail,
8
9
  } from "./console-viewport.ts";
9
10
  import { fitTeammateRow, formatTeammateLabel, runningTeammateActivity } from "./activity.ts";
11
+ import { discoverAgents, resolveAgent, type AgentDefinition } from "./agents.ts";
10
12
  import { getState, getTeammate, listTasks, listTeammates, livingTeammates } from "./state.ts";
11
13
  import {
12
14
  ensureLivePoll,
@@ -36,7 +38,7 @@ function colorFor(name: string): (typeof TEAM_COLORS)[number] {
36
38
  }
37
39
 
38
40
  function ensureSpinner(): void {
39
- const working = livingTeammates().some((t) => t.status === "working" || t.status === "starting");
41
+ const working = livingTeammates().some(isWorking);
40
42
  if (working && !spinnerTimer) {
41
43
  spinnerTimer = setInterval(() => {
42
44
  spinnerFrame = (spinnerFrame + 1) % PI_SPINNER_FRAMES.length;
@@ -72,7 +74,7 @@ export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string
72
74
  placement: "belowEditor",
73
75
  render: (width: number) => {
74
76
  // Only WORKING teammates appear above the input box; idle and
75
- // stopped teammates stay in the /teammate console instead.
77
+ // stopped teammates stay in the /agent-teams console instead.
76
78
  const working = livingTeammates().filter(isWorking);
77
79
  if (working.length === 0) return [];
78
80
  const lines: string[] = [];
@@ -171,6 +173,11 @@ function indent(text: string): string[] {
171
173
  return text.split("\n").map((line) => ` ${line}`);
172
174
  }
173
175
 
176
+ function displaySource(sourcePath: string): string {
177
+ const relative = path.relative(process.cwd(), sourcePath);
178
+ return relative.startsWith("..") ? sourcePath : `./${relative}`;
179
+ }
180
+
174
181
  function buildTeammateDetail(name: string): string[] {
175
182
  const teammate = getTeammate(name);
176
183
  if (!teammate) return ["(teammate removed from the roster)"];
@@ -231,6 +238,26 @@ function buildTaskDetail(taskId: string): string[] {
231
238
  ];
232
239
  }
233
240
 
241
+ function buildRoleDetail(name: string): string[] {
242
+ const def = resolveAgent(name);
243
+ if (!def) return ["(agent definition not found)"];
244
+ return [
245
+ `@${def.name} (${def.scope})${def.gitManaged ? " [git-managed]" : " [local]"}`,
246
+ "",
247
+ "== role ==",
248
+ ` Source: ${displaySource(def.source)}`,
249
+ ` Description: ${def.description || "(none)"}`,
250
+ ` Tools: ${def.tools.join(", ") || "(role defaults)"}`,
251
+ ` Model: ${def.model ?? "(session default)"}`,
252
+ ` Verify: ${def.verify ?? "(none)"}`,
253
+ ` Worktree: ${def.worktree ? "true" : "false"}`,
254
+ ` Living instances: ${livingTeammates().filter((teammate) => teammate.agent === def.name).length}`,
255
+ "",
256
+ "== role prompt ==",
257
+ ...indent(cap(def.prompt)),
258
+ ];
259
+ }
260
+
234
261
  // ── Full-screen console ───────────────────────────────────────────
235
262
 
236
263
  type ConsolePage = "roster" | "board";
@@ -239,11 +266,30 @@ interface RosterRow {
239
266
  key: string;
240
267
  kind: "teammate";
241
268
  }
269
+ interface RoleRow {
270
+ key: string;
271
+ kind: "role";
272
+ }
242
273
  interface BoardRow {
243
274
  key: string;
244
275
  kind: "task";
245
276
  }
246
- type ConsoleRow = RosterRow | BoardRow;
277
+ type ConsoleRow = RosterRow | RoleRow | BoardRow;
278
+
279
+ /** SGR mouse-wheel events carry a button code; return signed line delta. */
280
+ function wheelDelta(data: string): number | undefined {
281
+ const match = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);
282
+ if (!match) return undefined;
283
+ const button = Number.parseInt(match[1], 10);
284
+ if ((button & 64) === 0) return undefined;
285
+ const direction = button & 3;
286
+ if (direction === 0) return -1;
287
+ if (direction === 1) return 1;
288
+ return undefined;
289
+ }
290
+
291
+ /** Chrome lines around the list viewport: border, header, spacer, footer, border. */
292
+ const LIST_CHROME_LINES = 5;
247
293
 
248
294
  /** Full-screen Team Console — owns input via ctx.ui.custom. Pages: roster /
249
295
  * board; each row opens a scrolling detail view. */
@@ -253,16 +299,37 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
253
299
  let mode: "list" | "detail" = "list";
254
300
  let selectedRoster = 0;
255
301
  let selectedBoard = 0;
256
- let detailTitle = "";
302
+ let listOffset = 0;
303
+ let detailKind: ConsoleRow["kind"] = "teammate";
304
+ let detailKey = "";
257
305
  let offset = 0;
306
+ let confirmName: string | undefined;
258
307
  let closed = false;
259
308
  let renderTimer: ReturnType<typeof setInterval> | undefined;
309
+
310
+ // Definitions are reread lazily so mid-session role creation shows up,
311
+ // but never once per animation frame.
312
+ let rolesCache: AgentDefinition[] = [];
313
+ let rolesCacheAt = 0;
314
+ const ROLES_CACHE_TTL_MS = 2000;
315
+ const getRoles = (): AgentDefinition[] => {
316
+ const now = Date.now();
317
+ if (now - rolesCacheAt > ROLES_CACHE_TTL_MS) {
318
+ rolesCache = [...discoverAgents().values()].sort((a, b) => a.name.localeCompare(b.name));
319
+ rolesCacheAt = now;
320
+ }
321
+ return rolesCache;
322
+ };
323
+
260
324
  const requestRender = () => {
261
325
  if (!closed) tui.requestRender();
262
326
  };
263
327
  const startLiveRefresh = () => {
264
328
  if (renderTimer) return;
265
- renderTimer = setInterval(requestRender, PI_SPINNER_INTERVAL_MS);
329
+ renderTimer = setInterval(() => {
330
+ // Redraw only while something can animate (spinner/activity/stall).
331
+ if (livingTeammates().some(isWorking)) requestRender();
332
+ }, PI_SPINNER_INTERVAL_MS);
266
333
  renderTimer.unref?.();
267
334
  };
268
335
  const stopLiveRefresh = () => {
@@ -276,8 +343,12 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
276
343
 
277
344
  const currentRows = (): ConsoleRow[] =>
278
345
  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 }))
346
+ // The console is the management surface: idle and stopped stay
347
+ // visible, and persistent agent roles are listed after the session.
348
+ ? [
349
+ ...listTeammates().map((t) => ({ key: t.name, kind: "teammate" as const })),
350
+ ...getRoles().map((def) => ({ key: def.name, kind: "role" as const })),
351
+ ]
281
352
  : listTasks().map((task) => ({ key: task.id, kind: "task" as const }));
282
353
 
283
354
  const windowLines = (full: string[], width: number): { lines: string[]; range: string } => {
@@ -293,48 +364,109 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
293
364
  const headerLine = (): string => {
294
365
  const alive = livingTeammates();
295
366
  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}`;
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}`;
297
368
  };
298
369
 
299
- const renderList = (width: number): string[] => {
370
+ interface ContentLine {
371
+ text: string;
372
+ /** Selectable row index, or -1 for section chrome. */
373
+ select: number;
374
+ }
375
+
376
+ const teammateRowText = (key: string, selected: boolean): string => {
377
+ const teammate = getTeammate(key)!;
378
+ const marker = selected ? style.accent("❯ ") : " ";
379
+ const name = theme.bold(theme.fg(colorFor(teammate.name), `@${teammate.name}`));
380
+ if (isWorking(teammate)) {
381
+ const prefix = `${marker}${name} `;
382
+ const width = tui.terminal.columns;
383
+ const available = Math.max(0, width - prefix.length - PI_SPINNER_FRAMES[spinnerFrame].length - 2);
384
+ const status = theme.fg("warning", formatTeammateLabel(PI_SPINNER_FRAMES[spinnerFrame], runningTeammateActivity(teammate) + stallSuffix(teammate), available));
385
+ return `${prefix}${status}`;
386
+ }
387
+ if (teammate.status === "idle") {
388
+ return `${marker}${name} ${style.dim(`○ idle${teammate.currentTaskId ? ` · ${teammate.currentTaskId}` : ""}`)}`;
389
+ }
390
+ return `${marker}${name} ${theme.fg("warning", "■ stopped")}`;
391
+ };
392
+
393
+ const roleRowText = (key: string, selected: boolean): string => {
394
+ const def = getRoles().find((candidate) => candidate.name === key)!;
395
+ const marker = selected ? style.accent("❯ ") : " ";
396
+ const name = theme.bold(theme.fg(colorFor(key), `@${key}`));
397
+ const live = livingTeammates().filter((teammate) => teammate.agent === key && teammate.status !== "stopped").length;
398
+ 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}`;
400
+ };
401
+
402
+ const taskRowText = (key: string, selected: boolean): string => {
403
+ const task = listTasks().find((candidate) => candidate.id === key)!;
404
+ const marker = selected ? style.accent("❯ ") : " ";
405
+ const label = theme.fg(colorFor(key), `[${key}]`);
406
+ const holder = task.claimedBy ? style.dim(` @${task.claimedBy}`) : "";
407
+ const statusText = task.status === "completed"
408
+ ? style.success("✓")
409
+ : task.status === "claimed"
410
+ ? theme.fg("warning", "◐ claimed")
411
+ : style.dim("○ pending");
412
+ return `${marker}${label} ${statusText} ${theme.fg("customMessageText", task.subject)}${holder}`;
413
+ };
414
+
415
+ const buildContent = (): ContentLine[] => {
300
416
  const rows = currentRows();
417
+ const lines: ContentLine[] = [];
418
+ if (page === "board") {
419
+ if (rows.length === 0) {
420
+ lines.push({ text: style.dim("The task board is empty. Create tasks with task_create."), select: -1 });
421
+ }
422
+ rows.forEach((row, index) => lines.push({ text: taskRowText(row.key, index === currentPageSelection(rows.length)), select: index }));
423
+ return lines;
424
+ }
425
+ const teammates = rows.filter((row): row is RosterRow => row.kind === "teammate");
426
+ const roles = rows.filter((row): row is RoleRow => row.kind === "role");
427
+ const selection = currentPageSelection(rows.length);
428
+ lines.push({ text: style.dim("== teammates (this session) =="), select: -1 });
429
+ if (teammates.length === 0) {
430
+ lines.push({ text: style.dim("No teammates this session. Ask the model to spawn one."), select: -1 });
431
+ }
432
+ teammates.forEach((row, index) => lines.push({ text: teammateRowText(row.key, index === selection), select: index }));
433
+ lines.push({ text: "", select: -1 });
434
+ lines.push({ text: style.dim("== agent roles (persistent definitions) =="), select: -1 });
435
+ if (roles.length === 0) {
436
+ lines.push({ text: style.dim("No agent definitions discovered."), select: -1 });
437
+ }
438
+ roles.forEach((row, index) => lines.push({ text: roleRowText(row.key, teammates.length + index === selection), select: teammates.length + index }));
439
+ return lines;
440
+ };
441
+
442
+ const listViewport = (): number => Math.max(1, tui.terminal.rows - LIST_CHROME_LINES);
443
+
444
+ const renderList = (width: number): string[] => {
301
445
  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."));
446
+ const content = buildContent();
447
+ const viewport = listViewport();
448
+ const selection = currentPageSelection(currentRows().length);
449
+ const selectedIndex = content.findIndex((line) => line.select === selection);
450
+ if (selectedIndex >= 0) {
451
+ if (selectedIndex < listOffset) listOffset = selectedIndex;
452
+ else if (selectedIndex >= listOffset + viewport) listOffset = selectedIndex - viewport + 1;
307
453
  }
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)));
454
+ listOffset = clampConsoleScroll(listOffset, content.length, viewport);
455
+ const footer = confirmName
456
+ ? theme.fg("warning", `Shut down @${confirmName}? Its claimed task returns to the board · y yes / n no`)
457
+ : style.dim(page === "roster"
458
+ ? "↑↓ select · enter open · tab board · x shutdown · esc/q close"
459
+ : "↑↓ select · enter open · tab roster · esc/q close");
460
+ return [
461
+ border,
462
+ style.accent(truncateToWidth(headerLine(), width)),
463
+ "",
464
+ ...content.slice(listOffset, listOffset + viewport)
465
+ .map((line) => truncateToWidth(line.text, Math.max(10, width - 1))),
466
+ "",
467
+ truncateToWidth(footer, Math.max(10, width - 1)),
468
+ border,
469
+ ];
338
470
  };
339
471
 
340
472
  const currentPageSelection = (rowCount: number): number => {
@@ -342,6 +474,9 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
342
474
  return rowCount === 0 ? 0 : Math.min(selection, rowCount - 1);
343
475
  };
344
476
 
477
+ const detailTitle = (): string =>
478
+ detailKind === "task" ? `[${detailKey}]` : detailKind === "role" ? `@${detailKey} · role` : `@${detailKey}`;
479
+
345
480
  const renderDetail = (width: number): string[] => {
346
481
  const border = style.border("─".repeat(Math.max(1, width)));
347
482
  const source = detailSource();
@@ -349,7 +484,7 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
349
484
  const footer = style.dim(` ${detail.range} · ↑↓ scroll · pgup/pgdn page · home/end jump · esc back · q close`);
350
485
  const lines = [
351
486
  border,
352
- style.accent(truncateToWidth(`agent-teams ${detailTitle}`, width)),
487
+ style.accent(truncateToWidth(`agent-teams ${detailTitle()}`, width)),
353
488
  "",
354
489
  ...detail.lines.map((line) => ` ${line}`),
355
490
  "",
@@ -359,10 +494,11 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
359
494
  return lines.map((line) => truncateToWidth(line, Math.max(10, width - 1)));
360
495
  };
361
496
 
362
- const detailSource = (): string[] =>
363
- detailTitle.startsWith("[")
364
- ? buildTaskDetail(detailTitle.slice(1, detailTitle.indexOf("]")))
365
- : buildTeammateDetail(detailTitle.replace(/^@/, ""));
497
+ const detailSource = (): string[] => {
498
+ if (detailKind === "task") return buildTaskDetail(detailKey);
499
+ if (detailKind === "role") return buildRoleDetail(detailKey);
500
+ return buildTeammateDetail(detailKey);
501
+ };
366
502
 
367
503
  return {
368
504
  render: (width) => (mode === "list" ? renderList(width) : renderDetail(width)),
@@ -371,9 +507,22 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
371
507
  handleDetailInput(data);
372
508
  return;
373
509
  }
510
+ if (confirmName) {
511
+ if (data === "y" || data === "Y") {
512
+ const name = confirmName;
513
+ confirmName = undefined;
514
+ void shutdownFromConsole(ctx, name);
515
+ } else if (data === "n" || data === "N" || matchesKey(data, Key.escape)) {
516
+ confirmName = undefined;
517
+ } else if (data === "q" || data === "Q") {
518
+ closeConsole(done);
519
+ }
520
+ return;
521
+ }
374
522
  const rows = currentRows();
375
523
  if (matchesKey(data, Key.tab)) {
376
524
  page = page === "roster" ? "board" : "roster";
525
+ listOffset = 0;
377
526
  return;
378
527
  }
379
528
  if (matchesKey(data, Key.down)) bumpSelection(1, rows.length);
@@ -384,20 +533,29 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
384
533
  if (row) {
385
534
  mode = "detail";
386
535
  offset = 0;
387
- detailTitle = row.kind === "teammate" ? `@${row.key}` : `[${row.key}]`;
536
+ detailKind = row.kind;
537
+ detailKey = row.key;
388
538
  }
389
539
  return;
390
540
  }
391
541
  if (data === "x" || data === "X") {
392
542
  const selection = currentPageSelection(rows.length);
393
543
  const row = rows[selection];
394
- if (page === "roster" && row?.kind === "teammate") void shutdownFromConsole(ctx, row.key);
544
+ if (page === "roster" && row?.kind === "teammate" && getTeammate(row.key)?.status !== "stopped") {
545
+ confirmName = row.key;
546
+ }
395
547
  return;
396
548
  }
397
- if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
398
- closeConsole(done);
549
+ const wheel = wheelDelta(data);
550
+ if (wheel !== undefined) {
551
+ scrollList(wheel * 3);
399
552
  return;
400
553
  }
554
+ if (matchesKey(data, Key.pageUp)) scrollList(-listViewport());
555
+ else if (matchesKey(data, Key.pageDown)) scrollList(listViewport());
556
+ else if (matchesKey(data, Key.home)) listOffset = 0;
557
+ else if (matchesKey(data, Key.end)) listOffset = clampConsoleScroll(Number.MAX_SAFE_INTEGER, buildContent().length, listViewport());
558
+ else if (matchesKey(data, Key.escape) || data === "q" || data === "Q") closeConsole(done);
401
559
  },
402
560
  invalidate: () => requestRender(),
403
561
  dispose: () => {
@@ -418,6 +576,10 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
418
576
  else selectedBoard = clampIndex(selectedBoard + delta, rowCount);
419
577
  }
420
578
 
579
+ function scrollList(delta: number): void {
580
+ listOffset = clampConsoleScroll(listOffset + delta, buildContent().length, listViewport());
581
+ }
582
+
421
583
  function handleDetailInput(data: string): void {
422
584
  const source = detailSource();
423
585
  const viewport = maxConsoleBody(tui.terminal.rows);
@@ -431,14 +593,9 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
431
593
  return;
432
594
  }
433
595
  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
- }
596
+ const wheel = wheelDelta(data);
597
+ if (wheel !== undefined) {
598
+ offset = scrollConsoleDetail(offset, wheel * 3, total, viewport);
442
599
  return;
443
600
  }
444
601
  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.