pi-extended-teams 2.2.7 → 2.2.8

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
@@ -40,11 +40,11 @@ The current Pi session becomes the agent group automatically. There is no separa
40
40
 
41
41
  ## How it works
42
42
 
43
- - Read agents run in-process. Edit agents run in separate Pi sessions through the configured terminal adapter. Both stay connected to the lead session.
43
+ - Public read and edit agents run in separate in-process Pi sessions. A write tier grants edit tools; it does not open a terminal pane. The terminal runtime remains available for existing integrations.
44
44
  - The activity card shows progress, intent tier, elapsed time, tokens, and tool activity.
45
45
  - You can open an agent's transcript, send it a message, interrupt a stuck tool command, or stop it.
46
46
  - Completed reports return to the lead automatically and remain recoverable when needed.
47
- - `get_agent_status` gives the lead or an eligible nested parent one read-only snapshot of owned active, queued, stalled, or recently completed read and edit agents.
47
+ - `get_agent_status` gives the lead or an eligible nested parent one read-only snapshot of owned active, queued, stalled, or recently completed read and edit agents. It uses current-run evidence, preserves lifecycle quarantine, and does not perform cleanup.
48
48
  - Every spawn names an intent tier instead of choosing ad hoc model settings. Configured favorites take priority; unset tiers inherit the current lead model and thinking.
49
49
  - Edit agents can claim isolated files. Claims coordinate cooperative agents; they are not access control.
50
50
  - Lazy session context and nested read helpers are available when a bounded task needs them.
@@ -104,6 +104,8 @@ For an edit, choose a write tier and name the files it may claim. Never run over
104
104
 
105
105
  Global settings live at `~/.pi/agent/pi-extended-teams/settings.json`. Project overrides live at `.pi/pi-extended-teams.json`. Favorite intent tiers are global so `/agents-favorite-models` and spawning use the same choices. Configuring favorites is optional; an unset tier falls back to the current lead-session model and thinking level.
106
106
 
107
+ Public read and edit spawns respect their role's concurrency limit and overflow setting. Enabled overflow queues accepted work; disabled overflow returns a capacity error. Quarantined requests stay fenced without blocking unrelated eligible work. `stop_teammate` can cancel a queued request before launch. Failed admissions trigger an attempted recipient notification and remain visible in status (up to 20 recent failures). The public queue and recent failure index are session-local, not restart-durable.
108
+
107
109
  Spawned sessions are private by default under `~/.pi/teams/<team>/agent-sessions/` and stay out of Pi's normal `/resume` picker.
108
110
 
109
111
  ## Security and data access
@@ -590,7 +590,9 @@ export async function runReadAgentInProcess(
590
590
  resolveSessionCreation = resolve;
591
591
  });
592
592
  let lifecycleRunId = member.lifecycleRunId ?? generateLifecycleRunId();
593
- if (teams.teamExists(readTeamName)) {
593
+ // Admitted runs can publish startup ownership without awaiting the compatibility lookup.
594
+ // writeRuntimeStatus validates their identity under lifecycle/config locks before work starts.
595
+ if (!member.lifecycleRunId && teams.teamExists(readTeamName)) {
594
596
  lifecycleRunId = await teams.ensureMemberLifecycleRunId(readTeamName, member.name, lifecycleRunId);
595
597
  }
596
598
  member.lifecycleRunId = lifecycleRunId;
@@ -1274,6 +1274,7 @@ export default function (pi: ExtensionAPI) {
1274
1274
  runningReadAgents,
1275
1275
  readAgentKey,
1276
1276
  interruptTeammate,
1277
+ cancelQueuedAgent: (targetTeamName, targetAgentName) => teamToolsRuntime?.cancelQueuedAgent(targetTeamName, targetAgentName) ?? false,
1277
1278
  shutdownTeammate,
1278
1279
  getTeamName: () => teamName,
1279
1280
  });
@@ -2,27 +2,22 @@ import { Type } from "@sinclair/typebox";
2
2
  import * as teams from "../../src/utils/teams";
3
3
  import * as runtime from "../../src/utils/runtime";
4
4
  import * as reportEvents from "../../src/utils/report-events";
5
- import { readLifecycleTombstone, type LifecycleTombstoneReadResult } from "../../src/utils/lifecycle-tombstone";
5
+ import { readLifecycleTombstone } from "../../src/utils/lifecycle-tombstone";
6
+ import { projectAgentStatus, type ActiveAgentPhase } from "../../src/orchestration/status-projection";
6
7
  import type { Member, TeamReportEvent } from "../../src/utils/models";
7
8
  import type { RunningReadAgent } from "../runtime/types";
8
9
  import { isWriteMemberAlive } from "../team/roster";
9
- import { describeReadAgentStatus } from "../ui/read-agent-status";
10
10
  import { formatElapsed } from "../ui/renderers";
11
11
 
12
- export type AgentStatusPhase =
13
- | RunningReadAgent["status"]
14
- | TeamReportEvent["status"]
15
- | "queued"
16
- | "stalled"
17
- | "stopping"
18
- | "quarantined"
19
- | "persistence-failed";
12
+ export type AgentStatusPhase = ActiveAgentPhase | TeamReportEvent["status"] | "queued";
20
13
 
21
14
  export interface QueuedAgentStatus {
22
15
  name: string;
23
16
  role: string;
24
17
  queuedAt: number;
25
18
  queuePosition: number;
19
+ error?: string;
20
+ failed?: boolean;
26
21
  parentAgentName?: string;
27
22
  parentLifecycleRunId?: string;
28
23
  }
@@ -85,55 +80,6 @@ function ownsReport(report: TeamReportEvent, scope?: AgentStatusScope): boolean
85
80
  && (typeof parentRunId !== "string" || parentRunId === scope.parentRunId);
86
81
  }
87
82
 
