pi-extended-teams 2.3.2 → 2.4.0

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.
@@ -122,7 +122,7 @@ export function createAgentCommunicationTools(options: AgentCommunicationToolsOp
122
122
  {
123
123
  name: "read_inbox",
124
124
  label: "Read Inbox",
125
- description: "Read this agent's inbox in the current Pi session.",
125
+ description: "Read this agent's inbox with current sender message-admission status. Status is a snapshot, not proof of process liveness; do not send to unavailable senders.",
126
126
  parameters: Type.Object({
127
127
  unread_only: Type.Optional(Type.Boolean({ default: true })),
128
128
  mark_as_read: Type.Optional(Type.Boolean({ default: true, description: "Set false to peek without marking messages read." })),
@@ -131,7 +131,7 @@ export function createAgentCommunicationTools(options: AgentCommunicationToolsOp
131
131
  const teamName = requireCurrentSession(options);
132
132
  const markAsRead = params.mark_as_read !== false;
133
133
  const unreadOnly = params.unread_only !== false;
134
- const msgs = await messaging.readInbox(teamName, options.agentName, unreadOnly, markAsRead);
134
+ const msgs = await messaging.readInboxWithSenderStatus(teamName, options.agentName, unreadOnly, markAsRead);
135
135
  const lifecycleRunId = options.getLifecycleRunId();
136
136
  // Do not fail after read flags are persisted; telemetry is best-effort.
137
137
  if (markAsRead && lifecycleRunId) {
@@ -333,7 +333,7 @@ export function registerCoordinationTools(pi: any, options: CoordinationToolsOpt
333
333
  pi.registerTool({
334
334
  name: "read_inbox",
335
335
  label: "Read Inbox",
336
- description: "Read messages from the current Pi session inbox. Defaults to this agent's inbox.",
336
+ description: "Read messages from the current Pi session inbox with current sender message-admission status. Defaults to your inbox. Status is a snapshot, not proof of process liveness; do not send to unavailable senders.",
337
337
  parameters: Type.Object({
338
338
  agent_name: Type.Optional(Type.String({ description: "Whose inbox to read. Defaults to your own." })),
339
339
  unread_only: Type.Optional(Type.Boolean({ default: true })),
@@ -345,7 +345,7 @@ export function registerCoordinationTools(pi: any, options: CoordinationToolsOpt
345
345
  if (process.env.PI_EXTENDED_TEAMS_HERDR_RESUME === "1" && targetAgent !== options.agentName) throw new Error("A resumed subagent can only read its own inbox.");
346
346
  const markAsRead = params.mark_as_read !== false;
347
347
  const unreadOnly = params.unread_only !== false;
348
- const msgs = await messaging.readInbox(targetTeamName, targetAgent, unreadOnly, markAsRead);
348
+ const msgs = await messaging.readInboxWithSenderStatus(targetTeamName, targetAgent, unreadOnly, markAsRead);
349
349
 
350
350
  if (markAsRead && options.isTeammate && targetAgent === options.agentName) {
351
351
  const config = await teams.readConfig(targetTeamName).catch(() => null);
@@ -28,7 +28,7 @@ export interface AgentFollowTranscriptOptions {
28
28
 
29
29
  type TranscriptBlock =
30
30
  | { kind: "section"; label: "user" | "thinking" | "assistant"; text: string }
31
- | { kind: "tool"; id?: string; name: string; args: unknown; result?: string; details?: unknown; isError?: boolean };
31
+ | { kind: "tool"; id?: string; name: string; args: unknown; result?: string; readSummary?: string; details?: unknown; isError?: boolean };
32
32
 
33
33
  function stringifyToolArgs(args: unknown): string {
34
34
  if (args === undefined) return "";
@@ -42,6 +42,7 @@ function stringifyToolArgs(args: unknown): string {
42
42
  function compactToolArgs(name: string, args: unknown): string {
43
43
  if (!args || typeof args !== "object") return sanitizeTuiLine(stringifyToolArgs(args));
44
44
  const values = args as Record<string, unknown>;
45
+ if (name === "ls") return compactTranscriptLine(String(values.path ?? "."));
45
46
  const primary = name === "bash"
46
47
  ? values.command
47
48
  : name === "read"
@@ -226,6 +227,14 @@ function renderToolBlock(theme: ExtendedTeamsTheme, block: Extract<TranscriptBlo
226
227
  if (compactBlock) return compactBlock;
227
228
 
228
229
  const header = renderToolHeader(theme, block);
230
+ if (!expandLargeToolResults && (block.name === "read" || block.name === "bash" || block.name === "ls")) {
231
+ const state = block.result === undefined
232
+ ? pendingText(theme, "working")
233
+ : block.isError ? failureText(theme, "✗") : successText(theme, "✓");
234
+ const summary = block.name === "read" && !block.isError && block.readSummary
235
+ ? mutedText(theme, ` · ${block.readSummary}`) : "";
236
+ return [boundTranscriptLine(`${header}${summary}${mutedText(theme, " · ")}${state}`, width)];
237
+ }
229
238
  if (block.result === undefined) {
230
239
  return [header, `${structuralText(theme, "│")} ${pendingText(theme, "waiting for result…")}`, `${structuralText(theme, "╰─")} ${pendingText(theme, "running")}`, ""];
231
240
  }
@@ -290,28 +299,40 @@ export function formatAgentFollowTranscript(messages: any[], options: AgentFollo
290
299
  const name = sanitizeTuiLine(String(message.toolName || "tool"));
291
300
  const matchingTool = (id ? toolsById.get(id) : undefined)
292
301
  ?? blocks.slice().reverse().find((block): block is Extract<TranscriptBlock, { kind: "tool" }> => block.kind === "tool" && block.result === undefined && block.name === name);
302
+ let readSummary: string | undefined;
303
+ if ((matchingTool?.name ?? name) === "read") {
304
+ const text = typeof message.content === "string" ? message.content
305
+ : (Array.isArray(message.content) ? message.content : [])
306
+ .filter((part: any) => part?.type === "text" && typeof part.text === "string")
307
+ .map((part: any) => part.text).join("\n");
308
+ const lineCount = text ? text.replace(/\r\n?/g, "\n").replace(/\n$/, "").split("\n").length : 0;
309
+ readSummary = `${lineCount} line${lineCount === 1 ? "" : "s"} · ${formatResultSize(text)}`;
310
+ }
293
311
  const result = sanitizeTuiText(extractTextParts(message.content));
294
312
  const isError = typeof message.isError === "boolean" ? message.isError : undefined;
295
313
  if (matchingTool) {
296
314
  matchingTool.result = result;
315
+ matchingTool.readSummary = readSummary;
297
316
  matchingTool.details = message.details;
298
317
  matchingTool.isError = isError;
299
318
  } else {
300
- blocks.push({ kind: "tool", id, name, args: undefined, result, details: message.details, isError });
319
+ blocks.push({ kind: "tool", id, name, args: undefined, result, readSummary, details: message.details, isError });
301
320
  }
302
321
  }
303
322
  }
304
323
 
305
- const lines = blocks.flatMap(block => {
324
+ const lines: string[] = [];
325
+ for (const block of blocks) {
306
326
  if (block.kind === "tool") {
307
- return renderToolBlock(theme, block, options.expandLargeToolResults === true, options.width);
327
+ lines.push(...renderToolBlock(theme, block, options.expandLargeToolResults === true, options.width));
328
+ } else if (block.label === "thinking") {
329
+ if (lines.length > 0 && lines[lines.length - 1] !== "") lines.push("");
330
+ lines.push(theme.fg("thinkingText", block.label), block.text.replace(/\*\*/g, ""), "");
331
+ } else {
332
+ const labelToken = block.label === "user" ? "customMessageLabel" : "accent";
333
+ lines.push(theme.fg(labelToken, block.label), block.text, "");
308
334
  }
309
- if (block.label === "thinking") {
310
- return [theme.fg("thinkingText", block.label), block.text.replace(/\*\*/g, ""), ""];
311
- }
312
- const labelToken = block.label === "user" ? "customMessageLabel" : "accent";
313
- return [theme.fg(labelToken, block.label), block.text, ""];
314
- });
335
+ }
315
336
  return lines.length > 0 ? lines : [theme.fg("dim", "Waiting for the agent's first transcript event…")];
316
337
  }
317
338
 
@@ -104,7 +104,11 @@ export function summarizeInboxMessage(message: any): string {
104
104
  const from = message?.from || "unknown";
105
105
  const summary = message?.summary || "message";
106
106
  const timestamp = message?.timestamp ? new Date(message.timestamp).toLocaleTimeString() : "";
107
- return `${from}${timestamp ? ` ${timestamp}` : ""}: ${summary}`;
107
+ const sender = message?.senderStatus;
108
+ const availability = sender
109
+ ? ` [sender now: ${sender.status}; can message: ${sender.canMessage === null ? "unknown" : sender.canMessage ? "yes" : "no"}]`
110
+ : "";
111
+ return `${from}${availability}${timestamp ? ` ${timestamp}` : ""}: ${summary}`;
108
112
  }
109
113
 
110
114
  export function formatInboxMessagesForModel(messages: any[]): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extended-teams",
3
- "version": "2.3.2",
3
+ "version": "2.4.0",
4
4
  "description": "Control-first, session-connected subagents for Pi with live navigation and intent-tier routing",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,7 +28,7 @@
28
28
  "scripts": {
29
29
  "typecheck": "tsc --noEmit",
30
30
  "test": "vitest run",
31
- "test:focused": "vitest run src/utils/claims.test.ts src/utils/tasks.test.ts src/utils/model-resolution.test.ts src/utils/settings.test.ts src/utils/write-queue.test.ts src/utils/shared-memory.test.ts extensions/index.test.ts extensions/events/register-events.test.ts extensions/internal/session-files.test.ts extensions/team/contracts.test.ts extensions/tools/delegation-guard.test.ts extensions/tools/agent-communication-tools.test.ts extensions/tools/read-helper.test.ts extensions/agents/read-agent.test.ts extensions/agents/write-agent.test.ts extensions/tools/team-tools.read-agent.test.ts extensions/ui/agent-follow-view.test.ts extensions/tools/coordination-tools.test.ts",
31
+ "test:focused": "vitest run --maxWorkers=2 src/utils/claims.test.ts src/utils/tasks.test.ts src/utils/model-resolution.test.ts src/utils/settings.test.ts src/utils/write-queue.test.ts src/utils/shared-memory.test.ts extensions/index.test.ts extensions/events/register-events.test.ts extensions/internal/session-files.test.ts extensions/team/contracts.test.ts extensions/tools/delegation-guard.test.ts extensions/tools/agent-communication-tools.test.ts extensions/tools/read-helper.test.ts extensions/agents/read-agent.test.ts extensions/agents/write-agent.test.ts extensions/tools/team-tools.read-agent.test.ts extensions/ui/agent-follow-view.test.ts extensions/tools/coordination-tools.test.ts",
32
32
  "build": "tsc --noEmit"
33
33
  },
34
34
  "main": "extensions/index.ts",
@@ -7,7 +7,12 @@ import { InboxMessage, TeamConfig } from "./models";
7
7
  import { withLock } from "./lock";
8
8
  import { configPath, inboxPath } from "./paths";
9
9
  import { readConfig } from "./teams";
10
- import { assertLifecycleTombstoneAbsent, withLifecycleTombstoneLock } from "./lifecycle-tombstone";
10
+ import {
11
+ assertLifecycleTombstoneAbsent,
12
+ readLifecycleTombstoneSnapshot,
13
+ withLifecycleTombstoneLock,
14
+ type LifecycleTombstonePhase,
15
+ } from "./lifecycle-tombstone";
11
16
 
12
17
  export interface MessageMetadataOptions {
13
18
  id?: string;
@@ -272,6 +277,57 @@ export async function readInbox(
272
277
  });
273
278
  }
274
279
 
280
+ export interface InboxSenderStatus {
281
+ status: "lead" | "active" | "not_running" | "quarantined" | "unknown" | LifecycleTombstonePhase;
282
+ canMessage: boolean | null;
283
+ }
284
+
285
+ export async function readInboxWithSenderStatus(
286
+ teamName: string,
287
+ agentName: string,
288
+ unreadOnly = false,
289
+ markAsRead = true
290
+ ): Promise<Array<InboxMessage & { senderStatus: InboxSenderStatus }>> {
291
+ const messages = await readInbox(teamName, agentName, unreadOnly, markAsRead);
292
+ if (messages.length === 0) return [];
293
+
294
+ // Advisory admission snapshot, not process liveness or a reservation to send.
295
+ // Enrich only returned copies: persisted inbox history must not cache live status.
296
+ let members: TeamConfig["members"] | undefined;
297
+ try {
298
+ const config = JSON.parse(fs.readFileSync(configPath(teamName), "utf-8")) as TeamConfig;
299
+ if (Array.isArray(config.members)) members = config.members;
300
+ } catch (error) {
301
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") members = [];
302
+ }
303
+
304
+ const senders = new Map<string, InboxSenderStatus>();
305
+ for (const sender of new Set(messages.map(message => message.from))) {
306
+ let senderStatus: InboxSenderStatus = { status: "unknown", canMessage: null };
307
+ try {
308
+ if (sender === "team-lead") {
309
+ senderStatus = { status: "lead", canMessage: true };
310
+ } else {
311
+ const fence = readLifecycleTombstoneSnapshot(teamName, sender);
312
+ if (fence.status !== "absent") {
313
+ senderStatus = {
314
+ status: fence.status === "corrupt" ? "quarantined" : fence.tombstone.phase,
315
+ canMessage: false,
316
+ };
317
+ } else if (members) {
318
+ const member = members.find(member => member.name === sender);
319
+ const canMessage = !!member && member.isActive !== false;
320
+ senderStatus = { status: canMessage ? "active" : "not_running", canMessage };
321
+ }
322
+ }
323
+ } catch {
324
+ // Status lookup must not discard messages after read flags were saved.
325
+ }
326
+ senders.set(sender, senderStatus);
327
+ }
328
+ return messages.map(message => ({ ...message, senderStatus: senders.get(message.from)! }));
329
+ }
330
+
275
331
  export async function markInboxMessagesRead(teamName: string, agentName: string, ids: string[]): Promise<void> {
276
332
  const p = inboxPath(teamName, agentName);
277
333
  if (!fs.existsSync(p)) throw new Error("Referenced inbox is unavailable.");