@fradser/pi-agent-teams 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Static } from "typebox";
2
2
  import { Type } from "typebox";
3
+ import { WORKER_BUILTIN_TOOLS } from "./worker-tools.ts";
3
4
 
4
5
  // ── Teammate ──────────────────────────────────────────────────────
5
6
 
@@ -31,6 +32,9 @@ export interface Teammate {
31
32
  agent: string;
32
33
  /** Per-spawn capability identity, regenerated for every process. */
33
34
  spawnId: string;
35
+ /** Stable Work Item selected by agent; each reopen creates a new assignment attempt. */
36
+ workId?: string;
37
+ context?: "fresh" | "fork";
34
38
  pid: number;
35
39
  status: TeammateStatus;
36
40
  /** Working directory (the worktree root when isolated). */
@@ -69,15 +73,13 @@ export interface Teammate {
69
73
  * the capability set). Absent only for legacy snapshots. */
70
74
  tools?: string[];
71
75
  /** True once recognized model/stream activity was observed for this
72
- * incarnation; the stall classifier uses this, not usage totals. */
76
+ * incarnation; console telemetry only, never a leader notification. */
73
77
  modelOutputSeen?: boolean;
74
78
  createdAt: number;
75
79
  updatedAt: number;
76
80
  stoppedAt?: number;
77
81
  /** Last wall-clock time output was observed (any stream event or prompt delivery). */
78
82
  lastOutputAt?: number;
79
- /** When the current stall episode notice was sent (one per episode). */
80
- stallNoticeSentAt?: number;
81
83
  }
82
84
 
83
85
  // ── Task board ────────────────────────────────────────────────────