88
- function lifecycleMatches(member: Member, runId?: string): boolean {
89
- return member.lifecycleRunId === runId;
90
- }
91
-
92
- function persistedLifecycleStatus(
93
- member: Member,
94
- result: LifecycleTombstoneReadResult,
95
- ): Pick<AgentStatusSnapshot, "phase" | "error"> | undefined {
96
- if (result.status === "absent") return undefined;
97
- if (result.status === "corrupt") return { phase: "quarantined", error: result.error };
98
-
99
- const tombstone = result.tombstone;
100
- const runMismatch = member.lifecycleRunId && tombstone.runId !== member.lifecycleRunId
101
- ? `Lifecycle fence belongs to run ${tombstone.runId}, not roster run ${member.lifecycleRunId}.`
102
- : undefined;
103
- const phase = tombstone.phase === "cleanup_failed" || tombstone.phase === "timed_out"
104
- ? "quarantined"
105
- : "stopping";
106
- return { phase, error: runMismatch || tombstone.error };
107
- }
108
-
109
- function phaseForActiveAgent(
110
- member: Member,
111
- state: RunningReadAgent | undefined,
112
- runtimeStatus: runtime.AgentRuntimeStatus | null,
113
- persistedStatus: Pick<AgentStatusSnapshot, "phase" | "error"> | undefined,
114
- now: number,
115
- terminal: any,
116
- ): AgentStatusPhase {
117
- if (state?.teardownState === "persistence_failed") return "persistence-failed";
118
- if (state?.teardownState === "quarantined") return "quarantined";
119
- if (state?.teardownState === "stopping") return "stopping";
120
- if (persistedStatus) return persistedStatus.phase;
121
- if (state && state.teardownState !== "finalized") {
122
- return describeReadAgentStatus(state, now).label === "hanging" ? "stalled" : state.status;
123
- }
124
-
125
- const heartbeatFresh = runtime.isHeartbeatFresh(runtimeStatus, now);
126
- const paneAlive = member.isActive !== false && isWriteMemberAlive(member, terminal);
127
- if (!heartbeatFresh && !paneAlive) {
128
- if (member.isActive !== false && !runtimeStatus?.ready && now - member.joinedAt <= runtime.STARTUP_STALL_MS) return "starting";
129
- return "stalled";
130
- }
131
-
132
- const action = runtimeStatus?.currentAction;
133
- if (action) return action === "done" ? "finishing" : action;
134
- return runtimeStatus?.ready ? "working" : "starting";
135
- }
136
-
137
83
  async function activeStatus(
138
84
  teamName: string,
139
85
  member: Member,
@@ -148,25 +94,24 @@ async function activeStatus(
148
94
  error: error instanceof Error ? error.message : String(error),
149
95
  })),
150
96
  ]);
151
- const state = candidateState && lifecycleMatches(member, candidateState.runId)
152
- ? candidateState
153
- : undefined;
154
- const runtimeStatus = candidateRuntimeStatus && lifecycleMatches(member, candidateRuntimeStatus.lifecycleRunId)
155
- ? candidateRuntimeStatus
156
- : null;
157
- const persistedStatus = persistedLifecycleStatus(member, lifecycleResult);
97
+ const projected = projectAgentStatus({
98
+ member, activity: candidateState, runtime: candidateRuntimeStatus, fence: lifecycleResult,
99
+ terminalAlive: member.tmuxPaneId && options.terminal?.isAlive ? isWriteMemberAlive(member, options.terminal) : null,
100
+ now,
101
+ });
102
+ const { state, runtime: runtimeStatus } = projected;
158
103
  const progress = state?.latestProgress || runtimeStatus?.latestProgress;
159
104
  const progressUpdatedAt = state?.progressUpdatedAt || runtimeStatus?.progressUpdatedAt;
160
105
  return {
161
106
  name: member.name,
162
107
  role: member.role || state?.role || "read",
163
- phase: phaseForActiveAgent(member, state, runtimeStatus, persistedStatus, now, options.terminal),
108
+ phase: projected.phase,
164
109
  progress,
165
110
  progressAgeMs: age(now, progressUpdatedAt),
166
111
  activeTool: state?.activeToolName || runtimeStatus?.activeToolName,
167
112
  activityAgeMs: age(now, state?.lastActivityAt),
168
113
  heartbeatAgeMs: age(now, runtimeStatus?.lastHeartbeatAt),
169
- error: state?.lastError?.message || persistedStatus?.error || runtimeStatus?.lastError?.message,
114
+ error: projected.error,
170
115
  };
171
116
  }
172
117
 
@@ -174,7 +119,8 @@ function queuedStatus(item: QueuedAgentStatus, now: number): AgentStatusSnapshot
174
119
  return {
175
120
  name: item.name,
176
121
  role: item.role,
177
- phase: "queued",
122
+ phase: item.failed ? "failed" : "queued",
123
+ error: item.error,
178
124
  queuePosition: item.queuePosition,
179
125
  queuedAgeMs: age(now, item.queuedAt),
180
126
  };
@@ -18,6 +18,7 @@ export interface TaskRuntimeToolsOptions {
18
18
  runningReadAgents: Map<string, RunningReadAgent>;
19
19
  readAgentKey(teamName: string, agentName: string): string;
20
20
  interruptTeammate?(agentName: string): Promise<TeammateInterruptResult>;
21
+ cancelQueuedAgent?(teamName: string, agentName: string): boolean;
21
22
  shutdownTeammate(teamName: string, member: Member, options?: ShutdownTeammateOptions): Promise<ReadAgentTeardownResult>;
22
23
  getTeamName(): string | null | undefined;
23
24
  }
