pi-extended-teams 2.2.6 → 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.
@@ -55,6 +55,8 @@ This works well for multi-angle code review, root-cause investigation, parallel
55
55
 
56
56
  With the editor empty, press Down to open agent navigation. Use Down/Up to move, `l` to expand large tool logs, `m` to message an agent, `i` to interrupt its currently running tool command, `x` to stop the whole agent, and Escape to return.
57
57
 
58
+ Inside Herdr, press `h` in an ordinary direct agent's preview to move it into a focused sibling Pi pane in the same workspace. Its conversation and team communication continue there. Nested helpers and delegation-enabled or workflow agents are excluded.
59
+
58
60
  Scroll the transcript with Page Up/Page Down or the mouse wheel, including under Herdr. Press End or scroll back to the bottom to follow new output.
59
61
 
60
62
  The lead can invoke the same command-only behavior with `interrupt_teammate({ agent_name: "agent" })`. It keeps the agent's session, task context, and file claims intact so you can send follow-up work. In-process cancellation is cooperative and may report that it is still pending; for tmux-backed agents, success means Pi's Escape key was delivered, not that command settlement was independently confirmed.
@@ -102,6 +104,8 @@ For an edit, choose a write tier and name the files it may claim. Never run over
102
104
 
103
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.
104
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
+
105
109
  Spawned sessions are private by default under `~/.pi/teams/<team>/agent-sessions/` and stay out of Pi's normal `/resume` picker.
106
110
 
107
111
  ## Security and data access
package/docs/access.md CHANGED
@@ -8,13 +8,13 @@
8
8
  - Write teammates start a separate Pi process in a tmux pane. The extension builds and runs the local Pi launch command with the teammate's working directory, model and thinking configuration, selected extensions, and `PI_TEAM_NAME`, `PI_AGENT_NAME`, and `PI_LIFECYCLE_RUN_ID` environment variables.
9
9
  - Before launching a write teammate, the extension runs a bounded local `pi models --all` preflight through `sh -c` to determine whether the configured model is available.
10
10
  - On macOS, while at least one write teammate is active, the extension may run `/usr/bin/caffeinate -i -w <lead-pid>` to prevent idle system sleep. It terminates that helper when no write teammates remain or the extension is disposed.
11
- - In a Herdr session, the extension can invoke the configured `herdr` binary to split, start, and close panes when moving an agent. The binary path defaults to `herdr` and can be set with `HERDR_BIN_PATH`.
11
+ - Inside Herdr, pressing `h` can move an eligible in-process agent into a sibling Pi pane. The extension calls the `herdr` executable to split, run, focus, or close that pane.
12
12
 
13
13
  ## Files and local state
14
14
 
15
15
  The extension reads settings from `~/.pi/agent/pi-extended-teams/settings.json` and `<project>/.pi/pi-extended-teams.json`. Model-provider compatibility also reads the legacy `~/.pi/pi-extended-teams.json` path. Predefined agents can be read from `~/.pi/agent/agents/` and `<project>/.pi/agents/`; team templates can be read or written at `~/.pi/teams.yaml`, `~/.pi/agent/teams.yaml`, and `<project>/.pi/teams.yaml`.
16
16
 
17
- For a team named `<team>`, coordination state is stored under `~/.pi/teams/<team>/`. This includes `config.json`, inboxes, runtime status, session-context references, lifecycle quarantine and tombstones, file claims, write and read-helper queues, shared memory, report events, lead-session metadata, debug logs, and private child transcripts under `agent-sessions/`. Task records are stored under `~/.pi/tasks/<team>/`, and agent reports may also be written under `~/.pi/agent/reports/`. Cleanup removes lifecycle and queue files when they are no longer needed.
17
+ For a team named `<team>`, coordination state is stored under `~/.pi/teams/<team>/`. This includes `config.json`, inboxes, runtime status, session-context references, lifecycle quarantine and tombstones, file claims, write and read-helper queues, shared memory, report events, lead-session metadata, debug logs, and private child transcripts and handoff prompt snapshots under `agent-sessions/`. Task records are stored under `~/.pi/tasks/<team>/`, and agent reports may also be written under `~/.pi/agent/reports/`. Cleanup removes lifecycle and queue files when they are no longer needed.
18
18
 
19
19
  The extension reads project extension sources only when Pi marks the same working directory trusted. Teammates receive the working directory supplied at spawn time. A write teammate can use any filesystem access granted to its child Pi process and enabled tools. A read teammate's tools are restricted by the extension, but it still receives prompt and project context supplied by the lead.
