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 +6 -2
- package/docs/access.md +2 -2
- package/extensions/agents/read-agent.ts +103 -3
- package/extensions/events/register-events.ts +15 -3
- package/extensions/index.ts +1 -0
- package/extensions/runtime/herdr.ts +13 -0
- package/extensions/runtime/types.ts +1 -0
- package/extensions/team/lifecycle.ts +6 -2
- package/extensions/tools/agent-status-tool.ts +15 -69
- package/extensions/tools/coordination-tools.ts +2 -0
- package/extensions/tools/task-runtime-tools.ts +7 -0
- package/extensions/tools/team-tools.ts +141 -152
- package/extensions/ui/agent-follow-view.ts +12 -3
- package/extensions/ui/read-agent-status.ts +2 -1
- package/package.json +2 -2
- package/skills/teams.md +2 -2
- package/src/orchestration/index.ts +21 -33
- package/src/orchestration/status-projection.ts +89 -0
- package/src/orchestration/types.ts +4 -1
- package/src/utils/models.ts +2 -1
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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) {
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
152
|
-
|
|
153
|
-
:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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:
|
|
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:
|
|
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));
|