pi-extended-teams 2.4.0 → 2.4.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.
@@ -16,6 +16,7 @@ export interface ReadAgentDeliveryState {
16
16
  acceptingMessages?: boolean;
17
17
  messageDeliveryClosed?: boolean;
18
18
  messageDeliveryTail?: Promise<void>;
19
+ messageAdmissionTail?: Promise<void>;
19
20
  messageDeliveryGeneration?: number;
20
21
  messageDeliveryCancellation?: Promise<void>;
21
22
  cancelMessageDelivery?: () => void;
@@ -163,7 +164,8 @@ export function closeReadAgentMessageDelivery(state: ReadAgentDeliveryState): Re
163
164
  export async function enqueueReadAgentMessageDelivery(
164
165
  state: ReadAgentDeliveryState,
165
166
  agentName: string,
166
- send: () => Promise<void>
167
+ send: () => Promise<void>,
168
+ received?: Promise<void>
167
169
  ): Promise<DeliveryOutcome> {
168
170
  if (!state.acceptingMessages || state.messageDeliveryClosed) {
169
171
  throw new Error(`Cannot send message to ${agentName}: agent is finishing.`);
@@ -175,7 +177,8 @@ export async function enqueueReadAgentMessageDelivery(
175
177
  state.pendingMessageDeliveries = (state.pendingMessageDeliveries ?? 0) + 1;
176
178
 
177
179
  const previousDelivery = state.messageDeliveryTail ?? Promise.resolve();
178
- const rawOutcome = previousDelivery.catch(() => {}).then(async (): Promise<DeliveryOutcome> => {
180
+ const previousAdmission = state.messageAdmissionTail ?? previousDelivery;
181
+ const rawOutcome = previousAdmission.catch(() => {}).then(async (): Promise<DeliveryOutcome> => {
179
182
  if (state.messageDeliveryClosed || state.messageDeliveryGeneration !== generation) {
180
183
  return { status: "cancelled" };
181
184
  }
@@ -192,7 +195,10 @@ export async function enqueueReadAgentMessageDelivery(
192
195
  await rawDelivery;
193
196
  return { status: "delivered" };
194
197
  });
195
- state.messageDeliveryTail = rawOutcome.then(() => {}, () => {});
198
+ const settled = rawOutcome.then(() => {}, () => {});
199
+ // Receipt releases the next send, not ownership of the model run it started.
200
+ state.messageAdmissionTail = received ? Promise.race([received, settled]) : settled;
201
+ state.messageDeliveryTail = Promise.all([previousDelivery, settled]).then(() => {});
196
202
 
197
203
  try {
198
204
  const outcome = await Promise.race([
@@ -20,6 +20,7 @@ import { loadNativeCheckOperations } from "../internal/pi-check-operations";
20
20
  import type { AgentReportSource, CompletedAgentReport, RunningReadAgent } from "../runtime/types";
21
21
  import { extractTextParts, sanitizeTuiLine } from "../ui/renderers";
22
22
  import { createAgentCommunicationTools, formatRepairRequest, type SubmittedAgentReport } from "../tools/agent-communication-tools";
23
+ import { registerSpawnedAgentCommunicationGuard, SPAWNED_AGENT_COMMUNICATION_GUIDANCE, USER_INTERACTION_TOOLS } from "../tools/spawned-agent-policy";
23
24
  import { requireWriteAgentTeam } from "../team/roster";
24
25
  import { isPiPromptPlanningMember, shouldSuppressLeadReportInjection } from "../../src/utils/workflow-metadata";
25
26
  import { canonicalPersistedModelSlot, loadSettings, requireFavoriteModelLevel } from "../../src/utils/settings";
@@ -389,31 +390,33 @@ export async function sendMessageToRunningReadAgent(
389
390
  const pendingInterrupt = agent.operationInterruptPromise;
390
391
  if (pendingInterrupt) await pendingInterrupt.catch(() => {});
391
392
  if (agent.messageDeliveryClosed || agent.stopRequested) throw new ReadAgentDeliveryCancelledError(agent.name);
392
- if (requireReceipt) {
393
- unsubscribe = session.subscribe(event => {
394
- if (event.type !== "message_end" || event.message.role !== "user") return;
395
- const body = event.message.content;
396
- const text = typeof body === "string"
397
- ? body
398
- : body.filter(part => part.type === "text").map(part => part.text).join("\n");
399
- if (text === content) {
400
- received = true;
401
- acknowledge();
402
- }
403
- });
404
- }
393
+ if (agent.completedOperationError) throw agent.completedOperationError;
394
+ unsubscribe = session.subscribe(event => {
395
+ if (event.type !== "message_end" || event.message.role !== "user") return;
396
+ const body = event.message.content;
397
+ const text = typeof body === "string"
398
+ ? body
399
+ : body.filter(part => part.type === "text").map(part => part.text).join("\n");
400
+ if (text === content) {
401
+ received = true;
402
+ acknowledge();
403
+ }
404
+ });
405
405
  if (session.isStreaming) {
406
406
  await session.sendUserMessage(content, { deliverAs: "steer" as const });
407
407
  } else {
408
408
  await runReadAgentSessionOperation(agent, () => session.sendUserMessage(content, undefined));
409
409
  if (requireReceipt && !received) throw new Error(`Requester ${agent.name} finished without receiving the report.`);
410
410
  }
411
- }
411
+ },
412
+ receipt
412
413
  );
413
414
  signalReadAgentWake(agent);
414
415
  pendingParentWakeSignals.get(agent)?.();
415
416
  try {
416
- await deliveryResult;
417
+ // A normal send acknowledges receipt, not completion of the recipient's work.
418
+ // The delivery tail still owns that work for finalization and error handling.
419
+ await (requireReceipt ? deliveryResult : Promise.race([receipt, deliveryResult]));
417
420
  if (requireReceipt && !received) {
418
421
  // Keep the receipt wait outside the raw tail so parent finalization can cancel an unconsumed steer.
419
422
  await Promise.race([receipt, agent.messageDeliveryCancellation!.then(() => {
@@ -1056,6 +1059,7 @@ export async function runReadAgentInProcess(
1056
1059
  settingsManager: childSettingsManager,
1057
1060
  noExtensions: true,
1058
1061
  additionalExtensionPaths: [...resourcePlan.extensionPaths],
1062
+ extensionFactories: [registerSpawnedAgentCommunicationGuard],
1059
1063
  noSkills: false,
1060
1064
  appendSystemPrompt: [
1061
1065
  `You are ${roleLabel} agent '${member.name}' in Pi session '${readTeamName}', running in-process so the lead can follow and control you from Pi.`,
@@ -1065,7 +1069,7 @@ export async function runReadAgentInProcess(
1065
1069
  role === "write"
1066
1070
  ? "Use read/bash/edit/write as needed for the assignment. Prefer precise edits. Stop and report if you need broader product or architecture approval."
1067
1071
  : "Even though the edit/write tools are available, do not use them: do not edit or write files, install or remove packages, start long-running services, commit, push, deploy, or make any other mutating or destructive change. Investigate and report; if a change is needed, recommend it to the lead instead of applying it.",
1068
- "Use send_message for direct communication and read_inbox only when you were told a reply is waiting. Do not coordinate a peer-agent society; the lead controls orchestration.",
1072
+ SPAWNED_AGENT_COMMUNICATION_GUIDANCE,
1069
1073
  "Progress reporting is required, not optional UI polish. Call report_progress before your first work tool with a concise phrase describing what you are starting. Call it again whenever you change phase or evidence source, hit a blocker, or begin synthesis; never make more than 3 work-tool calls without a fresh progress update. Use a new phrase describing what you are doing now. It updates the activity widget without messaging or waking the lead; do not use it as a heartbeat.",
1070
1074
  ...(member.requestedBy
1071
1075
  ? [`You are a depth-1 read helper requested by '${member.requestedBy}'. Your report_and_exit deliverable goes to that requesting writer; the lead receives only a classified completion notice. You cannot delegate.`]
@@ -1138,7 +1142,9 @@ export async function runReadAgentInProcess(
1138
1142
  const communicationToolNameSet = new Set(communicationToolNames);
1139
1143
  const delegationToolNameSet = new Set<string>(NESTED_DELEGATION_TOOL_NAMES);
1140
1144
  const extensionToolNames = loader.getExtensions().extensions.flatMap(extension => {
1141
- return Array.from(extension.tools.keys()).filter(name => !communicationToolNameSet.has(name) && !delegationToolNameSet.has(name));
1145
+ return Array.from(extension.tools.keys()).filter(name =>
1146
+ !communicationToolNameSet.has(name) && !delegationToolNameSet.has(name) && !USER_INTERACTION_TOOLS.includes(name)
1147
+ );
1142
1148
  });
1143
1149
  const nestedReadAgentToolNames = nestedReadAgentTools.map(tool => tool.name);
1144
1150
  const activeToolNames = Array.from(new Set([
@@ -1179,6 +1185,7 @@ export async function runReadAgentInProcess(
1179
1185
  modelRuntime: parentModelRuntime,
1180
1186
  modelRegistry: ctx.modelRegistry,
1181
1187
  tools: activeToolNames,
1188
+ excludeTools: USER_INTERACTION_TOOLS,
1182
1189
  customTools: [...communicationTools, ...nestedReadAgentTools],
1183
1190
  resourceLoader: loader,
1184
1191
  settingsManager: childSettingsManager,
@@ -1418,6 +1425,7 @@ export async function runReadAgentInProcess(
1418
1425
  // its continuation may accept or settle another child wave.
1419
1426
  const deliveryTail = state.messageDeliveryTail;
1420
1427
  if (deliveryTail) await deliveryTail.catch(() => {});
1428
+ if (state.completedOperationError) throw state.completedOperationError;
1421
1429
  }
1422
1430
  } else if (!state.stopRequested && options.isCurrentReadAgentRun(key, state)) {
1423
1431
  completionResolution = resolveCurrentReport();
@@ -15,6 +15,7 @@ import { generateLifecycleRunId } from "../../src/utils/lifecycle-tombstone";
15
15
  import { cleanupStaleSessionContextReferences } from "../internal/session-context-reference";
16
16
  import { cleanupStalePrivateAgentSessions } from "../internal/agent-session-files";
17
17
  import { expireCheckpoints } from "../../src/results/checkpoint-retention";
18
+ import { registerSpawnedAgentCommunicationGuard, SPAWNED_AGENT_COMMUNICATION_GUIDANCE } from "../tools/spawned-agent-policy";
18
19
 
19
20
  export const LEAD_ORCHESTRATION_GUIDANCE = `\n\npi-extended-teams lead orchestration rules:\n- Choose tiers by the agent's intended outcome, not by vague task importance. read-review is the normal default for focused review, verification, and bounded synthesis.\n- Use read-collect when the lane gathers bounded facts without owning the conclusion. Use read-analyze when it must explain behavior or root cause across connected evidence. Reserve read-critical for irreducible high-stakes security, architecture, concurrency, migration, or data-correctness reasoning.\n- For edits, use write-patch for a narrow localized change, write-feature for a bounded feature with a known design, write-system for a cross-cutting integration/refactor within explicitly claimed files, and write-critical only for high-risk security, concurrency, recovery, migration, or data-integrity changes.\n- Prefer the canonical read-*/write-* tiers. Legacy reading-*/writing-* names are compatibility aliases for this minor release, not intent guidance.\n- A spawned agent owns its assigned lane until it reports, blocks, fails, or the user cancels it. Do not duplicate, take over, test, edit, or synthesize that same lane in parallel; work only on clearly unrelated lanes.\n- When no unrelated work remains, end the turn. The extension resumes you when a report arrives. One get_agent_status snapshot is allowed when current status is needed; do not repeatedly call it, sleep, busy-wait, loop on inbox/status, send nudges, do dummy work, or treat healthy silence as failure.\n- Wait for the actual report before synthesizing. Intervene only on a reported blocker/error, actual health failure, or explicit user cancellation/change.\n- For durable bug, security, or testing claims from an agent report or backlog, concrete, reproducible findings with file/line evidence or a focused failing regression may proceed directly to TDD repair.
20
21
  - Use a separate read-only confirmation only when evidence is missing or weak, the claim is disputed, or irreducible high-risk uncertainty remains; never reconfirm an already confirmed finding.`;
@@ -66,6 +67,7 @@ export function registerAgentReportRenderer(pi: any): void {
66
67
  }
67
68
 
68
69
  export function registerExtensionEvents(pi: any, options: RegisterEventsOptions): void {
70
+ if (options.isTeammate) registerSpawnedAgentCommunicationGuard(pi);
69
71
  let teammateWakeIfUnread: (() => Promise<void>) | null = null;
70
72
  let teammatePendingInboxWake = false;
71
73
  let teammateInboxWakeTimer: NodeJS.Timeout | null = null;
@@ -387,6 +389,7 @@ export function registerExtensionEvents(pi: any, options: RegisterEventsOptions)
387
389
  if (!options.isTeammate) {
388
390
  return { systemPrompt: event.systemPrompt + LEAD_ORCHESTRATION_GUIDANCE };
389
391
  }
392
+ const systemPrompt = `${event.systemPrompt}\n\n${SPAWNED_AGENT_COMMUNICATION_GUIDANCE}`;
390
393
  if (firstTurn) {
391
394
  firstTurn = false;
392
395
 
@@ -430,8 +433,9 @@ export function registerExtensionEvents(pi: any, options: RegisterEventsOptions)
430
433
  }
431
434
 
432
435
  return {
433
- systemPrompt: event.systemPrompt + `\n\nYou are spawned agent '${options.agentName}' in Pi session '${teamName}'.\nYour lead is 'team-lead'.${modelInfo}\n\nCore rules for every spawned agent:\n- NEVER sleep, busy-wait, or poll. Do not use bash sleep, while-true, or any wait/poll loop. The extension wakes you when messages arrive.\n- You cannot spawn, promote, or create other agents. If another agent is needed, use send_message to ask team-lead to decide and spawn.\n- Use send_message for direct communication and read_inbox when the extension wakes you or you expect a reply.\n- Progress reporting is required, not optional UI polish. After reading your initial instructions, call report_progress before your first work tool with a concise phrase describing what you are starting. Call it again whenever you change phase or evidence source, hit a blocker, or begin synthesis; never make more than 3 work-tool calls without a fresh progress update. Use a new phrase describing what you are doing now. It updates the activity widget without messaging or waking the lead; do not use it as a heartbeat.\n- When your work is done, report and exit cleanly. Do not wait for the lead to shut you down.${roleSpecificGuidance}${rosterInfo}\nStart by calling read_inbox to get your initial instructions.`,
436
+ systemPrompt: systemPrompt + `\n\nYou are spawned agent '${options.agentName}' in Pi session '${teamName}'.\nYour lead is 'team-lead'.${modelInfo}\n\nCore rules for every spawned agent:\n- NEVER sleep, busy-wait, or poll. Do not use bash sleep, while-true, or any wait/poll loop. The extension wakes you when messages arrive.\n- You cannot spawn, promote, or create other agents. If another agent is needed, use send_message to ask team-lead to decide and spawn.\n- Use send_message for direct communication and read_inbox when the extension wakes you or you expect a reply.\n- Progress reporting is required, not optional UI polish. After reading your initial instructions, call report_progress before your first work tool with a concise phrase describing what you are starting. Call it again whenever you change phase or evidence source, hit a blocker, or begin synthesis; never make more than 3 work-tool calls without a fresh progress update. Use a new phrase describing what you are doing now. It updates the activity widget without messaging or waking the lead; do not use it as a heartbeat.\n- When your work is done, report and exit cleanly. Do not wait for the lead to shut you down.${roleSpecificGuidance}${rosterInfo}\nStart by calling read_inbox to get your initial instructions.`,
434
437
  };
435
438
  }
439
+ return { systemPrompt };
436
440
  });
437
441
  }
@@ -0,0 +1 @@
1
+ You are a spawned agent. Communicate only with team-lead through send_message. Never ask or address the user directly, open a user-question dialog, or use Orb to contact them. If you need a decision or permission, send the question and relevant context to team-lead. Do not treat a tool error or timeout as approval. Continue independent assigned work if possible. If you cannot continue without an answer, use report_and_exit with outcome blocked and include your questions; do not claim success or poll for a reply. Use report_and_exit for your final deliverable; nested helper reports still go to the requesting writer.
@@ -1,4 +1,5 @@
1
1
  import { Type } from "@sinclair/typebox";
2
+ import { requireLeadRecipient } from "./spawned-agent-policy";
2
3
  import * as messaging from "../../src/utils/messaging";
3
4
  import * as runtime from "../../src/utils/runtime";
4
5
  import * as claims from "../../src/utils/claims";
@@ -104,7 +105,7 @@ export function createAgentCommunicationTools(options: AgentCommunicationToolsOp
104
105
  {
105
106
  name: "send_message",
106
107
  label: "Send Message",
107
- description: "Send a direct message in the current Pi session. Spawned agents default to messaging the lead.",
108
+ description: "Send a direct message in the current Pi session. Spawned agents may message only team-lead.",
108
109
  parameters: Type.Object({
109
110
  recipient: Type.Optional(Type.String({ description: "Recipient agent name. Defaults to team-lead for spawned agents." })),
110
111
  content: Type.String(),
@@ -114,6 +115,7 @@ export function createAgentCommunicationTools(options: AgentCommunicationToolsOp
114
115
  const teamName = requireCurrentSession(options);
115
116
  const recipient = params.recipient || (options.isTeammate ? "team-lead" : undefined);
116
117
  if (!recipient) throw new Error("recipient is required when the lead sends a message.");
118
+ if (options.isTeammate) requireLeadRecipient(recipient);
117
119
  await messaging.sendPlainMessageIfRunning(teamName, options.agentName, recipient, params.content, params.summary || "Message");
118
120
  return { content: [{ type: "text", text: `Message sent to ${recipient}.` }], details: { session: teamName, recipient } };
119
121
  },
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { Type } from "@sinclair/typebox";
4
+ import { requireLeadRecipient } from "./spawned-agent-policy";
4
5
  import * as paths from "../../src/utils/paths";
5
6
  import * as teams from "../../src/utils/teams";
6
7
  import * as messaging from "../../src/utils/messaging";
@@ -309,7 +310,7 @@ export function registerCoordinationTools(pi: any, options: CoordinationToolsOpt
309
310
  pi.registerTool({
310
311
  name: "send_message",
311
312
  label: "Send Message",
312
- description: "Send a direct message in the current Pi session. Spawned agents default to messaging the lead.",
313
+ description: "Send a direct message in the current Pi session. Spawned agents may message only team-lead.",
313
314
  parameters: Type.Object({
314
315
  recipient: Type.Optional(Type.String({ description: "Recipient agent name. Defaults to team-lead for spawned agents." })),
315
316
  content: Type.String(),
@@ -319,6 +320,7 @@ export function registerCoordinationTools(pi: any, options: CoordinationToolsOpt
319
320
  const targetTeamName = requireCurrentSession(options);
320
321
  const recipient = params.recipient || (options.isTeammate ? "team-lead" : undefined);
321
322
  if (!recipient) throw new Error("recipient is required when the lead sends a message.");
323
+ if (options.isTeammate) requireLeadRecipient(recipient);
322
324
  const deliveredDirectly = await options.deliverMessageToActiveAgent?.(targetTeamName, recipient, params.content) === true;
323
325
  if (!deliveredDirectly) {
324
326
  await messaging.sendPlainMessageIfRunning(targetTeamName, options.agentName, recipient, params.content, params.summary || "Message");
@@ -0,0 +1,22 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+
5
+ export const USER_INTERACTION_TOOLS = ["ask_user", "ask_user_batch", "orb_ask", "orb_say"];
6
+ export const SPAWNED_AGENT_COMMUNICATION_GUIDANCE = readFileSync(
7
+ path.join(__dirname, "../prompts/spawned-agent-communication.md"), "utf8"
8
+ ).trim();
9
+
10
+ export function requireLeadRecipient(recipient: string): void {
11
+ if (recipient !== "team-lead") {
12
+ throw new Error("Spawned agents may send messages only to team-lead.");
13
+ }
14
+ }
15
+
16
+ export function registerSpawnedAgentCommunicationGuard(pi: Pick<ExtensionAPI, "on">): void {
17
+ pi.on("tool_call", event => {
18
+ if (USER_INTERACTION_TOOLS.includes(event.toolName)) {
19
+ return { block: true, reason: "Spawned agents cannot contact the user. Send your question to team-lead using send_message." };
20
+ }
21
+ });
22
+ }
@@ -8,9 +8,6 @@ import { createActivityColors } from "./activity-colors";
8
8
 
9
9
  const REFRESH_INTERVAL_MS = 250;
10
10
  const MAX_NAVIGATION_AGENTS = 6;
11
- const COLLAPSED_TOOL_RESULT_LINE_LIMIT = 14;
12
- const COLLAPSED_TOOL_RESULT_HEAD_LINES = 8;
13
- const COLLAPSED_TOOL_RESULT_TAIL_LINES = 3;
14
11
 
15
12
  export interface AgentFollowViewOptions {
16
13
  getAgents(): RunningReadAgent[];
@@ -227,7 +224,7 @@ function renderToolBlock(theme: ExtendedTeamsTheme, block: Extract<TranscriptBlo
227
224
  if (compactBlock) return compactBlock;
228
225
 
229
226
  const header = renderToolHeader(theme, block);
230
- if (!expandLargeToolResults && (block.name === "read" || block.name === "bash" || block.name === "ls")) {
227
+ if (!expandLargeToolResults) {
231
228
  const state = block.result === undefined
232
229
  ? pendingText(theme, "working")
233
230
  : block.isError ? failureText(theme, "✗") : successText(theme, "✓");
@@ -241,22 +238,12 @@ function renderToolBlock(theme: ExtendedTeamsTheme, block: Extract<TranscriptBlo
241
238
 
242
239
  const result = block.result || "(no output)";
243
240
  const resultLines = result.split("\n");
244
- const isCollapsed = !expandLargeToolResults && resultLines.length > COLLAPSED_TOOL_RESULT_LINE_LIMIT;
245
- const visibleLines = isCollapsed
246
- ? [
247
- ...resultLines.slice(0, COLLAPSED_TOOL_RESULT_HEAD_LINES),
248
- `… ${resultLines.length - COLLAPSED_TOOL_RESULT_HEAD_LINES - COLLAPSED_TOOL_RESULT_TAIL_LINES} lines hidden · press l to expand logs`,
249
- ...resultLines.slice(-COLLAPSED_TOOL_RESULT_TAIL_LINES),
250
- ]
251
- : resultLines;
252
241
  const resultLineWidth = width === undefined ? undefined : Math.max(1, width - visibleWidth("│ "));
253
- const boundedLines = visibleLines.map((line) => resultLineWidth === undefined
242
+ const boundedLines = resultLines.map((line) => resultLineWidth === undefined
254
243
  ? line
255
244
  : truncateToWidth(line, resultLineWidth, "…"));
256
- const body = boundedLines.map((line, index) => isCollapsed && index === COLLAPSED_TOOL_RESULT_HEAD_LINES
257
- ? `${structuralText(theme, "│")} ${mutedText(theme, line)}`
258
- : `${structuralText(theme, "│")} ${line}`);
259
- const summary = `${resultLines.length} line${resultLines.length === 1 ? "" : "s"} · ${formatResultSize(result)}${isCollapsed ? " · collapsed" : ""}`;
245
+ const body = boundedLines.map((line) => `${structuralText(theme, "│")} ${line}`);
246
+ const summary = `${resultLines.length} line${resultLines.length === 1 ? "" : "s"} · ${formatResultSize(result)}`;
260
247
  const renderedSummary = block.isError ? failureText(theme, summary) : successText(theme, summary);
261
248
  return [header, ...body, `${structuralText(theme, "╰─")} ${renderedSummary}`, ""];
262
249
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extended-teams",
3
- "version": "2.4.0",
3
+ "version": "2.4.3",
4
4
  "description": "Control-first, session-connected subagents for Pi with live navigation and intent-tier routing",
5
5
  "repository": {
6
6
  "type": "git",