20
20
 
@@ -1,4 +1,9 @@
1
1
  import type { AgentSession } from "@mariozechner/pi-coding-agent";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { buildPiCommand, getPiLaunchCommand, shellQuote } from "../internal/pi-command";
6
+ import { herdrCommand } from "../runtime/herdr";
2
7
  import * as runtime from "../../src/utils/runtime";
3
8
  import * as teams from "../../src/utils/teams";
4
9
  import * as messaging from "../../src/utils/messaging";
@@ -12,7 +17,7 @@ import { isPiPromptPlanningMember, shouldSuppressLeadReportInjection } from "../
12
17
  import { canonicalPersistedModelSlot, loadSettings, requireFavoriteModelLevel } from "../../src/utils/settings";
13
18
  import { parseQualifiedModel } from "../../src/utils/model-resolution";
14
19
  import { closePersistedRecipient } from "../team/recipient-closure";
15
- import { generateExtensionInstanceId, generateLifecycleRunId } from "../../src/utils/lifecycle-tombstone";
20
+ import { generateExtensionInstanceId, generateLifecycleRunId, withLifecycleTombstoneLock } from "../../src/utils/lifecycle-tombstone";
16
21
  import { createLifecycleRuntime, type ShutdownTeammateOptions } from "../team/lifecycle";
17
22
  import {
18
23
  createSpawnResourcePlan,
@@ -585,7 +590,9 @@ export async function runReadAgentInProcess(
585
590
  resolveSessionCreation = resolve;
586
591
  });
587
592
  let lifecycleRunId = member.lifecycleRunId ?? generateLifecycleRunId();
588
- 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)) {
589
596
  lifecycleRunId = await teams.ensureMemberLifecycleRunId(readTeamName, member.name, lifecycleRunId);
590
597
  }
591
598
  member.lifecycleRunId = lifecycleRunId;
@@ -634,6 +641,7 @@ export async function runReadAgentInProcess(
634
641
  getTeamName: () => readTeamName,
635
642
  }).shutdownTeammate;
636
643
 
644
+ let handoffRequested = false;
637
645
  let submittedFinalReport: SubmittedAgentReport | undefined;
638
646
  let finalReportSubmissionInProgress = false;
639
647
  let childSessionManager: any;