@@ -55,6 +56,12 @@ export function registerTaskRuntimeTools(pi: any, options: TaskRuntimeToolsOptio
55
56
  const teamName = options.getTeamName();
56
57
  if (!teamName) throw new Error("No active agent session. Spawn an agent first.");
57
58
 
59
+ if (options.cancelQueuedAgent?.(teamName, params.agent_name)) {
60
+ return {
61
+ content: [{ type: "text", text: `Cancelled queued agent ${params.agent_name}.` }],
62
+ details: { session: teamName, agentName: params.agent_name, stopped: true, queued: true, reason: params.reason },
63
+ };
64
+ }
58
65
  const config = await teams.readConfig(teamName);
59
66
  let member = config.members.find(m => m.name === params.agent_name);
60
67
  const runningState = options.runningReadAgents.get(options.readAgentKey(teamName, params.agent_name));
@@ -73,6 +73,7 @@ export interface NestedReadAgentToolBinding {
73
73
 
74
74
  export interface TeamToolsRuntime {
75
75
  createNestedReadAgentTools(binding: NestedReadAgentToolBinding): any[];
76
+ cancelQueuedAgent(teamName: string, agentName: string): boolean;
76
77
  }
77
78
 
78
79
  interface SpawnTeammateOptions {
@@ -97,11 +98,14 @@ interface QueuedReadSpawn {
97
98
  requestedAt: number;
98
99
  nameReservationId?: string;
99
100
  pendingChildAcceptance?: PendingChildAcceptance;
101
+ admissionError?: string;
102
+ quarantineError?: string;
103
+ launchCommitted?: boolean;
100
104
  }
101
105
 
102
106
  export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamToolsRuntime {
103
107
  if (options.isTeammate) {
104
- return { createNestedReadAgentTools: () => [] };
108
+ return { createNestedReadAgentTools: () => [], cancelQueuedAgent: () => false };
105
109
  }
106
110
 
107
111
  function emitOrchestrationResponse(requestId: string | undefined, type: string, payload: Record<string, any>): void {
@@ -234,39 +238,38 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
234
238
 
235
239
  const pendingChildController = options.pendingChildController ?? createPendingChildController();
236
240
  const queuedReadSpawnsByTeam = new Map<string, QueuedReadSpawn[]>();
241
+ const failedAdmissionsByTeam = new Map<string, QueuedReadSpawn[]>();
237
242
  const readQueueDrainingTeams = new Set<string>();
238
- const readAdmissionReservationsByTeam = new Map<string, Map<string, number>>();
243
+ const pendingQueueDrains = new Set<string>();
244
+ const readAdmissionReservationsByTeam = new Map<string, Map<string, { count: number; role: string }>>();
239
245
  const nestedReadNameReservationsByTeam = new Map<string, Map<string, string>>();
240
246
 
241
- function activeAgentCount(teamName: string, role?: string): number {
242
- let count = 0;
243
- for (const agent of options.runningReadAgents.values()) {
244
- if (agent.teamName !== teamName) continue;
245
- if (role && (agent.role || "read") !== role) continue;
246
- count += 1;
247
+ function activeAgentCount(teamName: string, role?: string, includeReservations = false): number {
248
+ const activeKeys = new Set<string>();
249
+ if (includeReservations) {
250
+ for (const [key, reservation] of readAdmissionReservationsByTeam.get(teamName) ?? []) {
251
+ if (!role || reservation.role === role) activeKeys.add(key);
252
+ }
247
253
  }
248
- return count;
249
- }
250
-
251
- function activeReadCount(teamName: string): number {
252
- const activeKeys = new Set(readAdmissionReservationsByTeam.get(teamName)?.keys() ?? []);
253
254
  for (const [key, agent] of options.runningReadAgents) {
254
- if (agent.teamName === teamName && (agent.role || "read") === "read") activeKeys.add(key);
255
+ if (agent.teamName === teamName && (!role || (agent.role || "read") === role)) activeKeys.add(key);
255
256
  }
256
257
  return activeKeys.size;
257
258
  }
258
259
 
259
- function reserveReadAdmission(teamName: string, key: string): void {
260
- const reservations = readAdmissionReservationsByTeam.get(teamName) ?? new Map<string, number>();
261
- reservations.set(key, (reservations.get(key) ?? 0) + 1);
260
+ function reserveReadAdmission(teamName: string, key: string, role: string): void {
261
+ const reservations = readAdmissionReservationsByTeam.get(teamName) ?? new Map<string, { count: number; role: string }>();
262
+ const pending = reservations.get(key);
263
+ if (pending && pending.role !== role) throw new Error(`Agent ${key} already has a ${pending.role} admission in progress.`);
264
+ reservations.set(key, { count: (pending?.count ?? 0) + 1, role });
262
265
  readAdmissionReservationsByTeam.set(teamName, reservations);
263
266
  }
264
267
 
265
268
  function releaseReadAdmission(teamName: string, key: string): void {
266
269
  const reservations = readAdmissionReservationsByTeam.get(teamName);
267
270
  if (!reservations) return;
268
- const count = reservations.get(key) ?? 0;
269
- if (count > 1) reservations.set(key, count - 1);
271
+ const reservation = reservations.get(key);
272
+ if (reservation && reservation.count > 1) reservation.count -= 1;
270
273
  else reservations.delete(key);
271
274
  if (reservations.size === 0) readAdmissionReservationsByTeam.delete(teamName);
272
275
  }
@@ -295,13 +298,15 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
295
298
  }
296
299
 
297
300
  async function listQueuedAgentStatuses(teamName: string): Promise<QueuedAgentStatus[]> {
298
- const readers = readQueue(teamName).map((queued, index) => ({
301
+ const readers = [...readQueue(teamName), ...(failedAdmissionsByTeam.get(teamName) ?? [])].map((queued, index) => ({
299
302
  name: queued.member.name,
300
303
  role: queued.member.role || "read",
301
304
  queuedAt: queued.requestedAt,
302
305
  queuePosition: index + 1,
303
306
  parentAgentName: queued.member.parentAgentName,
304
307
  parentLifecycleRunId: queued.member.parentLifecycleRunId,
308
+ error: queued.admissionError || queued.quarantineError,
309
+ failed: !!queued.admissionError,
305
310
  }));
306
311
  const writers = (await writeQueue.listWriteQueue(teamName)).map((queued, index) => ({
307
312
  name: queued.name,
@@ -342,6 +347,9 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
342
347
  pi.on?.("session_shutdown", () => {
343
348
  if (lifecycleProbeCleanedUp) return;
344
349
  lifecycleProbeCleanedUp = true;
350
+ for (const [teamName, queue] of queuedReadSpawnsByTeam) {
351
+ for (const queued of queue) removeQueuedReadSpawnById(teamName, queued.id);
352
+ }
345
353
  if (typeof lifecycleProbeUnsubscribe === "function") lifecycleProbeUnsubscribe();
346
354
  pendingChildCancelUnsubscribe();
347
355
  lifecycleFenceUnsubscribe();
@@ -495,7 +503,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
495
503
  else pendingChildController.settleAcceptance(identity);
496
504
  }
497
505
 
498
- async function rollbackNestedChildAdmission(
506
+ async function rollbackAgentAdmission(
499
507
  teamName: string,
500
508
  member: Member,
501
509
  cause: unknown,
@@ -508,11 +516,11 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
508
516
  }
509
517
  try {
510
518
  const removed = await teams.removeMemberMatchingRun(teamName, member.name, runId);
511
- if (!removed) throw new Error(`matching child run ${runId} was not present`);
519
+ if (!removed) throw new Error(`matching agent run ${runId} was not present`);
512
520
  } catch (rollbackError) {
513
521
  const causeMessage = cause instanceof Error ? cause.message : String(cause);
514
522
  const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
515
- throw new Error(`${causeMessage} Exact-run rollback for nested read agent ${member.name} failed: ${rollbackMessage}`);
523
+ throw new Error(`${causeMessage} Exact-run rollback for agent ${member.name} failed: ${rollbackMessage}`);
516
524
  } finally {
517
525
  settlePendingChild(pendingChild);
518
526
  }
@@ -530,7 +538,9 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
530
538
  member: Member,
531
539
  prompt: string,
532
540
  ctx: any,
533
- queuedAcceptance?: PendingChildAcceptance
541
+ queuedAcceptance?: PendingChildAcceptance,
542
+ assertPending?: () => void,
543
+ commitLaunch?: () => void,
534
544
  ): Promise<AdmittedReadAgentLaunch> {
535
545
  const addValidateAndLaunch = async (parentLifecycleLock?: LifecycleTombstoneLock): Promise<AdmittedReadAgentLaunch> => {
536
546
  let pendingAcceptance = queuedAcceptance;
@@ -542,17 +552,25 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
542
552
  }
543
553
 
544
554
  try {
555
+ if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
556
+ assertPending?.();
545
557
  await teams.addMember(teamName, member);
546
558
  } catch (error) {
547
559
  settlePendingChild(pendingAcceptance);
548
560
  throw error;
549
561
  }
550
562
 
563
+ try {
564
+ if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
565
+ assertPending?.();
566
+ } catch (error) {
567
+ return rollbackAgentAdmission(teamName, member, error, pendingAcceptance);
568
+ }
551
569
  let pendingChildRun: PendingChildRun | undefined;
552
570
  if (member.delegationDepth === 1) {
553
571
  const runId = member.lifecycleRunId;
554
572
  if (!pendingAcceptance || !runId) {
555
- return rollbackNestedChildAdmission(
573
+ return rollbackAgentAdmission(
556
574
  teamName,
557
575
  member,
558
576
  new Error(`Nested read agent ${member.name} did not receive an exact lifecycle run identity.`),
@@ -561,7 +579,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
561
579
  }
562
580
  pendingChildRun = pendingChildController.bindAcceptedChild(pendingAcceptance, member.name, runId);
563
581
  if (!pendingChildRun) {
564
- return rollbackNestedChildAdmission(
582
+ return rollbackAgentAdmission(
565
583
  teamName,
566
584
  member,
567
585
  new Error(`Nested read agent ${member.name} lost its parent acceptance before launch.`),
@@ -572,7 +590,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
572
590
  try {
573
591
  await assertNestedChildAdmission(teamName, member, parentLifecycleLock);
574
592
  } catch (error) {
575
- return rollbackNestedChildAdmission(teamName, member, error, pendingChildRun);
593
+ return rollbackAgentAdmission(teamName, member, error, pendingChildRun);
576
594
  }
577
595
  }
578
596
 
@@ -602,6 +620,9 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
602
620
  };
603
621
 
604
622
  try {
623
+ if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
624
+ assertPending?.();
625
+ commitLaunch?.();
605
626
  const launch = options.runReadAgentInProcess(teamName, member, launchPrompt, ctx, options.readAgentOptions());
606
627
  return {
607
628
  launch: sessionContextReference
@@ -612,10 +633,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
612
633
  };
613
634
  } catch (error) {
614
635
  removeReference();
615
- if (member.delegationDepth === 1) {
616
- return rollbackNestedChildAdmission(teamName, member, error, pendingChildRun);
617
- }
618
- throw error;
636
+ return rollbackAgentAdmission(teamName, member, error, pendingChildRun);
619
637
  }
620
638
  };
621
639
 
@@ -634,23 +652,24 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
634
652
  ctx: any,
635
653
  nameReservationId?: string,
636
654
  releaseNameOnFailure = true,
637
- queuedAcceptance?: PendingChildAcceptance
655
+ queuedAcceptance?: PendingChildAcceptance,
656
+ assertPending?: () => void,
657
+ commitLaunch?: () => void,
638
658
  ): Promise<boolean> {
639
659
  const key = options.readAgentKey(teamName, member.name);
640
- const reservesReadCapacity = (member.role || "read") === "read";
641
- if (reservesReadCapacity) reserveReadAdmission(teamName, key);
660
+ reserveReadAdmission(teamName, key, member.role || "read");
642
661
 
643
662
  const releaseNameReservation = () => {
644
663
  releaseNestedReadName(teamName, member.name, nameReservationId);
645
664
  };
646
665
  const releaseReservationAndDrain = () => {
647
666
  if (releaseNameOnFailure) releaseNameReservation();
648
- if (reservesReadCapacity) releaseReadAdmission(teamName, key);
667
+ releaseReadAdmission(teamName, key);
649
668
  void drainQueuedReadSpawns(teamName);
650
669
  };
651
670
  const finishRun = (pendingChildRun: PendingChildRun | undefined) => {
652
671
  settlePendingChild(pendingChildRun);
653
- if (reservesReadCapacity) releaseReadAdmission(teamName, key);
672
+ releaseReadAdmission(teamName, key);
654
673
  void drainQueuedReadSpawns(teamName);
655
674
  };
656
675
  const drainAfterRun = (pendingChildRun: PendingChildRun | undefined) => {
@@ -669,7 +688,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
669
688
  };
670
689
 
671
690
  try {
672
- const admitted = await admitAndLaunchReadAgentMember(teamName, member, prompt, ctx, queuedAcceptance);
691
+ const admitted = await admitAndLaunchReadAgentMember(teamName, member, prompt, ctx, queuedAcceptance, assertPending, commitLaunch);
673
692
  releaseNameReservation();
674
693
  void Promise.resolve(admitted.launch).then(
675
694
  () => drainAfterRun(admitted.pendingChildRun),
@@ -710,6 +729,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
710
729
  pendingChildAcceptance,
711
730
  };
712
731
  setReadQueue(teamName, [...queue, queued]);
732
+ void drainQueuedReadSpawns(teamName);
713
733
  return queued;
714
734
  };
715
735
 
@@ -733,55 +753,62 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
733
753
  }
734
754
 
735
755
  async function drainQueuedReadSpawns(teamName: string): Promise<void> {
736
- if (readQueueDrainingTeams.has(teamName)) return;
756
+ if (lifecycleProbeCleanedUp) return;
757
+ if (readQueueDrainingTeams.has(teamName)) {
758
+ pendingQueueDrains.add(teamName);
759
+ return;
760
+ }
737
761
  readQueueDrainingTeams.add(teamName);
738
762
  try {
739
- while (true) {
740
- const next = readQueue(teamName)[0];
741
- if (!next) return;
742
- const settings = loadSettings({ projectDir: next.member.cwd });
743
- if (activeReadCount(teamName) >= settings.readAgents.maxConcurrent) return;
744
-
745
- const queued = readQueue(teamName)[0];
746
- if (!queued) return;
747
- const fence = await readLifecycleTombstone(teamName, queued.member.name);
748
- if (fence.status !== "absent") return;
749
- try {
750
- const config = await teams.readConfig(teamName);
751
- if (config.members.some((member) => member.name === queued.member.name)) {
763
+ let progressed = true;
764
+ while (progressed && !lifecycleProbeCleanedUp) {
765
+ progressed = false;
766
+ for (const queued of readQueue(teamName)) {
767
+ if (queued.admissionError) continue;
768
+ const role = queued.member.role || "read";
769
+ const settings = loadSettings({ projectDir: queued.member.cwd });
770
+ const capacity = role === "write" ? settings.writeAgents : settings.readAgents;
771
+ if (activeAgentCount(teamName, role, true) >= capacity.maxConcurrent) continue;
772
+ const assertPending = () => {
773
+ if (!readQueue(teamName).some(item => item.id === queued.id)) throw new Error(`Queued agent ${queued.member.name} was cancelled.`);
774
+ };
775
+ try {
776
+ const fence = await readLifecycleTombstone(teamName, queued.member.name);
777
+ if (fence.status !== "absent") {
778
+ queued.quarantineError = fence.status === "corrupt" ? fence.error : `Lifecycle run ${fence.tombstone.runId} is quarantined.`;
779
+ continue;
780
+ }
781
+ queued.quarantineError = undefined;
782
+ const config = await teams.readConfig(teamName);
783
+ assertPending();
784
+ if (config.members.some(member => member.name === queued.member.name)) throw new Error(`A teammate named ${queued.member.name} already exists.`);
785
+ if (activeAgentCount(teamName, role, true) >= capacity.maxConcurrent) continue;
786
+ queued.member.joinedAt = Date.now();
787
+ await startReadAgentMember(teamName, queued.member, queued.prompt, queued.ctx,
788
+ queued.nameReservationId, false, queued.pendingChildAcceptance, assertPending,
789
+ () => { queued.launchCommitted = true; });
790
+ removeQueuedReadSpawnById(teamName, queued.id, false);
791
+ progressed = true;
792
+ } catch (error) {
793
+ if (!readQueue(teamName).some(item => item.id === queued.id)) continue;
794
+ queued.launchCommitted = false;
795
+ const latestFence = await readLifecycleTombstone(teamName, queued.member.name).catch(() => null);
796
+ if (latestFence && latestFence.status !== "absent") {
797
+ queued.quarantineError = "Lifecycle quarantine appeared before admission.";
798
+ continue;
799
+ }
800
+ queued.admissionError = error instanceof Error ? error.message : String(error);
801
+ failedAdmissionsByTeam.set(teamName, [...(failedAdmissionsByTeam.get(teamName) ?? []), queued].slice(-20));
752
802
  removeQueuedReadSpawnById(teamName, queued.id);
753
- continue;
754
- }
755
- queued.member.joinedAt = Date.now();
756
- await startReadAgentMember(
757
- teamName,
758
- queued.member,
759
- queued.prompt,
760
- queued.ctx,
761
- queued.nameReservationId,
762
- false,
763
- queued.pendingChildAcceptance
764
- );
765
- removeQueuedReadSpawnById(teamName, queued.id, false);
766
- } catch (error) {
767
- const latestFence = await readLifecycleTombstone(teamName, queued.member.name);
768
- if (latestFence.status !== "absent") {
769
- await messaging.sendPlainMessage(
770
- teamName,
771
- "system",
772
- "team-lead",
773
- `Retained queued agent ${queued.member.name}: lifecycle quarantine appeared before admission.`,
774
- `Queued agent ${queued.member.name} retained by quarantine`,
775
- "yellow"
776
- ).catch(() => {});
777
- return;
803
+ await messaging.sendPlainMessage(teamName, "system", queued.member.parentAgentName || "team-lead",
804
+ `Queued agent ${queued.member.name} failed admission: ${queued.admissionError}`,
805
+ `Queued agent ${queued.member.name} failed`, "red").catch(() => {});
778
806
  }
779
- // Invalid non-lifecycle requests may be dropped so later work can run.
780
- removeQueuedReadSpawnById(teamName, queued.id);
781
807
  }
782
808
  }
783
809
  } finally {
784
810
  readQueueDrainingTeams.delete(teamName);
811
+ if (pendingQueueDrains.delete(teamName)) void drainQueuedReadSpawns(teamName);
785
812
  }
786
813
  }
787
814
 
@@ -800,7 +827,11 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
800
827
  const { availableModels } = await getModelSelectionState(ctx, ctx.cwd, [explicitDefaultModel]);
801
828
  const defaultModel = requireQualifiedKnownModel(explicitDefaultModel, availableModels, "model_slot");
802
829
  if (!defaultModel) throw new Error("Agent sessions require a configured model_slot level before spawning.");
803
- teams.createTeam(sessionName, getPiSessionId(ctx) || "local-session", "lead-agent", "Pi session agents", defaultModel);
830
+ try {
831
+ teams.createTeamIfAbsent(sessionName, getPiSessionId(ctx) || "local-session", "lead-agent", "Pi session agents", defaultModel);
832
+ } catch (error) {
833
+ if (!(error instanceof teams.TeamAlreadyExistsError)) throw error;
834
+ }
804
835
  options.adoptTeamAsLead(sessionName, ctx);
805
836
  return sessionName;
806
837
  }
@@ -810,6 +841,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
810
841
  const safeTeamName = paths.sanitizeName(params.team_name);
811
842
  const cwd = params.cwd || ctx.cwd;
812
843
  const teamConfig = await teams.readConfig(safeTeamName);
844
+ failedAdmissionsByTeam.set(safeTeamName, (failedAdmissionsByTeam.get(safeTeamName) ?? []).filter(item => item.member.name !== safeName));
813
845
  let nestedNameReservationId: string | undefined;
814
846
  let nestedNameReservationTransferred = false;
815
847
 
@@ -958,90 +990,40 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
958
990
  helperKind: spawnOptions.nestedParent ? "read_helper" : undefined,
959
991
  };
960
992
 
961
- if (role === "read") {
962
- if (!spawnOptions.nestedParent) removeQueuedReadSpawnsByName(safeTeamName, safeName);
963
- const currentReadCount = activeReadCount(safeTeamName);
964
- if (currentReadCount >= settings.readAgents.maxConcurrent) {
965
- if (!settings.readAgents.queueOverflow) {
966
- throw new Error(`Read-agent capacity reached (${currentReadCount}/${settings.readAgents.maxConcurrent}) and queueOverflow is disabled.`);
967
- }
968
- const queued = await enqueueReadSpawn(
969
- safeTeamName,
970
- member,
971
- params.prompt,
972
- params,
973
- resolved,
974
- ctx,
975
- nestedNameReservationId
976
- );
977
- nestedNameReservationTransferred = !!nestedNameReservationId;
978
- const queuePosition = readQueue(safeTeamName).findIndex((item) => item.id === queued.id) + 1;
979
- return {
980
- content: [{ type: "text", text: `Read teammate ${params.name} queued at position ${queuePosition}; capacity is ${currentReadCount}/${settings.readAgents.maxConcurrent}.` }],
981
- details: queuedReadResolutionDetails(queued, params, { queuePosition }),
982
- };
993
+ if (role === "write") await writeQueue.removeQueuedWriteSpawnsByName(safeTeamName, safeName);
994
+ if (!spawnOptions.nestedParent) removeQueuedReadSpawnsByName(safeTeamName, safeName);
995
+ if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
996
+ const capacity = role === "write" ? settings.writeAgents : settings.readAgents;
997
+ const activeCount = activeAgentCount(safeTeamName, role, true);
998
+ if (activeCount >= capacity.maxConcurrent) {
999
+ if (!capacity.queueOverflow) {
1000
+ throw new Error(`${role === "write" ? "Edit" : "Read"}-agent capacity reached (${activeCount}/${capacity.maxConcurrent}) and queueOverflow is disabled.`);
983
1001
  }
984
-
985
- const sessionContextAvailable = await startReadAgentMember(safeTeamName, member, params.prompt, ctx, nestedNameReservationId);
1002
+ const queued = await enqueueReadSpawn(safeTeamName, member, params.prompt, params, resolved, ctx, nestedNameReservationId);
1003
+ nestedNameReservationTransferred = !!nestedNameReservationId;
1004
+ const queuePosition = readQueue(safeTeamName).findIndex(item => item.id === queued.id) + 1;
986
1005
  return {
987
- content: [{ type: "text", text: `Read teammate ${params.name} started in-process.` }],
988
- details: spawnResolutionDetails(member, params, resolved, {
989
- mode: "in-process",
990
- terminalId: null,
991
- queued: false,
992
- sessionContextAvailable,
993
- }),
1006
+ content: [{ type: "text", text: `Agent ${params.name} queued at position ${queuePosition}; capacity is ${activeCount}/${capacity.maxConcurrent}.` }],
1007
+ details: queuedReadResolutionDetails(queued, params, { queuePosition }),
994
1008
  };
995
1009
  }
996
1010
 
997
- await writeQueue.removeQueuedWriteSpawnsByName(safeTeamName, safeName);
998
- const activeWriteCount = activeAgentCount(safeTeamName, "write");
999
- await writeTeamsDebugEvent(safeTeamName, "write-agent.spawn.request", {
1000
- agentName: safeName,
1001
- cwd,
1002
- category: params.category ?? null,
1003
- requestedRole: role,
1004
- resolvedRole: role,
1005
- model: chosenModel,
1006
- modelSource: resolved.modelSource,
1007
- thinking: chosenThinking ?? null,
1008
- activeWriteCount,
1009
- maxConcurrent: settings.writeAgents.maxConcurrent,
1010
- queueOverflow: false,
1011
- mode: "in-process",
1012
- debugLogPath: debugLogPath ?? null,
1013
- }, settings);
1014
-
1015
- if (activeWriteCount >= settings.writeAgents.maxConcurrent) {
1016
- await writeTeamsDebugEvent(safeTeamName, "write-agent.spawn.failure", {
1017
- agentName: safeName,
1018
- reason: "capacity-reached",
1019
- activeWriteCount,
1020
- maxConcurrent: settings.writeAgents.maxConcurrent,
1021
- mode: "in-process",
1011
+ const sessionContextAvailable = await startReadAgentMember(safeTeamName, member, params.prompt, ctx, nestedNameReservationId);
1012
+ if (role === "write") {
1013
+ await writeTeamsDebugEvent(safeTeamName, "write-agent.spawn.success", {
1014
+ agentName: safeName, cwd, requestedRole: role, model: chosenModel,
1015
+ modelSource: resolved.modelSource, thinking: chosenThinking ?? null,
1016
+ activeWriteCount: activeCount, maxConcurrent: capacity.maxConcurrent,
1017
+ queueOverflow: capacity.queueOverflow, terminalId: null, mode: "in-process",
1022
1018
  debugLogPath: debugLogPath ?? null,
1023
1019
  }, settings);
1024
- throw new Error(`Edit-agent capacity reached (${activeWriteCount}/${settings.writeAgents.maxConcurrent}). Wait for an active edit agent to finish before spawning another.`);
1025
1020
  }
1026
-
1027
- await writeTeamsDebugEvent(safeTeamName, "write-agent.spawn.success", {
1028
- agentName: safeName,
1029
- terminalId: null,
1030
- windowId: null,
1031
- mode: "in-process",
1032
- debugLogPath: debugLogPath ?? null,
1033
- }, settings);
1034
- const sessionContextAvailable = await startReadAgentMember(safeTeamName, member, params.prompt, ctx);
1035
1021
  options.renderReadAgentStatus();
1036
1022
  const debugSuffix = debugLogPath ? ` Debug log: ${debugLogPath}.` : "";
1037
1023
  return {
1038
- content: [{ type: "text", text: `Edit agent ${params.name} started in-process and is followable from Pi.${debugSuffix}` }],
1024
+ content: [{ type: "text", text: `${role === "write" ? "Edit agent" : "Read teammate"} ${params.name} started in-process and is followable from Pi.${debugSuffix}` }],
1039
1025
  details: spawnResolutionDetails(member, params, resolved, {
1040
- mode: "in-process",
1041
- terminalId: null,
1042
- queued: false,
1043
- debugLogPath,
1044
- sessionContextAvailable,
1026
+ mode: "in-process", terminalId: null, queued: false, debugLogPath, sessionContextAvailable,
1045
1027
  }),
1046
1028
  };
1047
1029
  } finally {
@@ -1326,5 +1308,12 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
1326
1308
  },
1327
1309
  });
1328
1310
 
1329
- return { createNestedReadAgentTools };
1311
+ return {
1312
+ createNestedReadAgentTools,
1313
+ cancelQueuedAgent: (teamName, agentName) => {
1314
+ // Retain the entry for admission bookkeeping, but let active teardown own cancellation after launch commits.
1315
+ if (readQueue(teamName).some(queued => queued.member.name === agentName && queued.launchCommitted)) return false;
1316
+ return removeQueuedReadSpawnsByName(teamName, agentName).length > 0;
1317
+ },
1318
+ };
1330
1319
  }
@@ -1,8 +1,9 @@
1
1
  import type { RunningReadAgent } from "../runtime/types";
2
+ import { AGENT_HANGING_MS } from "../../src/orchestration/status-projection";
2
3
  import { formatElapsed } from "./renderers.js";
3
4
 
4
5
  export const READ_AGENT_IDLE_NUDGE_MS = 5 * 60_000;
5
- export const READ_AGENT_HANGING_NUDGE_MS = 15 * 60_000;
6
+ export const READ_AGENT_HANGING_NUDGE_MS = AGENT_HANGING_MS;
6
7
 
7
8
  export type ReadAgentStatusLabel = RunningReadAgent["status"] | "idle" | "hanging";
8
9
  export type ReadAgentIdleLevel = "none" | "soft" | "hard";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extended-teams",
3
- "version": "2.2.7",
3
+ "version": "2.2.8",
4
4
  "description": "Control-first, session-connected subagents for Pi with live navigation and intent-tier routing",
5
5
  "repository": {
6
6
  "type": "git",
package/skills/teams.md CHANGED
@@ -69,7 +69,7 @@ Inspect only the concurrent rotation path and focused tests. Report gaps, covera
69
69
 
70
70
  Then:
71
71
 
72
- 1. Read agents run in-process; edit agents run in separate Pi sessions through the configured terminal adapter. Both report back to the lead.
72
+ 1. Public read and edit agents run in separate in-process Pi sessions and report back to the lead. A write tier grants edit tools, not a terminal pane. The terminal runtime remains available for existing integrations.
73
73
  2. The lead synthesizes the reports for the user.
74
74
  3. Finished agents leave the active status list; completed reports remain available in the session UI.
75
75
 
@@ -138,7 +138,7 @@ When the user says "agents", "use agents", "spawn agents", "send agents", "agent
138
138
  - When no unrelated work remains, end the turn. The extension resumes the lead when reports arrive.
139
139
  - One `get_agent_status` snapshot is allowed when current status is needed. Never call it repeatedly, sleep, busy-wait, or loop on inbox/status.
140
140
  - Trust quiet agents. Do not ping, message, or check an agent just because it has been quiet for less than several minutes; active status remains visible in the activity card and Down-key live view.
141
- - When new, changed, or previously omitted evidence affects an active owner, use `send_message` with an **Evidence delta** as defined below instead of replacing or stopping it. Active in-process read agents receive the message as a steering turn and can continue intelligently; active tmux writers wake through their inbox.
141
+ - When new, changed, or previously omitted evidence affects an active owner, use `send_message` with an **Evidence delta** as defined below instead of replacing or stopping it. Active in-process agents receive the message as a steering turn; legacy terminal-backed agents receive inbox delivery.
142
142
  - Once a final report is accepted, new message admission is closed and the agent is self-exiting; teardown may still be finishing. Do not call `stop_teammate` after normal completion. If genuinely new work appears after the report, spawn a fresh bounded `read-collect` lane rather than trying to revive that closing session.
143
143
  - Do not wake the lead just to ping idle agents.
144
144
  - Use `check_teammate` only when `get_agent_status` shows a suspected stall or failure. It is a lifecycle diagnostic and may clean up an agent classified as dead.
@@ -6,6 +6,8 @@ import * as runtime from "../utils/runtime";
6
6
  import * as writeQueue from "../utils/write-queue";
7
7
  import * as reports from "../utils/report-events";
8
8
  import type { Member } from "../utils/models";
9
+ import { readLifecycleTombstone } from "../utils/lifecycle-tombstone";
10
+ import { isAgentActivity, projectAgentStatus } from "./status-projection";
9
11
  import { canonicalPersistedModelSlot, loadSettings, requireFavoriteModelLevel, roleForFavoriteModelSlot } from "../utils/settings";
10
12
  import type {
11
13
  BroadcastMessageOnceRequest,
@@ -19,7 +21,6 @@ import type {
19
21
  SpawnTeammatesOnceResponse,
20
22
  TeamObservation,
21
23
  TeammateResolutionDetails,
22
- TeammateHealth,
23
24
  TeammateObservation,
24
25
  } from "./types";
25
26
 
@@ -41,11 +42,6 @@ export async function appendTeamReportEvent(teamName: string, event: reports.New
41
42
  return await next;
42
43
  }
43
44
 
44
- function readAgentIsKnownRunning(teamName: string, agentName: string, options: ObserveRuntimeOptions): boolean {
45
- const key = options.readAgentKey?.(teamName, agentName) || `${teamName}:${agentName}`;
46
- return !!options.runningReadAgents?.has(key);
47
- }
48
-
49
45
  function operationValue(source: { operationId?: string; workflowRunId?: string; metadata?: Record<string, any> }, key: "operationId" | "workflowRunId"): string | undefined {
50
46
  return source[key] || source.metadata?.[key] || source.metadata?.orchestration?.[key];
51
47
  }
@@ -268,40 +264,32 @@ async function observeKnownTeammate(
268
264
  const unreadCount = (await messaging.peekInbox(teamName, member.name, true).catch(() => [])).length;
269
265
  const runtimeStatus = member.name === "team-lead" ? null : await runtime.readRuntimeStatus(teamName, member.name).catch(() => null);
270
266
  const now = options.now ?? Date.now();
271
- const hasRecentHeartbeat = runtime.isHeartbeatFresh(runtimeStatus, now);
272
-
273
- let alive: boolean | null;
274
- if (member.name === "team-lead") {
275
- alive = true;
276
- } else if (role === "read") {
277
- alive = readAgentIsKnownRunning(teamName, member.name, options) || (!!runtimeStatus && hasRecentHeartbeat && member.isActive !== false);
278
- } else if (member.tmuxPaneId && options.terminal?.isAlive) {
279
- alive = options.terminal.isAlive(member.tmuxPaneId);
280
- } else {
281
- alive = null;
282
- }
283
-
284
- const startupStalled = alive === true && unreadCount > 0 && (now - member.joinedAt) > runtime.STARTUP_STALL_MS && !(runtimeStatus?.ready);
285
- let health: TeammateHealth;
286
- if (member.name === "team-lead") health = "lead";
287
- else if (alive === null) health = "unknown";
288
- else if (!alive) health = "dead";
289
- else if (startupStalled) health = "stalled";
290
- else if (runtimeStatus?.ready) health = hasRecentHeartbeat ? "healthy" : "idle";
291
- else health = "starting";
267
+ const key = options.readAgentKey?.(teamName, member.name) || `${teamName}:${member.name}`;
268
+ const candidate = options.runningReadAgents?.get(key);
269
+ const fence = member.name === "team-lead" ? { status: "absent" as const }
270
+ : await readLifecycleTombstone(teamName, member.name).catch(error => ({
271
+ status: "corrupt" as const, error: error instanceof Error ? error.message : String(error),
272
+ }));
273
+ const projected = projectAgentStatus({
274
+ member, activity: isAgentActivity(candidate) ? candidate : undefined,
275
+ runtime: runtimeStatus, fence, now, unreadCount,
276
+ terminalAlive: member.tmuxPaneId && options.terminal?.isAlive ? options.terminal.isAlive(member.tmuxPaneId) : null,
277
+ });
292
278
 
293
279
  return {
294
280
  teamName,
295
281
  agentName: member.name,
296
282
  member,
297
283
  role,
298
- alive,
299
- health,
284
+ alive: member.name === "team-lead" ? true : projected.alive,
285
+ health: member.name === "team-lead" ? "lead" : projected.health,
286
+ phase: member.name === "team-lead" ? "lead" : projected.phase,
287
+ error: projected.error,
300
288
  unreadCount,
301
- agentLoopReady: !!runtimeStatus?.ready,
302
- hasRecentHeartbeat,
303
- startupStalled,
304
- runtime: runtimeStatus,
289
+ agentLoopReady: projected.agentLoopReady,
290
+ hasRecentHeartbeat: projected.hasRecentHeartbeat,
291
+ startupStalled: projected.startupStalled,
292
+ runtime: projected.runtime,
305
293
  };
306
294
  }
307
295
 
@@ -0,0 +1,89 @@
1
+ import type { Member } from "../utils/models";
2
+ import type { LifecycleTombstoneReadResult } from "../utils/lifecycle-tombstone";
3
+ import { isHeartbeatFresh, STARTUP_STALL_MS, type AgentRuntimeStatus } from "../utils/runtime";
4
+
5
+ export const AGENT_HANGING_MS = 15 * 60_000;
6
+
7
+ export type ActiveAgentPhase = "starting" | "thinking" | "working" | "finishing"
8
+ | "stalled" | "stopping" | "quarantined" | "persistence-failed";
9
+
10
+ export interface AgentActivity {
11
+ runId: string;
12
+ status: "starting" | "thinking" | "working" | "finishing";
13
+ startedAt: number;
14
+ role?: string;
15
+ lastActivityAt?: number;
16
+ teardownState?: string;
17
+ handoffDetached?: boolean;
18
+ latestProgress?: string;
19
+ progressUpdatedAt?: number;
20
+ activeToolName?: string;
21
+ lastError?: { message: string };
22
+ }
23
+
24
+ export function isAgentActivity(value: unknown): value is AgentActivity {
25
+ return typeof value === "object" && value !== null
26
+ && "runId" in value && typeof value.runId === "string"
27
+ && "startedAt" in value && typeof value.startedAt === "number"
28
+ && "status" in value && typeof value.status === "string"
29
+ && ["starting", "thinking", "working", "finishing"].includes(value.status);
30
+ }
31
+
32
+ function lifecyclePhase(member: Member, fence: LifecycleTombstoneReadResult): { phase: ActiveAgentPhase; error?: string } | undefined {
33
+ if (fence.status === "absent") return;
34
+ if (fence.status === "corrupt") return { phase: "quarantined", error: fence.error };
35
+ const { tombstone } = fence;
36
+ const mismatch = member.lifecycleRunId && tombstone.runId !== member.lifecycleRunId
37
+ ? `Lifecycle fence belongs to run ${tombstone.runId}, not roster run ${member.lifecycleRunId}.`
38
+ : undefined;
39
+ return {
40
+ phase: tombstone.phase === "cleanup_failed" || tombstone.phase === "timed_out" ? "quarantined" : "stopping",
41
+ error: mismatch || tombstone.error,
42
+ };
43
+ }
44
+
45
+ export function projectAgentStatus(input: {
46
+ member: Member;
47
+ activity?: AgentActivity;
48
+ runtime: AgentRuntimeStatus | null;
49
+ fence: LifecycleTombstoneReadResult;
50
+ terminalAlive: boolean | null;
51
+ now: number;
52
+ unreadCount?: number;
53
+ }) {
54
+ const { member, now } = input;
55
+ const state = input.activity?.runId === member.lifecycleRunId ? input.activity : undefined;
56
+ const runtime = input.runtime?.lifecycleRunId === member.lifecycleRunId ? input.runtime : null;
57
+ const persisted = lifecyclePhase(member, input.fence);
58
+ const hasRecentHeartbeat = isHeartbeatFresh(runtime, now);
59
+ const inProcess = !!state && state.teardownState !== "finalized" && !state.handoffDetached;
60
+ const externalAlive = member.isActive !== false && (hasRecentHeartbeat || input.terminalAlive === true);
61
+ const alive = inProcess || externalAlive ? true
62
+ : member.isActive === false || input.terminalAlive === false || member.role === "read" ? false : null;
63
+ const startupStalled = alive === true && (input.unreadCount ?? 0) > 0
64
+ && now - member.joinedAt > STARTUP_STALL_MS && !runtime?.ready;
65
+ const teardownPhase = state?.teardownState === "persistence_failed" ? "persistence-failed"
66
+ : state?.teardownState === "quarantined" ? "quarantined"
67
+ : state?.teardownState === "stopping" ? "stopping" : undefined;
68
+ const fencedPhase = teardownPhase ?? persisted?.phase;
69
+ let phase: ActiveAgentPhase;
70
+ if (fencedPhase) phase = fencedPhase;
71
+ else if (inProcess) phase = now - (state.lastActivityAt || state.startedAt) >= AGENT_HANGING_MS ? "stalled" : state.status;
72
+ else if (!externalAlive) phase = member.isActive !== false && !runtime?.ready && now - member.joinedAt <= STARTUP_STALL_MS ? "starting" : "stalled";
73
+ else phase = runtime?.currentAction === "done" ? "finishing" : runtime?.currentAction ?? (runtime?.ready ? "working" : "starting");
74
+
75
+ const health = fencedPhase === "quarantined" ? "quarantined"
76
+ : fencedPhase === "persistence-failed" ? "persistence-failed"
77
+ : fencedPhase ? "stopping"
78
+ : startupStalled || (phase === "stalled" && alive) ? "stalled"
79
+ : alive === null ? "unknown"
80
+ : !alive ? "dead"
81
+ : runtime?.ready ? hasRecentHeartbeat ? "healthy" : "idle"
82
+ : inProcess && state.status !== "starting" ? "healthy" : "starting";
83
+ return {
84
+ phase, alive, health, hasRecentHeartbeat, startupStalled,
85
+ agentLoopReady: !fencedPhase && alive === true && (!!runtime?.ready || (inProcess && state.status !== "starting")),
86
+ state, runtime,
87
+ error: persisted?.error || state?.lastError?.message || runtime?.lastError?.message,
88
+ };
89
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AgentRuntimeStatus } from "../utils/runtime";
2
+ import type { ActiveAgentPhase, projectAgentStatus } from "./status-projection";
2
3
  import type { QueuedWriteSpawn } from "../utils/write-queue";
3
4
  import type { FileClaim } from "../utils/claims";
4
5
  import type { InboxMessage, Member, TaskFile, TeamConfig, TeamReportEvent, ThinkingLevel } from "../utils/models";
@@ -12,7 +13,7 @@ export interface OrchestrationOperationMetadata {
12
13
  metadata?: Record<string, any>;
13
14
  }
14
15
 
15
- export type TeammateHealth = "lead" | "healthy" | "idle" | "starting" | "stalled" | "dead" | "unknown";
16
+ export type TeammateHealth = "lead" | ReturnType<typeof projectAgentStatus>["health"];
16
17
 
17
18
  export interface TeammateObservation {
18
19
  teamName: string;
@@ -21,6 +22,8 @@ export interface TeammateObservation {
21
22
  role: string;
22
23
  alive: boolean | null;
23
24
  health: TeammateHealth;
25
+ phase?: ActiveAgentPhase | "lead";
26
+ error?: string;
24
27
  unreadCount: number;
25
28
  agentLoopReady: boolean;
26
29
  hasRecentHeartbeat: boolean;