pi-extended-teams 2.1.18 → 2.2.3
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/LICENSE +21 -0
- package/README.md +25 -12
- package/SECURITY.md +19 -0
- package/docs/access.md +35 -0
- package/extensions/agents/read-agent.ts +153 -4
- package/extensions/agents/write-agent.ts +8 -5
- package/extensions/events/register-events.ts +1 -1
- package/extensions/index.ts +46 -4
- package/extensions/runtime/nested-read-agents.ts +1 -1
- package/extensions/runtime/teammate-interrupt.ts +222 -0
- package/extensions/runtime/types.ts +15 -0
- package/extensions/team/team-contracts.json +1 -1
- package/extensions/tools/agent-status-tool.ts +270 -0
- package/extensions/tools/task-runtime-tools.ts +24 -3
- package/extensions/tools/team-tools.ts +76 -16
- package/extensions/ui/agent-follow-view.ts +32 -6
- package/extensions/ui/agent-navigation.ts +2 -0
- package/extensions/ui/renderers.ts +4 -4
- package/extensions/ui/theme.ts +1 -1
- package/package.json +15 -10
- package/skills/teams.md +14 -11
- package/src/adapters/tmux-adapter.ts +11 -0
- package/src/orchestration/index.ts +1 -1
- package/src/utils/runtime.ts +4 -0
- package/src/utils/settings.ts +23 -1
- package/src/utils/terminal-adapter.ts +7 -0
- package/assets/pi-extended-teams-agent-navigation.png +0 -0
- package/assets/pi-extended-teams-in-action.png +0 -0
- package/extensions/agents/read-agent-session-lifecycle.test.ts +0 -605
- package/extensions/agents/read-agent.test.ts +0 -3077
- package/extensions/agents/write-agent.test.ts +0 -513
- package/extensions/events/register-events.test.ts +0 -465
- package/extensions/index.test.ts +0 -1659
- package/extensions/internal/agent-session-files.test.ts +0 -164
- package/extensions/internal/pi-command.test.ts +0 -191
- package/extensions/internal/pi-runtime-api.test.ts +0 -43
- package/extensions/internal/session-context-reference.test.ts +0 -290
- package/extensions/internal/session-files.test.ts +0 -225
- package/extensions/resources/spawn-resource-plan.test.ts +0 -233
- package/extensions/runtime/active-agent-sleep.test.ts +0 -157
- package/extensions/runtime/pending-child-controller.test.ts +0 -169
- package/extensions/team/contracts.test.ts +0 -111
- package/extensions/team/lifecycle.test.ts +0 -1095
- package/extensions/team/recipient-closure.test.ts +0 -97
- package/extensions/team/roster.test.ts +0 -129
- package/extensions/team/writer-screens.test.ts +0 -50
- package/extensions/tools/agent-communication-tools.test.ts +0 -306
- package/extensions/tools/coordination-tools.test.ts +0 -670
- package/extensions/tools/delegation-guard.test.ts +0 -95
- package/extensions/tools/file-claim-tools.test.ts +0 -104
- package/extensions/tools/predefined-tools.test.ts +0 -647
- package/extensions/tools/read-helper.test.ts +0 -35
- package/extensions/tools/task-runtime-tools.test.ts +0 -334
- package/extensions/tools/team-tools.read-agent.test.ts +0 -1924
- package/extensions/ui/agent-follow-view.test.ts +0 -578
- package/extensions/ui/agent-navigation.test.ts +0 -52
- package/extensions/ui/extensions-command.test.ts +0 -336
- package/extensions/ui/favorite-models-command.test.ts +0 -280
- package/extensions/ui/read-agent-status.test.ts +0 -117
- package/extensions/ui/status-widget.test.ts +0 -169
- package/extensions/ui-frame.test.ts +0 -69
- package/src/adapters/tmux-adapter.test.ts +0 -276
- package/src/orchestration/orchestrator.test.ts +0 -396
- package/src/utils/claims.test.ts +0 -137
- package/src/utils/hooks.test.ts +0 -75
- package/src/utils/lifecycle-tombstone.test.ts +0 -105
- package/src/utils/lock.race.child.ts +0 -44
- package/src/utils/lock.race.test.ts +0 -198
- package/src/utils/lock.test.ts +0 -90
- package/src/utils/messaging.test.ts +0 -337
- package/src/utils/model-resolution.test.ts +0 -231
- package/src/utils/models.test.ts +0 -8
- package/src/utils/predefined-teams.save-template.test.ts +0 -74
- package/src/utils/predefined-teams.test.ts +0 -441
- package/src/utils/report-events.test.ts +0 -154
- package/src/utils/runtime.test.ts +0 -314
- package/src/utils/security.test.ts +0 -43
- package/src/utils/settings.test.ts +0 -480
- package/src/utils/shared-memory.test.ts +0 -80
- package/src/utils/tasks.race.test.ts +0 -44
- package/src/utils/tasks.test.ts +0 -229
- package/src/utils/thinking-levels.test.ts +0 -56
- package/src/utils/workflow-metadata.test.ts +0 -11
- package/src/utils/write-queue.test.ts +0 -251
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { TerminalAdapter } from "../../src/utils/terminal-adapter.js";
|
|
2
|
+
import * as teams from "../../src/utils/teams.js";
|
|
3
|
+
import * as runtime from "../../src/utils/runtime.js";
|
|
4
|
+
import type { Member } from "../../src/utils/models.js";
|
|
5
|
+
import type { RunningReadAgent } from "./types.js";
|
|
6
|
+
export const DEFAULT_INTERRUPT_SETTLE_TIMEOUT_MS = 1_500;
|
|
7
|
+
export type TeammateInterruptStatus =
|
|
8
|
+
| "interrupted" | "interrupt_sent" | "no_command" | "not_found" | "pending" | "unsupported" | "failed";
|
|
9
|
+
export interface TeammateInterruptResult {
|
|
10
|
+
status: TeammateInterruptStatus;
|
|
11
|
+
agentName: string;
|
|
12
|
+
message: string;
|
|
13
|
+
agentKind?: "read" | "write";
|
|
14
|
+
lifecycleRunId?: string;
|
|
15
|
+
mechanism?: "agent-session-abort" | "tmux-escape";
|
|
16
|
+
reason?: "no-active-session" | "session-not-created" | "lifecycle-closing"
|
|
17
|
+
| "missing-pane" | "missing-runtime-proof" | "terminal-interrupt-unavailable";
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface TeammateInterrupterOptions {
|
|
21
|
+
terminal: Pick<TerminalAdapter, "interrupt"> | null | undefined;
|
|
22
|
+
runningReadAgents: Map<string, RunningReadAgent>;
|
|
23
|
+
readAgentKey(teamName: string, agentName: string): string;
|
|
24
|
+
getTeamName(): string | null | undefined;
|
|
25
|
+
settleTimeoutMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ResultFields = Omit<TeammateInterruptResult, "status" | "agentName" | "message">;
|
|
29
|
+
type InterruptTeammate = (agentName: string) => Promise<TeammateInterruptResult>;
|
|
30
|
+
|
|
31
|
+
function result(status: TeammateInterruptStatus, agentName: string, message: string, fields: ResultFields = {}): TeammateInterruptResult {
|
|
32
|
+
return { status, agentName, message, ...fields };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function errorText(error: unknown): string {
|
|
36
|
+
return error instanceof Error ? error.message : String(error);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function activeMember(members: Member[], agentName: string): Member | undefined {
|
|
40
|
+
return members.find(member => member.name === agentName && member.name !== "team-lead" && member.isActive !== false);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function settleWithin(operation: Promise<void>, timeoutMs: number): Promise<"settled" | "timed_out" | unknown> {
|
|
44
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
45
|
+
const observed = operation.then<"settled", unknown>(() => "settled", error => error);
|
|
46
|
+
const timedOut = new Promise<"timed_out">((resolve) => {
|
|
47
|
+
timeout = setTimeout(() => resolve("timed_out"), Math.max(0, timeoutMs));
|
|
48
|
+
});
|
|
49
|
+
const outcome = await Promise.race([observed, timedOut]);
|
|
50
|
+
if (timeout) clearTimeout(timeout);
|
|
51
|
+
return outcome;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readLifecycleClosing(state: RunningReadAgent): boolean {
|
|
55
|
+
return state.stopRequested === true
|
|
56
|
+
|| state.messageDeliveryClosed === true
|
|
57
|
+
|| ["stopping", "quarantined", "persistence_failed", "finalized"].includes(state.teardownState || "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function interruptReadAgent(
|
|
61
|
+
state: RunningReadAgent, member: Member, options: TeammateInterrupterOptions,
|
|
62
|
+
): Promise<TeammateInterruptResult> {
|
|
63
|
+
const fields = { agentKind: "read" as const, lifecycleRunId: state.runId };
|
|
64
|
+
if (member.lifecycleRunId !== state.runId || readLifecycleClosing(state)) {
|
|
65
|
+
return result("unsupported", state.name, `Cannot interrupt ${state.name}: its lifecycle is closing or has been replaced.`, {
|
|
66
|
+
...fields, reason: "lifecycle-closing",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (!state.session) {
|
|
70
|
+
return result("unsupported", state.name, `Cannot interrupt ${state.name}: its nested session has not started.`, {
|
|
71
|
+
...fields, reason: "session-not-created",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const generation = state.activeOperationGeneration;
|
|
76
|
+
const toolName = state.activeToolName?.trim();
|
|
77
|
+
if (!generation || !toolName || state.status !== "working" || !state.session.isStreaming) {
|
|
78
|
+
return result("no_command", state.name, `Agent ${state.name} has no running tool command to interrupt.`, fields);
|
|
79
|
+
}
|
|
80
|
+
if (state.operationInterruptPromise) {
|
|
81
|
+
return result("pending", state.name, `An interrupt is already pending for ${state.name}'s running ${toolName} command.`, fields);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const operationSettlement = state.activeOperationSettlementPromise;
|
|
85
|
+
if (!operationSettlement) {
|
|
86
|
+
return result("no_command", state.name, `Agent ${state.name} has no running tool command to interrupt.`, fields);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
state.interruptRequestedGeneration = generation;
|
|
90
|
+
let nativeAbort: Promise<void>;
|
|
91
|
+
try {
|
|
92
|
+
nativeAbort = Promise.resolve(state.session.abort());
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (state.interruptRequestedGeneration === generation) state.interruptRequestedGeneration = undefined;
|
|
95
|
+
const message = errorText(error);
|
|
96
|
+
return result("failed", state.name, `Could not interrupt ${state.name}'s running ${toolName} command: ${message}`, {
|
|
97
|
+
...fields, error: message,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const operation = Promise.allSettled([nativeAbort, operationSettlement]).then((results) => {
|
|
101
|
+
const failedAbort = results[0];
|
|
102
|
+
if (failedAbort.status === "rejected") throw failedAbort.reason;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
state.operationInterruptPromise = operation;
|
|
106
|
+
void operation.then(
|
|
107
|
+
() => { if (state.operationInterruptPromise === operation) state.operationInterruptPromise = undefined; },
|
|
108
|
+
() => {
|
|
109
|
+
if (state.operationInterruptPromise === operation) state.operationInterruptPromise = undefined;
|
|
110
|
+
if (state.interruptRequestedGeneration === generation) state.interruptRequestedGeneration = undefined;
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const outcome = await settleWithin(operation, options.settleTimeoutMs ?? DEFAULT_INTERRUPT_SETTLE_TIMEOUT_MS);
|
|
115
|
+
if (outcome === "timed_out") {
|
|
116
|
+
return result("pending", state.name, `Sent an interrupt to ${state.name}'s running ${toolName} command, but cooperative cancellation is still pending. The agent was not stopped.`, fields);
|
|
117
|
+
}
|
|
118
|
+
if (outcome !== "settled") {
|
|
119
|
+
const message = errorText(outcome);
|
|
120
|
+
return result("failed", state.name, `Could not interrupt ${state.name}'s running ${toolName} command: ${message}`, {
|
|
121
|
+
...fields, error: message,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const sameState = options.runningReadAgents.get(options.readAgentKey(state.teamName, state.name)) === state;
|
|
126
|
+
if (!sameState || state.runId !== fields.lifecycleRunId) {
|
|
127
|
+
const message = "The target lifecycle changed while the interrupt was settling.";
|
|
128
|
+
return result("failed", state.name, `The command interrupt settled, but ${state.name}'s lifecycle changed before it could be confirmed.`, {
|
|
129
|
+
...fields, error: message,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return result("interrupted", state.name, `Interrupted ${state.name}'s running ${toolName} command. The agent is still active and can receive a follow-up message.`, {
|
|
133
|
+
...fields, mechanism: "agent-session-abort",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function interruptWriter(
|
|
138
|
+
teamName: string, member: Member, options: TeammateInterrupterOptions,
|
|
139
|
+
): Promise<TeammateInterruptResult> {
|
|
140
|
+
const fields = { agentKind: "write" as const, lifecycleRunId: member.lifecycleRunId };
|
|
141
|
+
if (!member.lifecycleRunId) {
|
|
142
|
+
return result("unsupported", member.name, `Cannot interrupt ${member.name}: its lifecycle identity is unavailable.`, {
|
|
143
|
+
...fields, reason: "missing-runtime-proof",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (!member.tmuxPaneId) {
|
|
147
|
+
return result("unsupported", member.name, `Cannot interrupt ${member.name}: it has no tmux pane.`, {
|
|
148
|
+
...fields, reason: "missing-pane",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (typeof options.terminal?.interrupt !== "function") {
|
|
152
|
+
return result("unsupported", member.name, `Cannot interrupt ${member.name}: the terminal adapter cannot send Pi's interrupt key.`, {
|
|
153
|
+
...fields, reason: "terminal-interrupt-unavailable",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const [latestConfig, latestRuntime] = await Promise.all([
|
|
158
|
+
teams.readConfig(teamName),
|
|
159
|
+
runtime.readRuntimeStatus(teamName, member.name).catch(() => null),
|
|
160
|
+
]);
|
|
161
|
+
const latestMember = activeMember(latestConfig.members, member.name);
|
|
162
|
+
const heartbeatRecent = !!latestRuntime?.lastHeartbeatAt
|
|
163
|
+
&& Date.now() - latestRuntime.lastHeartbeatAt <= runtime.HEARTBEAT_STALE_MS;
|
|
164
|
+
if (!latestMember
|
|
165
|
+
|| latestMember.lifecycleRunId !== member.lifecycleRunId
|
|
166
|
+
|| latestMember.tmuxPaneId !== member.tmuxPaneId
|
|
167
|
+
|| latestRuntime?.lifecycleRunId !== member.lifecycleRunId
|
|
168
|
+
|| latestRuntime.ready !== true
|
|
169
|
+
|| !heartbeatRecent) {
|
|
170
|
+
return result("unsupported", member.name, `Cannot interrupt ${member.name}: current runtime proof does not match its active lifecycle.`, {
|
|
171
|
+
...fields, reason: "missing-runtime-proof",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const toolName = latestRuntime.activeToolName?.trim();
|
|
176
|
+
if (latestRuntime.currentAction !== "working" || !toolName) {
|
|
177
|
+
return result("no_command", member.name, `Agent ${member.name} has no running tool command to interrupt.`, fields);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
if (!options.terminal.interrupt(member.tmuxPaneId)) {
|
|
182
|
+
const message = "tmux rejected the interrupt key.";
|
|
183
|
+
return result("failed", member.name, `Could not send Pi's Escape interrupt to ${member.name}'s running ${toolName} command. The agent was not stopped.`, {
|
|
184
|
+
...fields, error: message,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const message = errorText(error);
|
|
189
|
+
return result("failed", member.name, `Could not send Pi's Escape interrupt to ${member.name}'s running ${toolName} command: ${message}`, {
|
|
190
|
+
...fields, error: message,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return result("interrupt_sent", member.name, `Sent Pi's Escape interrupt to ${member.name}'s running ${toolName} command. tmux accepted the key; command settlement is not confirmed.`, {
|
|
194
|
+
...fields, mechanism: "tmux-escape",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function createTeammateInterrupter(options: TeammateInterrupterOptions): InterruptTeammate {
|
|
199
|
+
return async (agentNameInput: string): Promise<TeammateInterruptResult> => {
|
|
200
|
+
const agentName = agentNameInput.trim();
|
|
201
|
+
const teamName = options.getTeamName();
|
|
202
|
+
if (!teamName) return result("unsupported", agentName, "No active agent session is available to interrupt.", { reason: "no-active-session" });
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const config = await teams.readConfig(teamName);
|
|
206
|
+
const member = activeMember(config.members, agentName);
|
|
207
|
+
if (!member) return result("not_found", agentName, `Agent ${agentName} is not active in this session.`);
|
|
208
|
+
|
|
209
|
+
const state = options.runningReadAgents.get(options.readAgentKey(teamName, agentName));
|
|
210
|
+
if (state) return interruptReadAgent(state, member, options);
|
|
211
|
+
if (member.role === "write") return interruptWriter(teamName, member, options);
|
|
212
|
+
return result("unsupported", agentName, `Cannot interrupt ${agentName}: no live in-process session is available.`, {
|
|
213
|
+
agentKind: "read",
|
|
214
|
+
lifecycleRunId: member.lifecycleRunId,
|
|
215
|
+
reason: "session-not-created",
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
const message = errorText(error);
|
|
219
|
+
return result("failed", agentName, `Could not interrupt ${agentName}: ${message}`, { error: message });
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -13,6 +13,21 @@ export interface RunningReadAgent extends ManagedReadAgentLifecycleState {
|
|
|
13
13
|
recentEvents: string[];
|
|
14
14
|
lastActivityAt: number;
|
|
15
15
|
activeToolName?: string;
|
|
16
|
+
/** Monotonic identity for the current nested AgentSession turn. */
|
|
17
|
+
operationGeneration?: number;
|
|
18
|
+
/** Present only while the matching AgentSession turn is active. */
|
|
19
|
+
activeOperationGeneration?: number;
|
|
20
|
+
/** Settles only after the exact active AgentSession turn has unwound. */
|
|
21
|
+
activeOperationSettlementPromise?: Promise<void>;
|
|
22
|
+
/** Marks the exact active turn for an expected command interruption. */
|
|
23
|
+
interruptRequestedGeneration?: number;
|
|
24
|
+
/** Coalesces cancellation of the active turn without exposing teardown state. */
|
|
25
|
+
operationInterruptPromise?: Promise<void>;
|
|
26
|
+
/** Latest settled turn observed by the runner while waiting for post-interrupt work. */
|
|
27
|
+
completedOperationGeneration?: number;
|
|
28
|
+
completedOperationInterrupted?: boolean;
|
|
29
|
+
completedOperationError?: unknown;
|
|
30
|
+
operationAwaitingResume?: boolean;
|
|
16
31
|
lastError?: RuntimeError;
|
|
17
32
|
idleNudgeLevel?: "soft" | "hard";
|
|
18
33
|
role?: string;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"id": "ui.agent-follow-navigation",
|
|
16
|
-
"description": "From an empty main editor, Down opens the first active agent in a full-window interactive live-follow view. Down moves to the next agent; Up moves to the previous agent and returns to main only from the first agent; Escape always returns to main. The view exposes transcript, thinking, progress, elapsed time, and tokens; pairs tool calls with their results in readable blocks; collapses large results with head/tail context and an expansion toggle; and provides an input for direct messages to the selected active agent. Pressing x stops the selected subagent
|
|
16
|
+
"description": "From an empty main editor, Down opens the first active agent in a full-window interactive live-follow view. Down moves to the next agent; Up moves to the previous agent and returns to main only from the first agent; Escape always returns to main. The view exposes transcript, thinking, progress, elapsed time, and tokens; pairs tool calls with their results in readable blocks; collapses large results with head/tail context and an expansion toggle; and provides an input for direct messages to the selected active agent. Pressing i interrupts the selected subagent's running tool command while preserving its session and claims. Pressing x stops the selected subagent completely. Both actions keep the live view open."
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"id": "helpers.requested-by-writer",
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import * as teams from "../../src/utils/teams";
|
|
3
|
+
import * as runtime from "../../src/utils/runtime";
|
|
4
|
+
import * as reportEvents from "../../src/utils/report-events";
|
|
5
|
+
import { readLifecycleTombstone, type LifecycleTombstoneReadResult } from "../../src/utils/lifecycle-tombstone";
|
|
6
|
+
import type { Member, TeamReportEvent } from "../../src/utils/models";
|
|
7
|
+
import type { RunningReadAgent } from "../runtime/types";
|
|
8
|
+
import { isWriteMemberAlive } from "../team/roster";
|
|
9
|
+
import { describeReadAgentStatus } from "../ui/read-agent-status";
|
|
10
|
+
import { formatElapsed } from "../ui/renderers";
|
|
11
|
+
|
|
12
|
+
export type AgentStatusPhase =
|
|
13
|
+
| RunningReadAgent["status"]
|
|
14
|
+
| TeamReportEvent["status"]
|
|
15
|
+
| "queued"
|
|
16
|
+
| "stalled"
|
|
17
|
+
| "stopping"
|
|
18
|
+
| "quarantined"
|
|
19
|
+
| "persistence-failed";
|
|
20
|
+
|
|
21
|
+
export interface QueuedAgentStatus {
|
|
22
|
+
name: string;
|
|
23
|
+
role: string;
|
|
24
|
+
queuedAt: number;
|
|
25
|
+
queuePosition: number;
|
|
26
|
+
parentAgentName?: string;
|
|
27
|
+
parentLifecycleRunId?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AgentStatusScope {
|
|
31
|
+
parentName: string;
|
|
32
|
+
parentRunId: string;
|
|
33
|
+
parentStartedAt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AgentStatusSnapshot {
|
|
37
|
+
name: string;
|
|
38
|
+
role: string;
|
|
39
|
+
phase: AgentStatusPhase;
|
|
40
|
+
progress?: string;
|
|
41
|
+
progressAgeMs?: number;
|
|
42
|
+
activeTool?: string;
|
|
43
|
+
activityAgeMs?: number;
|
|
44
|
+
heartbeatAgeMs?: number;
|
|
45
|
+
queuePosition?: number;
|
|
46
|
+
queuedAgeMs?: number;
|
|
47
|
+
completedAgeMs?: number;
|
|
48
|
+
summary?: string;
|
|
49
|
+
error?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AgentStatusToolOptions {
|
|
53
|
+
getTeamName(): string | null | undefined;
|
|
54
|
+
runningReadAgents: Map<string, RunningReadAgent>;
|
|
55
|
+
readAgentKey(teamName: string, agentName: string): string;
|
|
56
|
+
terminal: any;
|
|
57
|
+
listQueuedAgents(teamName: string): QueuedAgentStatus[] | Promise<QueuedAgentStatus[]>;
|
|
58
|
+
scope?: AgentStatusScope;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const AGENT_WAIT_CONTRACT = "Reports arrive automatically as new turns. End this turn to wait. Use get_agent_status once for a current snapshot; do not poll.";
|
|
62
|
+
|
|
63
|
+
function age(now: number, timestamp?: number): number | undefined {
|
|
64
|
+
return timestamp === undefined ? undefined : Math.max(0, now - timestamp);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ownsMember(member: Member, scope?: AgentStatusScope): boolean {
|
|
68
|
+
if (member.name === "team-lead") return false;
|
|
69
|
+
if (!scope) return member.delegationDepth !== 1 && member.helperKind !== "read_helper";
|
|
70
|
+
return member.parentAgentName === scope.parentName
|
|
71
|
+
&& member.parentLifecycleRunId === scope.parentRunId;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ownsQueue(item: QueuedAgentStatus, scope?: AgentStatusScope): boolean {
|
|
75
|
+
if (!scope) return !item.parentAgentName;
|
|
76
|
+
return item.parentAgentName === scope.parentName
|
|
77
|
+
&& item.parentLifecycleRunId === scope.parentRunId;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ownsReport(report: TeamReportEvent, scope?: AgentStatusScope): boolean {
|
|
81
|
+
if (!scope) return !report.requestedBy;
|
|
82
|
+
const parentRunId = report.metadata?.parentLifecycleRunId;
|
|
83
|
+
return report.requestedBy === scope.parentName
|
|
84
|
+
&& report.createdAt >= scope.parentStartedAt
|
|
85
|
+
&& (typeof parentRunId !== "string" || parentRunId === scope.parentRunId);
|
|
86
|
+
}
|
|
87
|
+
|
|
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
|
+
async function activeStatus(
|
|
138
|
+
teamName: string,
|
|
139
|
+
member: Member,
|
|
140
|
+
options: AgentStatusToolOptions,
|
|
141
|
+
now: number,
|
|
142
|
+
): Promise<AgentStatusSnapshot> {
|
|
143
|
+
const candidateState = options.runningReadAgents.get(options.readAgentKey(teamName, member.name));
|
|
144
|
+
const [candidateRuntimeStatus, lifecycleResult] = await Promise.all([
|
|
145
|
+
runtime.readRuntimeStatus(teamName, member.name).catch(() => null),
|
|
146
|
+
readLifecycleTombstone(teamName, member.name).catch(error => ({
|
|
147
|
+
status: "corrupt" as const,
|
|
148
|
+
error: error instanceof Error ? error.message : String(error),
|
|
149
|
+
})),
|
|
150
|
+
]);
|
|
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);
|
|
158
|
+
const progress = state?.latestProgress || runtimeStatus?.latestProgress;
|
|
159
|
+
const progressUpdatedAt = state?.progressUpdatedAt || runtimeStatus?.progressUpdatedAt;
|
|
160
|
+
return {
|
|
161
|
+
name: member.name,
|
|
162
|
+
role: member.role || state?.role || "read",
|
|
163
|
+
phase: phaseForActiveAgent(member, state, runtimeStatus, persistedStatus, now, options.terminal),
|
|
164
|
+
progress,
|
|
165
|
+
progressAgeMs: age(now, progressUpdatedAt),
|
|
166
|
+
activeTool: state?.activeToolName || runtimeStatus?.activeToolName,
|
|
167
|
+
activityAgeMs: age(now, state?.lastActivityAt),
|
|
168
|
+
heartbeatAgeMs: age(now, runtimeStatus?.lastHeartbeatAt),
|
|
169
|
+
error: state?.lastError?.message || persistedStatus?.error || runtimeStatus?.lastError?.message,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function queuedStatus(item: QueuedAgentStatus, now: number): AgentStatusSnapshot {
|
|
174
|
+
return {
|
|
175
|
+
name: item.name,
|
|
176
|
+
role: item.role,
|
|
177
|
+
phase: "queued",
|
|
178
|
+
queuePosition: item.queuePosition,
|
|
179
|
+
queuedAgeMs: age(now, item.queuedAt),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function completedStatus(report: TeamReportEvent, now: number): AgentStatusSnapshot {
|
|
184
|
+
return {
|
|
185
|
+
name: report.agentName,
|
|
186
|
+
role: report.role || "read",
|
|
187
|
+
phase: report.status,
|
|
188
|
+
completedAgeMs: age(now, report.createdAt),
|
|
189
|
+
summary: report.summary,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function formatAge(label: string, value?: number): string | undefined {
|
|
194
|
+
return value === undefined ? undefined : `${label}: ${formatElapsed(value)} ago`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function formatAgentStatusesForModel(statuses: AgentStatusSnapshot[]): string {
|
|
198
|
+
if (statuses.length === 0) return "No active, queued, or recently completed agents in this scope.";
|
|
199
|
+
|
|
200
|
+
const blocks = statuses.map(status => {
|
|
201
|
+
const lines = [`${status.name}: ${status.phase} (${status.role})`];
|
|
202
|
+
if (status.progress) lines.push(` progress: ${status.progress}${status.progressAgeMs === undefined ? "" : ` (${formatElapsed(status.progressAgeMs)} ago)`}`);
|
|
203
|
+
if (status.activeTool) lines.push(` tool: ${status.activeTool}`);
|
|
204
|
+
const activity = formatAge("activity", status.activityAgeMs)
|
|
205
|
+
?? formatAge("heartbeat", status.heartbeatAgeMs);
|
|
206
|
+
if (activity) lines.push(` ${activity}`);
|
|
207
|
+
if (status.queuePosition !== undefined) lines.push(` queue position: ${status.queuePosition}`);
|
|
208
|
+
const queued = formatAge("queued", status.queuedAgeMs);
|
|
209
|
+
if (queued) lines.push(` ${queued}`);
|
|
210
|
+
const completed = formatAge(status.phase === "failed" ? "failed" : "completed", status.completedAgeMs);
|
|
211
|
+
if (completed) lines.push(` ${completed}`);
|
|
212
|
+
if (status.summary) lines.push(` summary: ${status.summary}`);
|
|
213
|
+
if (status.error) lines.push(` error: ${status.error}`);
|
|
214
|
+
return lines.join("\n");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return `${blocks.join("\n\n")}\n\n${AGENT_WAIT_CONTRACT}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function createAgentStatusTool(options: AgentStatusToolOptions): any {
|
|
221
|
+
return {
|
|
222
|
+
name: "get_agent_status",
|
|
223
|
+
label: "Get Agent Status",
|
|
224
|
+
description: "Get one read-only snapshot of one or all agents owned by this parent. Omit agent_name to inspect all. This call is allowed when current status is needed; it never waits, polls, stops, or changes agents. Do not call it repeatedly. Final reports arrive automatically and resume this agent.",
|
|
225
|
+
parameters: Type.Object({
|
|
226
|
+
agent_name: Type.Optional(Type.String({ description: "One owned agent to inspect. Omit to inspect all owned agents." })),
|
|
227
|
+
}),
|
|
228
|
+
async execute(_toolCallId: string, params: { agent_name?: string }) {
|
|
229
|
+
const teamName = options.getTeamName();
|
|
230
|
+
if (!teamName) throw new Error("No active agent session. Spawn an agent first.");
|
|
231
|
+
|
|
232
|
+
const config = await teams.readConfig(teamName);
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
const activeMembers = config.members.filter(member => ownsMember(member, options.scope));
|
|
235
|
+
const rosterNames = new Set(config.members.map(member => member.name));
|
|
236
|
+
const queued = (await options.listQueuedAgents(teamName))
|
|
237
|
+
.filter(item => ownsQueue(item, options.scope) && !rosterNames.has(item.name));
|
|
238
|
+
const reportSince = options.scope?.parentStartedAt ?? config.createdAt;
|
|
239
|
+
const reports = (await reportEvents.listTeamReportEvents(teamName, {
|
|
240
|
+
since: reportSince,
|
|
241
|
+
agentName: params.agent_name,
|
|
242
|
+
}).catch(() => []))
|
|
243
|
+
.filter(report => ownsReport(report, options.scope));
|
|
244
|
+
|
|
245
|
+
const activeStatuses = await Promise.all(activeMembers.map(member => activeStatus(teamName, member, options, now)));
|
|
246
|
+
const queuedStatuses = queued.map(item => queuedStatus(item, now));
|
|
247
|
+
const currentNames = new Set([...activeStatuses, ...queuedStatuses].map(status => status.name));
|
|
248
|
+
const completedByName = new Map<string, TeamReportEvent>();
|
|
249
|
+
for (const report of reports) {
|
|
250
|
+
if (currentNames.has(report.agentName)) continue;
|
|
251
|
+
completedByName.delete(report.agentName);
|
|
252
|
+
completedByName.set(report.agentName, report);
|
|
253
|
+
}
|
|
254
|
+
const completedStatuses = Array.from(completedByName.values()).slice(-20).map(report => completedStatus(report, now));
|
|
255
|
+
const statuses = [...activeStatuses, ...queuedStatuses, ...completedStatuses];
|
|
256
|
+
const selected = params.agent_name
|
|
257
|
+
? statuses.filter(status => status.name === params.agent_name)
|
|
258
|
+
: statuses;
|
|
259
|
+
|
|
260
|
+
if (params.agent_name && selected.length === 0) {
|
|
261
|
+
throw new Error(`Agent ${params.agent_name} has no current status in this scope.`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
content: [{ type: "text", text: formatAgentStatusesForModel(selected) }],
|
|
266
|
+
details: { teamName, statuses: selected },
|
|
267
|
+
};
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
@@ -10,18 +10,39 @@ import type { Member } from "../../src/utils/models";
|
|
|
10
10
|
import type { ReadAgentTeardownResult } from "../agents/read-agent-session-lifecycle";
|
|
11
11
|
import type { ShutdownTeammateOptions } from "../team/lifecycle";
|
|
12
12
|
import { readLifecycleTombstone } from "../../src/utils/lifecycle-tombstone";
|
|
13
|
+
import type { TeammateInterruptResult } from "../runtime/teammate-interrupt";
|
|
13
14
|
|
|
14
15
|
export interface TaskRuntimeToolsOptions {
|
|
15
16
|
isTeammate: boolean;
|
|
16
17
|
terminal: any;
|
|
17
18
|
runningReadAgents: Map<string, RunningReadAgent>;
|
|
18
19
|
readAgentKey(teamName: string, agentName: string): string;
|
|
20
|
+
interruptTeammate?(agentName: string): Promise<TeammateInterruptResult>;
|
|
19
21
|
shutdownTeammate(teamName: string, member: Member, options?: ShutdownTeammateOptions): Promise<ReadAgentTeardownResult>;
|
|
20
22
|
getTeamName(): string | null | undefined;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export function registerTaskRuntimeTools(pi: any, options: TaskRuntimeToolsOptions): void {
|
|
24
26
|
if (!options.isTeammate) {
|
|
27
|
+
if (options.interruptTeammate) {
|
|
28
|
+
pi.registerTool({
|
|
29
|
+
name: "interrupt_teammate",
|
|
30
|
+
label: "Interrupt Agent Command",
|
|
31
|
+
description: "Interrupt one active agent's currently running tool command without stopping the agent, removing it from the roster, or releasing its claims. Cancellation is cooperative for in-process tools, so the result may remain pending. Use this only for a command that is actually stuck; normal agent silence is not a reason to interrupt.",
|
|
32
|
+
parameters: Type.Object({ agent_name: Type.String() }),
|
|
33
|
+
async execute(_toolCallId: string, params: any) {
|
|
34
|
+
const teamName = options.getTeamName();
|
|
35
|
+
if (!teamName) throw new Error("No active agent session. Spawn an agent first.");
|
|
36
|
+
|
|
37
|
+
const result = await options.interruptTeammate!(params.agent_name);
|
|
38
|
+
return {
|
|
39
|
+
content: [{ type: "text", text: result.message }],
|
|
40
|
+
details: { session: teamName, ...result },
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
pi.registerTool({
|
|
26
47
|
name: "stop_teammate",
|
|
27
48
|
label: "Stop Agent",
|
|
@@ -107,8 +128,8 @@ export function registerTaskRuntimeTools(pi: any, options: TaskRuntimeToolsOptio
|
|
|
107
128
|
name: "check_teammate",
|
|
108
129
|
label: "Check Agent",
|
|
109
130
|
description: options.isTeammate
|
|
110
|
-
? "
|
|
111
|
-
: "
|
|
131
|
+
? "Diagnose one agent's lifecycle only after it has been quiet for several minutes or appears unhealthy. This may clean up an agent classified as dead, so do not use it for status polling."
|
|
132
|
+
: "Diagnose one agent's lifecycle only after get_agent_status shows a suspected stall or failure. This may clean up an agent classified as dead. It can also recover the latest persisted report after an agent leaves the roster.",
|
|
112
133
|
parameters: Type.Object({ agent_name: Type.String() }),
|
|
113
134
|
async execute(_toolCallId: string, params: any) {
|
|
114
135
|
const teamName = options.getTeamName();
|
|
@@ -175,7 +196,7 @@ export function registerTaskRuntimeTools(pi: any, options: TaskRuntimeToolsOptio
|
|
|
175
196
|
const unreadCount = (await messaging.readInbox(teamName, params.agent_name, true, false)).length;
|
|
176
197
|
const runtimeStatus = await runtime.readRuntimeStatus(teamName, params.agent_name).catch(() => null);
|
|
177
198
|
const now = Date.now();
|
|
178
|
-
const hasRecentHeartbeat =
|
|
199
|
+
const hasRecentHeartbeat = runtime.isHeartbeatFresh(runtimeStatus, now);
|
|
179
200
|
const runningState = options.runningReadAgents.get(options.readAgentKey(teamName, member.name));
|
|
180
201
|
const lifecycleHealth = runningState?.teardownState === "persistence_failed" ? "persistence-failed"
|
|
181
202
|
: runningState?.teardownState === "quarantined" ? "quarantined"
|