@@ -660,6 +668,7 @@ export async function runReadAgentInProcess(
660
668
  const closeRecipient = async (): Promise<ReadAgentDeliveryCloseResult> => {
661
669
  if (pendingChildParent) pendingChildController?.cancelParent(pendingChildParent);
662
670
  const deliveryClose = closeReadAgentMessageDelivery(state);
671
+ if (handoffRequested) return deliveryClose;
663
672
  if (!state.recipientClosurePromise) {
664
673
  state.recipientClosurePromise = closePersistedRecipient(
665
674
  readTeamName,
@@ -959,7 +968,7 @@ export async function runReadAgentInProcess(
959
968
  } as Parameters<typeof createAgentSession>[0] & { modelRuntime?: unknown });
960
969
 
961
970
  state.session = session;
962
- installReadAgentSessionLifecycle(session);
971
+ const sessionLifecycle = installReadAgentSessionLifecycle(session);
963
972
  try {
964
973
  if (typeof session.bindExtensions === "function") {
965
974
  await (session.bindExtensions as (bindings: { mode: "print" }) => Promise<void>)({ mode: "print" });
@@ -973,6 +982,96 @@ export async function runReadAgentInProcess(
973
982
  if (state.teardownState !== "persistence_failed") await state.teardownPromise;
974
983
  return;
975
984
  }
985
+ if (process.env.HERDR_ENV === "1" && (member.delegationDepth ?? 0) === 0
986
+ && !member.requestedBy && !member.parentAgentName && !member.allowNestedReadAgents
987
+ && !shouldSuppressLeadReportInjection(member)) {
988
+ let moving: Promise<void> | undefined;
989
+ let paneId: string | undefined;
990
+ let command = "";
991
+ let queued: string[] = [];
992
+ let released = false;
993
+ const release = Promise.all([finished, sessionLifecycle.finalized]).then(() => {
994
+ if (handoffRequested) { state.session = undefined; released = true; }
995
+ });
996
+ void release.catch(() => {});
997
+ const recordPane = (id: string | undefined) => withLifecycleTombstoneLock(readTeamName, member.name, async lock => {
998
+ const current = (await teams.readConfig(readTeamName)).members.find(item => item.name === member.name);
999
+ if (!current || current.lifecycleRunId !== state.runId || current.isActive === false || lock.read().status !== "absent") {
1000
+ throw new Error("The agent is no longer available to move.");
1001
+ }
1002
+ await teams.updateMember(readTeamName, member.name, { herdrPaneId: id, tmuxPaneId: "" });
1003
+ });
1004
+ state.moveToHerdr = () => moving ??= (async () => {
1005
+ if (!options.isCurrentReadAgentRun(key, state) || (!handoffRequested && !state.acceptingMessages)) {
1006
+ throw new Error("The agent is already finishing.");
1007
+ }
1008
+ if (handoffRequested && !released) throw new Error("The current operation is still stopping. Try h after it settles.");
1009
+ if (paneId) { herdrCommand("pane", "close", paneId); await recordPane(undefined); paneId = undefined; }
1010
+ const sessionFile = childSessionManager.getSessionFile();
1011
+ if (!sessionFile || !fs.existsSync(sessionFile)) throw new Error("The agent has no saved session yet.");
1012
+ if (!handoffRequested) {
1013
+ const promptDir = privateSessionDirectory ?? preparePrivateAgentSessionDirectory(readTeamName, member.name, state.runId);
1014
+ const promptFile = path.join(promptDir, "herdr-system-prompt.txt");
1015
+ fs.writeFileSync(promptFile, session.agent.state.systemPrompt.replace("running in-process so the lead can follow and control you from Pi", "running in a Herdr pane"), { mode: 0o600 });
1016
+ const tools = session.agent.state.tools.map(tool => tool.name).join(",");
1017
+ const identity = {
1018
+ HOME: os.homedir(), PI_CODING_AGENT_DIR: agentDir,
1019
+ PI_AGENT_NAME: member.name, PI_TEAM_NAME: readTeamName, PI_LIFECYCLE_RUN_ID: state.runId,
1020
+ PI_EXTENDED_TEAMS_HERDR_RESUME: "1",
1021
+ };
1022
+ const currentModel = session.model ?? model;
1023
+ const launch = buildPiCommand(getPiLaunchCommand(), `${currentModel.provider}/${currentModel.id}`,
1024
+ session.thinkingLevel ?? member.thinking, resourcePlan.extensionPaths,
1025
+ resourcePlan.trust.projectTrusted, resourcePlan.selfExtensionPath);
1026
+ command = [
1027
+ "env", ...Object.entries(identity).map(([name, value]) => `${name}=${shellQuote(value)}`), launch,
1028
+ "--session", shellQuote(sessionFile), "--tools", shellQuote(tools), "--system-prompt", shellQuote(promptFile),
1029
+ ].join(" ");
1030
+ // Keep fresh-shell input below the PTY line limit without shortening the launch arguments.
1031
+ const launchFile = path.join(promptDir, "herdr-launch.sh");
1032
+ fs.writeFileSync(launchFile, `exec ${command}\n`, { mode: 0o600 });
1033
+ command = `/bin/sh ${shellQuote(launchFile)}`;
1034
+ }
1035
+ paneId = JSON.parse(herdrCommand("pane", "split", "--current", "--direction", "right", "--cwd", member.cwd, "--focus")).result?.pane?.pane_id;
1036
+ if (typeof paneId !== "string" || !paneId) throw new Error("Herdr did not return a pane ID.");
1037
+ try {
1038
+ if (!handoffRequested) {
1039
+ handoffRequested = true;
1040
+ state.stopRequested = true;
1041
+ const delivery = closeReadAgentMessageDelivery(state);
1042
+ const pending = session.clearQueue();
1043
+ queued = [...pending.steering, ...pending.followUp];
1044
+ signalReadAgentWake(state);
1045
+ const shutdown = await sessionLifecycle.requestShutdown("resume", delivery.rawDeliverySettlement);
1046
+ if (shutdown.status !== "settled" || shutdown.abort !== "settled" || shutdown.dispose !== "settled") {
1047
+ throw new Error("The current operation has not stopped. Try h after it settles.");
1048
+ }
1049
+ await release;
1050
+ }
1051
+ if (queued.length) {
1052
+ SessionManager.open(sessionFile).appendMessage({ role: "user", content: queued.join("\n\n"), timestamp: Date.now() });
1053
+ queued = [];
1054
+ }
1055
+ const previousPid = (await runtime.readRuntimeStatus(readTeamName, member.name))?.pid;
1056
+ await recordPane(paneId);
1057
+ herdrCommand("pane", "run", paneId, command);
1058
+ const deadline = Date.now() + 10000;
1059
+ for (;;) {
1060
+ const status = await runtime.readRuntimeStatus(readTeamName, member.name);
1061
+ if (status?.lifecycleRunId === state.runId && status.pid && status.pid !== previousPid && status.pid !== process.pid) break;
1062
+ if (Date.now() >= deadline) throw new Error("Pi did not resume. The saved session is retained; h can retry.");
1063
+ await new Promise(resolve => setTimeout(resolve, 50));
1064
+ }
1065
+ if (options.isCurrentReadAgentRun(key, state)) options.runningReadAgents.delete(key);
1066
+ options.renderReadAgentStatus();
1067
+ } catch (error) {
1068
+ herdrCommand("pane", "close", paneId);
1069
+ await recordPane(undefined);
1070
+ paneId = undefined;
1071
+ throw error;
1072
+ }
1073
+ })().catch(error => { moving = undefined; throw error; });
1074
+ }
976
1075
  markReadAgentActivity(state, "started", "thinking");
977
1076
  options.renderReadAgentStatus();
978
1077
 
@@ -1262,6 +1361,7 @@ export async function runReadAgentInProcess(
1262
1361
  if (state.heartbeatTimer) clearInterval(state.heartbeatTimer);
1263
1362
  state.heartbeatTimer = undefined;
1264
1363
  closeReadAgentMessageDelivery(state);
1364
+ if (handoffRequested) { state.resolveFinished?.(); return; }
1265
1365
  const teardown = await shutdownTeammate(readTeamName, member, { reason: "quit" });
1266
1366
  if (pendingChildController && pendingChildParent) {
1267
1367
  pendingChildController.forgetParent(pendingChildParent);
@@ -220,10 +220,14 @@ export function registerExtensionEvents(pi: any, options: RegisterEventsOptions)
220
220
  }
221
221
 
222
222
  scheduleTeammateOneShot(() => {
223
- options.quietTrigger("read_inbox to get your instructions, then begin your work.");
223
+ options.quietTrigger(process.env.PI_EXTENDED_TEAMS_HERDR_RESUME === "1"
224
+ ? "Continue your existing assignment. Read any new inbox messages, then finish and report_and_exit from this pane."
225
+ : "read_inbox to get your instructions, then begin your work.");
224
226
  }, 1000);
225
227
 
226
228
  if (teamName) {
229
+ const herdrResume = process.env.PI_EXTENDED_TEAMS_HERDR_RESUME === "1";
230
+ let notifiedInbox: string | undefined;
227
231
  let wakeInFlight = false;
228
232
  const wakeIfUnread = async () => {
229
233
  if (teammateInboxDisposed) return;
@@ -232,7 +236,7 @@ export function registerExtensionEvents(pi: any, options: RegisterEventsOptions)
232
236
  scheduleTeammateInboxWake(250);
233
237
  return;
234
238
  }
235
- if (!ctx.isIdle()) {
239
+ if (!ctx.isIdle() && !herdrResume) {
236
240
  teammatePendingInboxWake = true;
237
241
  scheduleTeammateInboxWake(250);
238
242
  return;
@@ -246,7 +250,15 @@ export function registerExtensionEvents(pi: any, options: RegisterEventsOptions)
246
250
  lastHeartbeatAt: Date.now(),
247
251
  });
248
252
  if (unread.length > 0) {
249
- options.quietTrigger(`You have ${unread.length} new inbox message(s). Read them with read_inbox and act.`);
253
+ const content = `You have ${unread.length} new inbox message(s). Read them with read_inbox and act.`;
254
+ if (herdrResume) {
255
+ const inboxVersion = JSON.stringify(unread);
256
+ if (inboxVersion !== notifiedInbox) {
257
+ pi.sendMessage({ customType: "pi-extended-teams-wake", content, display: false },
258
+ { triggerTurn: true, deliverAs: "steer" });
259
+ notifiedInbox = inboxVersion;
260
+ }
261
+ } else options.quietTrigger(content);
250
262
  }
251
263
  } catch (e) {
252
264
  if (!teammateInboxDisposed) {
@@ -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
  });
@@ -0,0 +1,13 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ export function herdrCommand(...args: string[]): string {
4
+ if (process.env.HERDR_ENV !== "1") throw new Error("This action requires Herdr.");
5
+ const result = spawnSync("herdr", args, { encoding: "utf8", timeout: 5000 });
6
+ if (result.status !== 0) {
7
+ let code: string | undefined;
8
+ try { code = JSON.parse(result.stderr).error?.code; } catch { /* Non-JSON CLI failure. */ }
9
+ if (args[0] === "pane" && args[1] === "close" && code === "pane_not_found") return "";
10
+ throw new Error(result.stderr || result.error?.message || "Herdr command failed.");
11
+ }
12
+ return result.stdout;
13
+ }
@@ -47,6 +47,7 @@ export interface RunningReadAgent extends ManagedReadAgentLifecycleState {
47
47
  /** Fail finalization closed so runtime, member, fence, and transcript remain recoverable. */
48
48
  finalizationBlockedReason?: string;
49
49
  session?: AgentSession;
50
+ moveToHerdr?(): Promise<void>;
50
51
  finished?: Promise<void>;
51
52
  }
52
53
 
@@ -20,7 +20,8 @@ import {
20
20
  type ReadAgentTeardownResult,
21
21
  } from "../agents/read-agent-session-lifecycle";
22
22
  import { releaseAllClaimsForAgent } from "./roster";
23
- import { cleanupPidFileProcess } from "../internal/session-files";
23
+ import { cleanupPidFileProcess, unlinkPidFile } from "../internal/session-files";
24
+ import { herdrCommand } from "../runtime/herdr";
24
25
  import { closePersistedRecipient } from "./recipient-closure";
25
26
  import { cleanupPrivateAgentSessionDirectory } from "../internal/agent-session-files";
26
27
 
@@ -65,7 +66,10 @@ export function createLifecycleRuntime(options: LifecycleRuntimeOptions) {
65
66
  async function finalizeTeammateRuntime(teamName: string, member: Member, expectedRunId: string): Promise<void> {
66
67
  const pidFile = path.join(paths.teamDir(teamName), `${member.name}.pid`);
67
68
  const pidFileExisted = fs.existsSync(pidFile);
68
- const pidCleanup = cleanupPidFileProcess(pidFile, { skipPid: process.pid });
69
+ if (member.herdrPaneId) herdrCommand("pane", "close", member.herdrPaneId);
70
+ const pidCleanup = member.herdrPaneId
71
+ ? { pid: undefined, killError: undefined, unlinked: !pidFileExisted || unlinkPidFile(pidFile) }
72
+ : cleanupPidFileProcess(pidFile, { skipPid: process.pid });
69
73
  if (pidFileExisted && !pidCleanup.unlinked) {
70
74
  throw new Error(`Could not remove pid file for ${member.name}.`);
71
75
  }
@@ -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
  };
@@ -147,6 +147,7 @@ export function registerCoordinationTools(pi: any, options: CoordinationToolsOpt
147
147
  async execute(_toolCallId: string, params: any, _signal: AbortSignal, _onUpdate: any, ctx: any) {
148
148
  const targetTeamName = requireCurrentSession(options);
149
149
  if (!options.isTeammate) throw new Error("report_and_exit is only available to spawned agents.");
150
+ if (process.env.PI_EXTENDED_TEAMS_HERDR_RESUME === "1" && !params.content.trim()) throw new Error("Final report content must not be empty.");
150
151
 
151
152
  const config = await teams.readConfig(targetTeamName);
152
153
  const member = config.members.find(m => m.name === options.agentName);
@@ -271,6 +272,7 @@ export function registerCoordinationTools(pi: any, options: CoordinationToolsOpt
271
272
  async execute(_toolCallId: string, params: any, _signal: AbortSignal, _onUpdate: any, _ctx: any) {
272
273
  const targetTeamName = requireCurrentSession(options);
273
274
  const targetAgent = params.agent_name || options.agentName;
275
+ if (process.env.PI_EXTENDED_TEAMS_HERDR_RESUME === "1" && targetAgent !== options.agentName) throw new Error("A resumed subagent can only read its own inbox.");
274
276
  const markAsRead = params.mark_as_read !== false;
275
277
  const unreadOnly = params.unread_only !== false;
276
278
  const msgs = await messaging.readInbox(targetTeamName, targetAgent, unreadOnly, markAsRead);
@@ -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));