@@ -111,6 +113,8 @@ export interface BoardTask {
111
113
  /** Replacement task that made this task obsolete. */
112
114
  supersededBy?: string;
113
115
  result?: string;
116
+ /** Deferred mail archived when an accepted result closes its Work Item. */
117
+ deferredMessages?: Array<{ from: string; subject: string; body: string; timestamp: number }>;
114
118
  errorMessage?: string;
115
119
  createdAt: number;
116
120
  updatedAt: number;
@@ -123,6 +127,7 @@ export interface TaskIntent {
123
127
  taskId: string;
124
128
  worker: string;
125
129
  spawnId: string;
130
+ assignmentId?: string;
126
131
  status?: "completed" | "failed";
127
132
  result?: string;
128
133
  timestamp: number;
@@ -144,10 +149,12 @@ export interface WorkerUsage {
144
149
 
145
150
  export interface MailboxMessage {
146
151
  id: string;
152
+ assignmentId?: string;
153
+ spawnId?: string;
147
154
  from: string;
148
155
  subject: string;
149
156
  body: string;
150
- status?: "in_progress" | "completed" | "failed";
157
+ status?: "in_progress" | "completed" | "failed" | "inform" | "request" | "handoff";
151
158
  /** The harness retained this report for console inspection after shutdown;
152
159
  * it never initiated a leader follow-up. */
153
160
  archived?: boolean;
@@ -158,11 +165,12 @@ export interface MailboxMessage {
158
165
  * is derived by the harness from the first line of the body. */
159
166
  export interface WorkerReportEvent {
160
167
  id: string;
168
+ assignmentId?: string;
161
169
  type: "message";
162
170
  worker: string;
163
171
  spawnId: string;
164
172
  body: string;
165
- status?: "in_progress" | "completed" | "failed";
173
+ status?: "in_progress" | "completed" | "failed" | "inform" | "request" | "handoff";
166
174
  /** Wall-clock time the teammate wrote the event. */
167
175
  timestamp?: number;
168
176
  }
@@ -178,7 +186,8 @@ export function isWorkerEvent(value: unknown): value is WorkerReportEvent {
178
186
  && typeof event.worker === "string"
179
187
  && typeof event.spawnId === "string"
180
188
  && typeof event.body === "string"
181
- && (event.status === undefined || ["in_progress", "completed", "failed"].includes(event.status))
189
+ && (event.assignmentId === undefined || typeof event.assignmentId === "string")
190
+ && (event.status === undefined || ["in_progress", "completed", "failed", "inform", "request", "handoff"].includes(event.status))
182
191
  && (event.timestamp === undefined || (typeof event.timestamp === "number" && Number.isFinite(event.timestamp)));
183
192
  }
184
193
 
@@ -197,84 +206,113 @@ export interface InboxMessage {
197
206
  from: string;
198
207
  subject: string;
199
208
  body: string;
209
+ /** Incarnation the sender addressed. A replacement resident of the same name
210
+ * must never consume mail written for its predecessor. */
211
+ toSpawnId?: string;
200
212
  timestamp: number;
201
213
  }
202
214
 
203
215
  // ── Tool parameter schemas (typebox) ──────────────────────────────
204
216
 
205
- /** Spawn one named resident teammate or sub-agent. */
206
- export const TeammateSpawnParams = Type.Object({
207
- name: Type.String({ minLength: 1, description: "Teammate name, unique among living teammates; used for messaging and claiming" }),
208
- agent: Type.String({ description: "Agent definition name or role id; an inline definition may create this role in memory for the current session" }),
209
- prompt: Type.Optional(Type.String({ description: "Optional kickoff prompt delivered as the agent / teammate's first turn; omit to let it wait for messages or board claims" })),
210
- resources: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Stable resource tags owned by this direct assignment. Overlapping board claims are rejected until it closes." })),
211
- handoffFrom: Type.Optional(Type.String({ minLength: 1, description: "Stopped teammate whose assignment, claim, and recent reports should be summarized in this successor kickoff." })),
212
- definition: Type.Optional(Type.Object({
213
- description: Type.String({ description: "Routing contract for the generated role" }),
214
- tools: Type.Optional(Type.Array(Type.String(), { description: "Pi tool ids for the generated role; only pi built-ins (read, bash, edit, write, grep, find, ls, powershell) are grantable — teammates run without extensions, so MCP/extension ids fail the spawn" })),
215
- model: Type.Optional(Type.String({ description: 'Optional provider/model pin, or "inherit" to run on the leader\'s current model' })),
216
- verify: Type.Optional(Type.String({ description: "Role-default completion gate: a review prompt a fresh reviewer answers with VERDICT: PASS or FAIL" })),
217
- worktree: Type.Optional(Type.Boolean({ description: "Whether this role receives a dedicated Git worktree" })),
218
- prompt: Type.String({ minLength: 1, description: "Role prompt for this generated teammate / sub-agent" }),
219
- persist: Type.Optional(Type.Boolean({ description: "Persist only when the user explicitly asks to keep this role for future sessions" })),
220
- persistScope: Type.Optional(Type.Union([
221
- Type.Literal("project"),
222
- Type.Literal("project-local"),
223
- ], { description: "Persistence scope; defaults to project-local when persist is true" })),
224
- }, { description: "Optional generated role definition; kept in memory unless explicitly persisted" })),
225
- });
217
+ export const InlineAgentDefinitionParams = Type.Object({
218
+ description: Type.String({ minLength: 1, description: "Routing contract for the generated Agent" }),
219
+ tools: Type.Optional(Type.Array(Type.String(), { description: `Explicit minimal tool grant. Canonical built-ins: ${WORKER_BUILTIN_TOOLS.join(", ")}. Omitted or [] means coordination-only (agent_event and work), with no file or shell access. No aliases or inherited leader extension tools.` })),
220
+ model: Type.Optional(Type.String({ description: "Provider/model pin or inherit" })),
221
+ verify: Type.Optional(Type.String({ description: "Default Work verification gate" })),
222
+ worktree: Type.Optional(Type.Boolean({ description: "Dedicated Git worktree" })),
223
+ prompt: Type.String({ minLength: 1, description: "Agent role prompt" }),
224
+ persist: Type.Optional(Type.Boolean({ description: "Persist only when explicitly requested" })),
225
+ persistScope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("project-local")])),
226
+ }, { additionalProperties: false });
226
227
 
227
- /** Shut down one living teammate. */
228
- export const TeammateShutdownParams = Type.Object({
229
- name: Type.String({ description: "Teammate name on the roster" }),
230
- });
228
+ /** Legacy overloaded Agent control; replaced by AgentActionParams at final cutover. */
229
+ export const AgentActionParams = Type.Union([
230
+ Type.Object({ action: Type.Literal("delegate"), name: Type.String({ minLength: 1 }), prompt: Type.String({ minLength: 1 }), definition: Type.Optional(InlineAgentDefinitionParams), resources: Type.Optional(Type.Array(Type.String({ minLength: 1 }))), verify: Type.Optional(Type.String()), model: Type.Optional(Type.String()), fork: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
231
+ Type.Object({ action: Type.Literal("start"), name: Type.String({ minLength: 1 }), definition: Type.Optional(InlineAgentDefinitionParams), model: Type.Optional(Type.String()) }, { additionalProperties: false }),
232
+ Type.Object({ action: Type.Literal("inspect"), name: Type.String({ minLength: 1 }), session: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
233
+ Type.Object({ action: Type.Literal("stop"), session: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
234
+ ], { description: "Delegate, start, inspect, or stop an Agent session" });
231
235
 
236
+ /** Shut down one living teammate. */
232
237
  /** Create a board task (leader-only). */
233
- export const TaskCreateParams = Type.Object({
234
- subject: Type.String({ minLength: 1, description: "Short task title shown on the board" }),
235
- description: Type.Optional(Type.String({ description: "Full task description for the claiming teammate" }),
236
- ),
237
- dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Task ids that must complete before this task is claimable" })),
238
- verify: Type.Optional(Type.String({ description: "Completion gate: a fresh reviewer judges the acceptance criteria. Explicit PASS/FAIL completes or returns the task; an inconclusive review escalates without counting as failure." })),
239
- resources: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Stable resource tags this task mutates, e.g. firmware/sub-node. Overlapping active tags cannot be claimed concurrently." })),
240
- supersedes: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Obsolete task ids this new task replaces. Superseded work cannot be claimed or completed." })),
241
- });
238
+ /** First public Work interface slice. Work creation reuses the current
239
+ * single-writer task record until every acquisition path shares one lifecycle. */
240
+ export const WorkToolParams = Type.Union([
241
+ Type.Object({
242
+ action: Type.Literal("create"),
243
+ subject: Type.String({ minLength: 1, description: "Work title" }),
244
+ description: Type.Optional(Type.String({ description: "Full Work description" })),
245
+ dependsOn: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Work IDs that must complete first" })),
246
+ verify: Type.Optional(Type.String({ description: "Completion gate for this Work Item" })),
247
+ resources: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Resource tags that cannot overlap active Work" })),
248
+ }, { additionalProperties: false }),
249
+ Type.Object({ action: Type.Literal("list") }, { additionalProperties: false }),
250
+ Type.Object({
251
+ action: Type.Literal("assign"),
252
+ id: Type.String({ minLength: 1, description: "Pending Work Item ID" }),
253
+ target: Type.Object({ session: Type.String({ minLength: 1, description: "Exact idle session route" }) }, { additionalProperties: false }),
254
+ }, { additionalProperties: false }),
255
+ Type.Object({
256
+ action: Type.Literal("release"),
257
+ id: Type.String({ minLength: 1, description: "Claimed Work Item ID" }),
258
+ reason: Type.String({ minLength: 1, description: "Reason Work is returned to pending" }),
259
+ }, { additionalProperties: false }),
260
+ Type.Object({
261
+ action: Type.Literal("reopen"),
262
+ id: Type.String({ minLength: 1, description: "Completed Work Item ID" }),
263
+ reason: Type.String({ minLength: 1, description: "Reason this completed Work is reopened" }),
264
+ }, { additionalProperties: false }),
265
+ Type.Object({
266
+ action: Type.Literal("supersede"),
267
+ subject: Type.String({ minLength: 1, description: "Replacement Work title" }),
268
+ description: Type.Optional(Type.String({ description: "Full replacement Work description" })),
269
+ dependsOn: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Replacement Work dependencies" })),
270
+ verify: Type.Optional(Type.String({ description: "Replacement completion gate" })),
271
+ resources: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Replacement resource tags" })),
272
+ supersedes: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, description: "Obsolete Work IDs replaced atomically" }),
273
+ }, { additionalProperties: false }),
274
+ ], { description: "Create, list, assign, release, reopen, or supersede current Work Items" });
242
275
 
243
276
  /** Shared leader/worker read-only board view. */
244
- export const TaskListParams = Type.Object({});
277
+ /** Shared communication event parameters across Leader, Worker, and Peers. */
278
+ export const AgentEventParams = Type.Object({
279
+ message: Type.String({ description: "Message content" }),
280
+ to: Type.Optional(Type.String({ minLength: 1, description: "Recipient Agent, leader, or exact session route" })),
281
+ intent: Type.Optional(Type.Union([Type.Literal("inform"), Type.Literal("request")])),
282
+ }, { additionalProperties: false });
245
283
 
246
284
  /** The reserved recipient name for reports to the team leader. */
247
285
  export const LEADER_RECIPIENT = "leader";
248
286
 
249
287
  /** The single messaging primitive: addressed peer mail and leader reports.
250
288
  * `status` is honored only for to="leader" terminal reports. */
251
- export const SendMessageParams = Type.Object({
252
- to: Type.String({ minLength: 1, description: 'Recipient: a teammate name on the roster, or "leader" to report to the team leader' }),
253
- message: Type.String({ description: "Message content; the first line becomes the title shown in the console" }),
254
- reopen: Type.Optional(Type.Boolean({ description: "Leader only: explicitly start a new assignment after the teammate reported completion" })),
255
- resources: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Leader only: resource tags for a reopened direct assignment. Ignored for worker reports." })),
256
- status: Type.Optional(Type.Union([
257
- Type.Literal("in_progress"),
258
- Type.Literal("completed"),
259
- Type.Literal("failed"),
260
- ], { description: 'Only for to="leader": completed or failed ends the current assignment and triggers immediate follow-up delivery' })),
261
- });
262
-
263
289
  /** Self-claim a pending board task. */
264
- export const TaskClaimParams = Type.Object({
265
- taskId: Type.Optional(Type.String({ description: "Specific task id to claim; omit to claim the first claimable task" })),
266
- });
290
+ /** Worker-only Work claim operation. It queues an intent; the single-writer
291
+ * harness remains the only authority that can grant ownership. */
292
+ export const WorkerWorkToolParams = Type.Union([
293
+ Type.Object({ action: Type.Literal("list") }, { additionalProperties: false }),
294
+ Type.Object({
295
+ action: Type.Literal("claim"),
296
+ id: Type.Optional(Type.String({ minLength: 1, description: "Specific claimable Work Item ID" })),
297
+ }, { additionalProperties: false }),
298
+ Type.Object({
299
+ action: Type.Literal("release"),
300
+ result: Type.Optional(Type.String({ description: "Reason owned Work is released" })),
301
+ }, { additionalProperties: false }),
302
+ Type.Object({
303
+ action: Type.Literal("submit"),
304
+ outcome: Type.Union([Type.Literal("success"), Type.Literal("failed")]),
305
+ result: Type.Optional(Type.String({ description: "Result evidence for the owned Work Item" })),
306
+ }, { additionalProperties: false }),
307
+ ], { description: "Worker claim or submit operation for Work" });
267
308
 
268
309
  /** Submit a claimed task outcome. Completion passes through the verify gate. */
269
- export const TaskSubmitParams = Type.Object({
270
- taskId: Type.String({ description: "The claimed task id" }),
271
- status: Type.Union([Type.Literal("completed"), Type.Literal("failed")], { description: "Outcome of the claimed task" }),
272
- result: Type.Optional(Type.String({ description: "Result summary recorded on the board when completed" })),
273
- });
274
-
275
310
  // ── State snapshot for persistence ────────────────────────────────
276
311
 
312
+ export const TEAM_RUNTIME_VERSION = 2;
313
+
277
314
  export interface TeamState {
315
+ runtimeVersion: number;
278
316
  teammates: Record<string, Teammate>;
279
317
  tasks: Record<string, BoardTask>;
280
318
  /** Unified teammate model for this session; spawns without a role pin use it. */
@@ -290,7 +328,8 @@ export interface TeamState {
290
328
  peerInboxOffsets: Record<string, number>;
291
329
  /** Delivered peer message ids per inbox, capped FIFO for deduplication. */
292
330
  peerDeliveredIds: Record<string, string[]>;
293
- /** Harness-owned peer-delivery transition by message id. This records queueing
294
- * or control-stream acceptance only; it never asserts recipient processing. */
295
- peerDeliveryStates: Record<string, "queued" | "routed">;
331
+ /** Harness-owned peer-delivery transition by message id. This records queueing,
332
+ * control-stream acceptance, or a drop aimed at a retired incarnation; it
333
+ * never asserts recipient processing. */
334
+ peerDeliveryStates: Record<string, "queued" | "routed" | "dropped">;
296
335
  }
package/src/ui.ts CHANGED
@@ -3,6 +3,8 @@
3
3
 
4
4
  import { truncateToWidth, Key, matchesKey, fuzzyFilter } from "@earendil-works/pi-tui";
5
5
  import {
6
+ agentColor,
7
+ createLiveActivityWidget,
6
8
  createPiThemeStyle,
7
9
  createSearchPicker,
8
10
  modelLabel,
@@ -11,15 +13,15 @@ import {
11
13
  PI_SPINNER_INTERVAL_MS,
12
14
  notifyPi,
13
15
  renderPiPanel,
14
- renderPiWidgetRow,
16
+ renderLiveActivityIdentity,
15
17
  sortModels,
16
18
  type SearchPicker,
17
19
  } from "@fradser/pi-kit";
18
- import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
20
+ import { type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
19
21
  import {
20
22
  clampConsoleScroll, consoleScrollRange, maxConsoleBody, scrollConsoleDetail, wrapConsoleDetail,
21
23
  } from "./console-viewport.ts";
22
- import { fitTeammateRow, formatTeammateLabel, runningTeammateActivity } from "./activity.ts";
24
+ import { formatTeammateLabel, runningTeammateActivity } from "./activity.ts";
23
25
  import { MODEL_INHERIT_ALIAS, discoverAgents, resolveAgent, type AgentDefinition } from "./agents.ts";
24
26
  import { getPeerDeliveryState, getState, getTeammate, getTeamDefaultModel, listTasks, listTeammates, livingTeammates, setTeamDefaultModel } from "./state.ts";
25
27
  import {
@@ -38,19 +40,15 @@ import { inboxPath } from "./statefile.ts";
38
40
  import * as fs from "node:fs";
39
41
  import * as path from "node:path";
40
42
 
41
- const TEAM_COLORS = ["success", "warning", "error", "mdLink"] as const;
42
43
  let spinnerTimer: ReturnType<typeof setInterval> | undefined;
43
44
  let spinnerFrame = 0;
44
45
 
45
- function hashName(name: string): number {
46
- let h = 0;
47
- for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(0) + i * 7) | 0;
48
- return Math.abs(h);
49
- }
50
-
51
- function colorFor(name: string): (typeof TEAM_COLORS)[number] {
52
- return TEAM_COLORS[hashName(name) % TEAM_COLORS.length];
53
- }
46
+ const teamActivityWidget = createLiveActivityWidget({
47
+ key: "teammate",
48
+ placement: "aboveEditor",
49
+ fit: truncateToWidth,
50
+ activityFormat: "markdown",
51
+ });
54
52
 
55
53
  function ensureSpinner(): void {
56
54
  const working = livingTeammates().some(isWorking);
@@ -73,41 +71,12 @@ export function stopUiTimers(): void {
73
71
  }
74
72
 
75
73
  export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string }): void {
76
- if (!ctx?.ui?.setWidget) return;
77
- if (ctx.mode && ctx.mode !== "tui") return;
78
-
79
- if (livingTeammates().filter(isWorking).length === 0) {
80
- ctx.ui.setWidget("teammate", undefined);
81
- return;
82
- }
83
-
84
- ctx.ui.setWidget("teammate", (tui, theme) => {
85
- const timer = setInterval(() => tui.requestRender(), PI_SPINNER_INTERVAL_MS);
86
- timer.unref?.();
87
- const style = createPiThemeStyle(theme);
88
- return {
89
- placement: "belowEditor",
90
- render: (width: number) => {
91
- // Only WORKING teammates appear above the input box; idle and
92
- // stopped teammates stay in the /agent-teams console instead.
93
- const working = livingTeammates().filter(isWorking);
94
- if (working.length === 0) return [];
95
- const lines: string[] = [];
96
- for (const teammate of working) {
97
- lines.push(renderPiWidgetRow(fitTeammateRow(
98
- PI_SPINNER_FRAMES[spinnerFrame],
99
- style.fg(colorFor(teammate.name), teammate.name),
100
- runningTeammateActivity(teammate) + stallSuffix(teammate),
101
- Math.max(1, width - 1),
102
- (activity) => theme.bold(style.fg("accent", activity)),
103
- ), width, truncateToWidth));
104
- }
105
- return lines;
106
- },
107
- invalidate: () => {},
108
- dispose: () => clearInterval(timer),
109
- };
110
- });
74
+ const working = livingTeammates().filter(isWorking);
75
+ teamActivityWidget.update(ctx, working.map((teammate) => ({
76
+ id: teammate.name,
77
+ identity: teammate.name,
78
+ activity: runningTeammateActivity(teammate) + stallSuffix(teammate),
79
+ })));
111
80
  }
112
81
 
113
82
  function isWorking(teammate: { status: string }): boolean {
@@ -289,7 +258,7 @@ function describeSpawnModel(pinned: string | undefined): string {
289
258
  const resolved = resolveSpawnModel(pinned, getTeamDefaultModel(), currentLeaderModelRef());
290
259
  if (pinned && pinned.trim().toLowerCase() !== MODEL_INHERIT_ALIAS) return pinned.trim();
291
260
  if (resolved.model) {
292
- const suffix = pinned ? "inherit -> " : "[team default] ";
261
+ const suffix = pinned ? "inherit -> " : resolved.source === "team-default" ? "[team default] " : "";
293
262
  return `${suffix}${resolved.model}`;
294
263
  }
295
264
  return pinned ? "inherit -> (unavailable at spawn)" : "(Pi default)";
@@ -333,7 +302,7 @@ const LIST_CHROME_LINES = 5;
333
302
  * teammate-model picker. */
334
303
  export function openTeamConsole(ctx: {
335
304
  ui: ExtensionUIContext;
336
- modelRegistry?: { getAvailable(): Array<{ provider: string; id: string; name?: string }> };
305
+ modelRegistry?: { getAll?(): Array<{ provider: string; id: string; name?: string }>; getAvailable(): Array<{ provider: string; id: string; name?: string }> };
337
306
  }): Promise<void> {
338
307
  return ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
339
308
  let page: ConsolePage = "roster";
@@ -396,7 +365,11 @@ export function openTeamConsole(ctx: {
396
365
  model?.id === CLEAR_TEAM_MODEL_ENTRY.id;
397
366
 
398
367
  const openModelPicker = (): void => {
399
- const models = sortModels([...(ctx.modelRegistry?.getAvailable() ?? [])]);
368
+ const registryModels =
369
+ typeof ctx.modelRegistry?.getAll === "function"
370
+ ? ctx.modelRegistry.getAll()
371
+ : (ctx.modelRegistry?.getAvailable() ?? []);
372
+ const models = sortModels([...registryModels]);
400
373
  if (models.length === 0) return;
401
374
  picker = createSearchPicker([CLEAR_TEAM_MODEL_ENTRY, ...models], {
402
375
  filter: fuzzyFilter,
@@ -520,7 +493,7 @@ export function openTeamConsole(ctx: {
520
493
  const teammateRowText = (key: string, selected: boolean): string => {
521
494
  const teammate = getTeammate(key)!;
522
495
  const marker = selected ? style.accent("❯ ") : " ";
523
- const name = theme.bold(theme.fg(colorFor(teammate.name), `@${teammate.name}`));
496
+ const name = renderLiveActivityIdentity(teammate.name, theme, "@");
524
497
  if (isWorking(teammate)) {
525
498
  const prefix = `${marker}${name} `;
526
499
  const width = tui.terminal.columns;
@@ -537,7 +510,7 @@ export function openTeamConsole(ctx: {
537
510
  const roleRowText = (key: string, selected: boolean): string => {
538
511
  const def = getRoles().find((candidate) => candidate.name === key)!;
539
512
  const marker = selected ? style.accent("❯ ") : " ";
540
- const name = theme.bold(theme.fg(colorFor(key), `@${key}`));
513
+ const name = renderLiveActivityIdentity(key, theme, "@");
541
514
  const live = livingTeammates().filter((teammate) => teammate.agent === key && teammate.status !== "stopped").length;
542
515
  const liveTag = live > 0 ? style.success(`[${live} live]`) : style.dim("[0 live]");
543
516
  const provenance = def.scope === "session" ? "in-memory" : displaySource(def.source);
@@ -547,7 +520,7 @@ export function openTeamConsole(ctx: {
547
520
  const taskRowText = (key: string, selected: boolean): string => {
548
521
  const task = listTasks().find((candidate) => candidate.id === key)!;
549
522
  const marker = selected ? style.accent("❯ ") : " ";
550
- const label = theme.fg(colorFor(key), `[${key}]`);
523
+ const label = theme.fg(agentColor(key), `[${key}]`);
551
524
  const holder = task.claimedBy ? style.dim(` @${task.claimedBy}`) : "";
552
525
  const statusText = task.status === "completed"
553
526
  ? style.success("✓")
@@ -564,7 +537,7 @@ export function openTeamConsole(ctx: {
564
537
  const lines: ContentLine[] = [];
565
538
  if (page === "board") {
566
539
  if (rows.length === 0) {
567
- lines.push({ text: style.dim("The task board is empty. Create tasks with task_create."), select: -1 });
540
+ lines.push({ text: style.dim("The Work list is empty. Create Work with work action=create."), select: -1 });
568
541
  }
569
542
  rows.forEach((row, index) => lines.push({ text: taskRowText(row.key, index === currentPageSelection(rows.length)), select: index }));
570
543
  return lines;
@@ -691,7 +664,10 @@ export function openTeamConsole(ctx: {
691
664
  return;
692
665
  }
693
666
  if (data === "m" || data === "M") {
694
- if (ctx.modelRegistry && ctx.modelRegistry.getAvailable().length > 0) {
667
+ const availableCount = ctx.modelRegistry
668
+ ? (typeof ctx.modelRegistry.getAll === "function" ? ctx.modelRegistry.getAll() : ctx.modelRegistry.getAvailable()).length
669
+ : 0;
670
+ if (ctx.modelRegistry && availableCount > 0) {
695
671
  openModelPicker();
696
672
  } else {
697
673
  notifyPi(ctx.ui, "No models are available in the model registry.", "warning");
@@ -0,0 +1,88 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { writeFileSync } from "node:fs";
3
+ import {
4
+ buildSessionContext,
5
+ SessionManager,
6
+ type ExtensionContext,
7
+ type SessionContext,
8
+ type SessionMessageEntry,
9
+ } from "@earendil-works/pi-coding-agent";
10
+
11
+ type AgentMessage = SessionContext["messages"][number];
12
+ type ContextReader = Pick<ExtensionContext["sessionManager"], "getBranch" | "getLeafId">;
13
+ type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
14
+ type ToolResultMessage = Extract<AgentMessage, { role: "toolResult" }>;
15
+ type ToolCall = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
16
+
17
+ function withoutRuntimeState(message: AgentMessage): AgentMessage {
18
+ if (message.role === "custom") {
19
+ const { details: _details, ...history } = message;
20
+ return history;
21
+ }
22
+ if (message.role === "toolResult") {
23
+ const { details: _details, addedToolNames: _tools, ...history } = message;
24
+ return history;
25
+ }
26
+ return message;
27
+ }
28
+
29
+ function completedExchanges(messages: AgentMessage[]): { calls: Set<ToolCall>; results: Set<ToolResultMessage> } {
30
+ const pending = new Map<string, ToolCall>();
31
+ const calls = new Set<ToolCall>();
32
+ const results = new Set<ToolResultMessage>();
33
+ for (const message of messages) {
34
+ if (message.role === "user" || message.role === "assistant") pending.clear();
35
+ if (message.role === "assistant") {
36
+ for (const block of message.content) {
37
+ if (block.type === "toolCall" && !pending.has(block.id)) pending.set(block.id, block);
38
+ }
39
+ } else if (message.role === "toolResult") {
40
+ const call = pending.get(message.toolCallId);
41
+ if (!call || call.name !== message.toolName) continue;
42
+ calls.add(call);
43
+ results.add(message);
44
+ pending.delete(call.id);
45
+ }
46
+ }
47
+ return { calls, results };
48
+ }
49
+
50
+ /** Snapshot the active Pi context without moving the parent's branch or replaying pending tools. */
51
+ export function snapshotWorkContext(sessionManager: ContextReader | undefined): AgentMessage[] {
52
+ if (!sessionManager || typeof sessionManager.getBranch !== "function" || typeof sessionManager.getLeafId !== "function") {
53
+ throw new Error("Fork context unavailable: leader session manager is unavailable.");
54
+ }
55
+ const messages = buildSessionContext(sessionManager.getBranch(), sessionManager.getLeafId()).messages;
56
+ const completed = completedExchanges(messages);
57
+ const history: AgentMessage[] = [];
58
+ for (const message of messages) {
59
+ if (message.role === "toolResult" && !completed.results.has(message)) continue;
60
+ if (message.role !== "assistant") {
61
+ history.push(withoutRuntimeState(message));
62
+ continue;
63
+ }
64
+ const content = message.content.filter((block) => block.type !== "toolCall" || completed.calls.has(block));
65
+ if (content.length === 0 && message.content.length > 0) continue;
66
+ const hasTools = content.some((block) => block.type === "toolCall");
67
+ const stopReason = message.stopReason === "toolUse" && !hasTools ? "stop" : message.stopReason;
68
+ history.push({ ...message, content, stopReason });
69
+ }
70
+ return structuredClone(history);
71
+ }
72
+
73
+ /** Materialize already-rebuilt context; appendMessage cannot represent Pi summary roles. */
74
+ export function writeWorkContext(cwd: string, directory: string, messages: AgentMessage[]): string {
75
+ const session = SessionManager.create(cwd, directory);
76
+ const file = session.getSessionFile()!;
77
+ let parentId: string | null = null;
78
+ const entries = messages.map((message): SessionMessageEntry => {
79
+ const entry: SessionMessageEntry = {
80
+ type: "message", id: randomUUID(), parentId, timestamp: new Date().toISOString(), message,
81
+ };
82
+ parentId = entry.id;
83
+ return entry;
84
+ });
85
+ const content = [session.getHeader(), ...entries].map((entry) => JSON.stringify(entry)).join("\n");
86
+ writeFileSync(file, `${content}\n`, { mode: 0o600, flag: "wx" });
87
+ return file;
88
+ }
@@ -0,0 +1,4 @@
1
+ /** Pi built-ins available to a bare worker; extension aliases are not inherited. */
2
+ export const WORKER_BUILTIN_TOOLS: readonly string[] = [
3
+ "read", "bash", "edit", "write", "grep", "find", "ls", "powershell",
4
+ ];