@fradser/pi-agent-teams 0.9.2 → 1.0.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.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Publish this package as the `agent` tool's coordinator.
3
+ *
4
+ * `@fradser/pi-subagents` owns the child process and knows nothing about teams. It
5
+ * reaches a team spawn only through this seam, and without a host published it
6
+ * takes its standalone path and delivers a Session Result itself. A hosted child
7
+ * instead uses the team's attempt-bound report pipeline; exactly one path owns
8
+ * each result.
9
+ *
10
+ * Publishing also moves spawn to the team path as a side effect, and that is
11
+ * intended: the team path is the one that assigns work, records the attempt, and
12
+ * routes the result. The difference is observable — the team path refuses a name
13
+ * with no role, because a resident is a role with a process attached, while the
14
+ * standalone path will synthesise one — and `features/` pins it.
15
+ */
16
+
17
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import { setAgentHost, snapshotWorkContext, type AgentStartRequest } from "@fradser/pi-subagents";
19
+ import { authorizeResidentInput, interruptResidentSequence, sendResidentSequence, shutdownAgentExact, spawnAgent } from "./team-machine.ts";
20
+
21
+ /** Wire `agent` to this package's spawn and shutdown. Safe to call more than
22
+ * once: a reload republishes rather than stacking hosts. */
23
+ export function publishAgentHost(sessionManager?: ExtensionContext["sessionManager"]): void {
24
+ setAgentHost({
25
+ async start(request: AgentStartRequest) {
26
+ const spawned = spawnAgent({
27
+ name: request.name,
28
+ role: request.name,
29
+ ...(request.model ? { model: request.model } : {}),
30
+ ...(request.prompt ? { prompt: request.prompt } : {}),
31
+ ...(request.tools !== undefined ? { tools: request.tools } : {}),
32
+ // Resolve disk roles against the machine's project cwd, not this
33
+ // module's process.cwd(); the disk definition always wins inline input.
34
+ ...(request.definition ? { definition: request.definition } : {}),
35
+ // `fork` asks for a snapshot of the leader's active context. The snapshot
36
+ // is taken here rather than in the tool, because the tool has no session
37
+ // manager and the seam is what owns this package's spawn.
38
+ ...(request.fork && sessionManager
39
+ ? { context: snapshotWorkContext(sessionManager) }
40
+ : {}),
41
+ });
42
+ if (!spawned.ok) return spawned;
43
+ const agent = spawned.agent;
44
+ // Prompted starts await accepted input; idle starts await native readiness.
45
+ // Neither outcome is proof the child's work has completed.
46
+ if (spawned.readiness) {
47
+ const failure = await spawned.readiness;
48
+ if (failure) return { ok: false as const, error: failure };
49
+ }
50
+ return {
51
+ ok: true as const,
52
+ session: exactRoute(agent.name, agent.spawnId),
53
+ };
54
+ },
55
+ authorizeInput: authorizeResidentInput,
56
+ send: sendResidentSequence,
57
+ interrupt: interruptResidentSequence,
58
+ stop: (name, spawnId) => shutdownAgentExact(name, spawnId),
59
+ });
60
+ }
61
+
62
+ function exactRoute(name: string, spawnId: string): string {
63
+ return `session:${name}:${spawnId}`;
64
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The coordination capability set this package contributes to a spawned child,
3
+ * and the worker extension entry that registers it.
4
+ *
5
+ * These live in their own dependency-free module rather than beside the
6
+ * registrations in `worker.ts` because two consumers need them and one of them
7
+ * is imported by `worker.ts`: `tool-copy.ts` renders the effective grant, and
8
+ * `team-machine.ts` passes both to the spawner. Declaring them in `worker.ts`
9
+ * would make `tool-copy.ts` import it and close a cycle.
10
+ *
11
+ * The spawner hardcodes none of this. A consumer that loads only its own worker
12
+ * extension advertises only what it implements, which is what keeps
13
+ * `@fradser/pi-subagents` from granting `work` and `agent_event` when it is
14
+ * installed without this package.
15
+ */
16
+
17
+ import * as path from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ /** Tool ids registered by this package's worker extension inside a child.
21
+ * Kept in sync with the `registerTool` calls in `worker.ts`. */
22
+ export const WORKER_CAPABILITY_TOOLS: readonly string[] = [
23
+ "message",
24
+ "task",
25
+ ];
26
+
27
+ /** This package's worker extension entry, passed to a spawned child with `-e`.
28
+ * `--no-extensions` disables discovered extensions but still loads explicit
29
+ * paths. `src/index.ts` is the extension module; the package-root `index.ts`
30
+ * only re-exports it. */
31
+ export const WORKER_EXTENSION_PATH = path.resolve(
32
+ path.dirname(fileURLToPath(import.meta.url)),
33
+ "index.ts",
34
+ );
package/src/guidance.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { AGENT_REFERENCE_PATH, formatAgentGuidance } from "./agents.ts";
2
- import { WORKER_BUILTIN_TOOLS } from "./worker-tools.ts";
1
+ import { AGENT_REFERENCE_PATH, formatAgentGuidance } from "@fradser/pi-subagents";
2
+ import { AGENT_BUILTIN_TOOLS } from "@fradser/pi-subagents";
3
3
 
4
4
  // One delivery contract for both idle and active Leader prompts.
5
5
  const LEADER_DELIVERY_GUIDANCE = `
@@ -39,16 +39,20 @@ is not a new broad review; broaden only when scope or risk changes.
39
39
  export const WORKER_GUIDANCE = `
40
40
  ## Agent Teams Worker Protocol
41
41
 
42
- You execute one current Work Item at a time. Use \`work\` to list available Work,
43
- claim pending Work, submit its outcome, or release it when appropriate. A queued
44
- claim or submission is not ownership or completion; the harness is authoritative.
45
-
46
- Use \`agent_event\` only for \`inform\` or \`request\` communication with the
47
- leader or an exact peer session. It arrives at the next safe tool boundary or wakes an idle leader. It cannot complete, release, reopen, or reassign
48
- Work. The runtime delivers ordinary final answers automatically for the current Work
42
+ You execute one current Task at a time. Use \`task\` to list what is available, take
43
+ what you will do with \`update status=in_progress\`, and deliver the outcome with
44
+ \`complete\`. A queued request is not ownership or completion; the harness is
45
+ authoritative.
46
+
47
+ Use \`message\` only for \`inform\` or \`request\` communication with the
48
+ leader or an exact peer session. It arrives at the next safe tool boundary or wakes an idle recipient. It cannot take, complete, reopen, or reassign
49
+ a Task. Communication and submission bind to the attempt that started your turn; a different
50
+ attempt, Work Item, or incarnation is rejected, not retargeted. After a submission or
51
+ cancellation acknowledgement, end the turn.
52
+ The runtime delivers ordinary final answers automatically for the current Work
49
53
  when execution settles as a successful candidate, not an independently verified result.
50
54
  If you cannot perform the requested work (including missing tools), you must use
51
- \`work({ action: "submit", outcome: "failed", result: "Blocker and unverified work" })\`.
55
+ \`task({ action: "complete", outcome: "failed", result: "Blocker and unverified work" })\`.
52
56
  Do not substitute an ordinary final answer describing the blocker: prose is not classified
53
57
  as failure. Ungated acceptance is not independently verified. Send intermediate events only for blockers needing a decision, changed
54
58
  constraint, or decision request; never bare
@@ -69,18 +73,18 @@ completion or repeat unchanged findings.
69
73
  export function buildIdleLeaderGuidance(cwd?: string): string {
70
74
  return `## Agent Teams
71
75
 
72
- Use \`agent\` with an explicit action: \`delegate\` for new independent Work,
73
- \`start\` for an unassigned resident, \`inspect\` for Presence, and \`stop\` with
74
- an exact returned session handle. Delegate an unknown role with an inline
75
- \`definition\` based on \`${AGENT_REFERENCE_PATH}\`; definitions are resolved live at spawn time and persist only when explicitly requested.
76
+ Use the selected subagent interface. The four intent tools are the only child
77
+ tool surface: \`agent_start\` with a prompt, \`agent_input\` with delivery next or steer,
78
+ \`agent_list\` for Presence, and \`agent_stop\` for exact-session close.
79
+ Identity selects saved \`agent.md\`; label is display only, as one lowercase hyphenated token. Temporary instructions stay in memory. Role reference: \`${AGENT_REFERENCE_PATH}\`.
76
80
 
77
- Choose explicit minimal tools: ${WORKER_BUILTIN_TOOLS.join(", ")} are canonical built-ins.
81
+ Choose minimal canonical tools: ${AGENT_BUILTIN_TOOLS.join(", ")}.
78
82
  Omitted tools or [] grant coordination-only access, not file or shell access. Check the returned effective grant.
79
83
  Delegate concrete acceptance criteria and an explicit verification gate where appropriate;
80
84
  ungated completion is not independently verified. An inform needs no acknowledgment unless
81
85
  a decision or action changes. Do not narrate repeated reports.
82
86
 
83
- Use \`work\` for the Work lifecycle (create, list, assign, release, reopen, supersede) and \`agent_event\` for communication. Claim and submit belong to the worker Work interface, not the leader one.
87
+ Use \`task\` for the Task lifecycle (create, list, update, complete, reopen) and \`message\` for communication. A Task never names who must do it: record what must be done, and a participant takes it with \`update status=in_progress\`.
84
88
  ${LEADER_DELIVERY_GUIDANCE}
85
89
  Available agents:
86
90
  ${formatAgentGuidance(cwd)}`;
@@ -89,37 +93,45 @@ ${formatAgentGuidance(cwd)}`;
89
93
  export function buildTeamLeaderGuidance(cwd?: string): string {
90
94
  return `## Agent Teams Orchestration
91
95
 
92
- The current session coordinates through three tools only:
93
- - \`agent\`: strict \`delegate\`, \`start\`, \`inspect\`, and exact-session \`stop\` actions.
94
- - \`work\`: create (subject), list, assign (id, target.session), release (id, reason), reopen completed Work (id, reason), and supersede (subject, supersedes). Worker-only claim and submit are not leader actions.
95
- - \`agent_event\`: communication-only \`inform\` or \`request\` messages.
96
+ The current session coordinates through three tool families:
97
+ - Subagents: \`agent_start\`, \`agent_input\` (delivery next or steer), \`agent_stop\`, and \`agent_list\`. Disk \`agent.md\` supplies identity. Use exact sessions and reuse them for dependent follow-ups.
98
+ - \`task\`: create (subject, optional dependsOn, resources, verify, supersedes), list, update (id, status=in_progress|pending, or content), complete (id, outcome, result), and reopen (id). One vocabulary for every participant.
99
+ - \`message\`: \`inform\` or \`request\` messages.
96
100
 
97
101
  Delegate independent Work once with concrete acceptance criteria and an explicit verification gate
98
102
  where appropriate. Ungated completion is not independently verified. Check the returned effective
99
- tool grant: omitted tools or [] are coordination-only, with no default bash or file access.
100
- An inform needs no acknowledgment unless a decision or action changes; do not narrate repeated reports.
101
- Send a current Work Session new information that
102
- changes the worker's assignment. Do not ask for progress reports or repeat instructions.
103
- The worker autonomously completes its assignment. Results, verification outcomes, and actionable failures arrive automatically; continue independent work or yield rather than
104
- polling, status requests, or repeated guidance.
105
-
106
- Agent session handles are incarnation-bound. Work IDs are stable; Work assignment,
107
- release, completed-only reopen, and supersession are explicit leader \`work\` actions,
108
- while claim and submit belong to the worker \`work\` interface. Automatic final answers
109
- and worker \`work\` submit share the same Work acceptance pipeline. Use \`agent_event\`
110
- for peer and Leader communication; it does not grant lifecycle authority.
103
+ tool grant: omitted tools or [] are coordination-only, with no bash or file access.
104
+ An inform needs no acknowledgment unless a decision changes.
105
+ Send a Work Session new information that changes its assignment; each message is the whole update.
106
+ The worker autonomously completes its assignment. Results, verification outcomes and actionable failures
107
+ arrive as messages; keep working on your own assignment while they come.
108
+
109
+ Agent session handles are incarnation-bound. Task IDs are stable, and a Task names
110
+ no owner: whoever acts on it holds it, and holding ends at \`complete\`, at process
111
+ exit, or at exact-session stop — there is no release verb. Automatic final answers and
112
+ \`task complete\` share one acceptance pipeline. \`message\` carries peer and Leader
113
+ talk and grants no lifecycle authority.
111
114
 
112
115
  ${LEADER_DELIVERY_GUIDANCE}
113
116
  ### Teammates are autonomous: recover, never punish
114
117
 
115
- For recovery, keep the existing Work ID: request explicit \`work release\` and await authoritative release before \`work assign\` to the chosen exact session. A queued release or a no-write message is not released authority. Do not create a competing duplicate through \`agent delegate\`. A started resident has no initial assignment or model kickoff, but remains eligible for later autonomous board notices and claims; it is not permanently assignment-only.
118
+ For recovery, keep the existing Task ID. Nothing is released by request: stop the
119
+ stale session, or wait for the attempt to end, and its lease is released with it. Do
120
+ not create a competing duplicate through another startup. A started
121
+ resident with no prompt has no initial work and no model kickoff, but remains
122
+ eligible for later tasks notices and takes work itself; it is not permanently idle.
123
+
124
+ Observe stalled sessions in \`/agent-teams\`; the harness leaves every child to its Leader. Exact-session
125
+ stop ends a child whose Work is delivered or abandoned — completion lives in the Work Item.
116
126
 
117
- Observe stalled sessions in \`/agent-teams\`. The harness never reclaims, restarts, or replaces a teammate. Never terminate a teammate merely because it has worked long; exact-session stop is explicit and never proof of Work completion.
127
+ A failed Task returns to pending under a recovery hold and waits: it cannot be taken
128
+ until somebody proceeds with a stated reason. Retired attempts' reports stop
129
+ instructing you.
118
130
 
119
- ### Yield while teammates work
131
+ ### Yield while agents work
120
132
 
121
- Continue independent work while teammates run. If none remains, end the turn; results resume the session automatically.
122
- Do not extend the turn with sleep, polling, legacy status requests, unsolicited steers, or repeated guidance.
133
+ Continue independent work while agents run, and end the turn when the work in hand is done; a result
134
+ resumes the session and opens the next one.
123
135
 
124
136
  Available agents:
125
137
  ${formatAgentGuidance(cwd)}
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { isTaskReplaced } from "@fradser/pi-tasks";
1
2
  /**
2
3
  * pi-agent-teams-fradser — Pi extension for Claude-style resident agent
3
4
  * teams. Composition only: worker capability registration, session lifecycle,
@@ -8,16 +9,17 @@
8
9
  import { getMarkdownTheme, keyHint, ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
9
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
11
  import { buildIdleLeaderGuidance, buildTeamLeaderGuidance, WORKER_GUIDANCE } from "./guidance.ts";
11
- import { clearSessionAgents } from "./agents.ts";
12
- import { getConfirmedStopTime, initTeamMachine, markTeammateFinished, removeRuntimeDir, shutdownTeamMachine, syncLeaderContext, teardownTeammates } from "./team-machine.ts";
12
+ import { publishAgentHost } from "./agent-host.ts";
13
+ import { getConfirmedStopTime, initTeamMachine, markTeammateFinished, onTaskChanged, onTaskCreated, onTaskTaken, removeRuntimeDir, shutdownTeamMachine, syncLeaderContext, teardownAgents } from "./team-machine.ts";
13
14
  import { cleanupExpiredStateDirs } from "./statefile.ts";
14
- import { livingTeammates, listTasks, resetState } from "./state.ts";
15
+ import { getTask, getAgent, livingAgents, listTasks, resetState } from "./state.ts";
16
+ import { configureTeamActivity } from "./activity.ts";
15
17
  import { ensureTeamWidget, refreshTeamUI, stopUiTimers } from "./ui.ts";
16
18
  import { registerLeaderTools, registerTeamCommand } from "./tools.ts";
17
- import { registerWorkerCapabilities, workerBinding } from "./worker.ts";
19
+ import { registerChildCapabilities, workerBinding } from "./worker.ts";
18
20
  import { plainText } from "./tool-copy.ts";
19
- import { agentColor, bindLifecycleRenderers, clearPiStatus, createToolExecutionWrapper, eventToolLifecycle, formatAgentMessagePrefix, notifyPi, renderAgentMessageBand } from "@fradser/pi-kit";
20
- import { annotateReportDelivery, formatReports, groupReportsByTeammate, TEAMMATE_HARNESS_MESSAGE_TYPE, TEAMMATE_REPORT_MESSAGE_TYPE, type LeaderReport } from "./leader-reports.ts";
21
+ import { agentColor, bindLifecycleRenderers, clearPiStatus, createToolExecutionWrapper, eventToolLifecycle, formatAgentMessagePrefix, hiddenThinkingEnabled, notifyPi, renderAgentMessageBand } from "@fradser/pi-kit";
22
+ import { annotateReportDelivery, formatReports, groupReportsByAgent, AGENT_HARNESS_MESSAGE_TYPE, AGENT_REPORT_MESSAGE_TYPE, type LeaderReport } from "@fradser/pi-subagents";
21
23
  import { Box, Markdown, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
22
24
 
23
25
  /** Geometry bound once: harness event rows share hint and wrapping. */
@@ -34,13 +36,52 @@ let leaderPi: ExtensionAPI | undefined;
34
36
  let leaderCtx: ExtensionContext | undefined;
35
37
 
36
38
  export const TEAMMATE_FINISHED_ENTRY_TYPE = "agent-teams-teammate-finished";
39
+ /** Audit entry for a retired attempt's nonterminal report. The evidence stays in the
40
+ * session while the context projection stops treating it as a current instruction;
41
+ * without a renderer the retained entry would be invisible in the transcript. */
42
+ export const TEAMMATE_HISTORICAL_ENTRY_TYPE = "agent-teams-historical-report";
43
+
44
+ /** One entry per retained report, so a re-delivered report cannot multiply its own
45
+ * audit trail. Bounded the same way the finish announcements are. */
46
+ const HISTORICAL_ENTRY_LIMIT = 512;
47
+ const retainedHistoricalReports = new Set<string>();
48
+ function rememberRetainedReport(report: LeaderReport): boolean {
49
+ const key = [
50
+ report.agent ?? "",
51
+ report.spawnId ?? "",
52
+ report.eventId ?? "",
53
+ report.timestamp ?? 0,
54
+ ].join(":");
55
+ if (retainedHistoricalReports.has(key)) return false;
56
+ retainedHistoricalReports.add(key);
57
+ while (retainedHistoricalReports.size > HISTORICAL_ENTRY_LIMIT) {
58
+ const oldest = retainedHistoricalReports.values().next().value;
59
+ if (oldest === undefined) break;
60
+ retainedHistoricalReports.delete(oldest);
61
+ }
62
+ return true;
63
+ }
64
+
65
+ /** Accepted outcomes remain evidence even after their process exits. Only
66
+ * nonterminal coordination loses current authority when its attempt retires; the
67
+ * source report stays in the session as the visible audit trail. */
68
+ function isHistoricalReport(report: LeaderReport): boolean {
69
+ if (report.origin === "harness" || report.harnessEvent || report.finished
70
+ || report.status === "completed" || report.status === "failed") return false;
71
+ if (!report.spawnId || !report.agent) return false;
72
+ const worker = getAgent(report.agent);
73
+ if (!worker || worker.spawnId !== report.spawnId || worker.status === "stopped") return true;
74
+ if (report.assignmentId !== worker.assignment?.id || worker.assignment?.closed || worker.reportSequenceEnded) return true;
75
+ const task = worker.currentTaskId ? getTask(worker.currentTaskId) : undefined;
76
+ return isTaskReplaced(task) || task?.status === "completed";
77
+ }
37
78
 
38
79
  function sendLeaderReport(report: LeaderReport): void {
39
80
  try {
40
81
  leaderPi?.sendMessage({
41
82
  customType: report.origin === "harness" || report.harnessEvent
42
- ? TEAMMATE_HARNESS_MESSAGE_TYPE
43
- : TEAMMATE_REPORT_MESSAGE_TYPE,
83
+ ? AGENT_HARNESS_MESSAGE_TYPE
84
+ : AGENT_REPORT_MESSAGE_TYPE,
44
85
  content: formatReports([report]),
45
86
  display: true,
46
87
  details: report,
@@ -54,7 +95,7 @@ function sendLeaderReport(report: LeaderReport): void {
54
95
  }
55
96
 
56
97
  export function hasActiveTeamState(): boolean {
57
- return livingTeammates().length > 0 || listTasks().length > 0;
98
+ return livingAgents().length > 0 || listTasks().length > 0;
58
99
  }
59
100
 
60
101
  function extractHarnessReport(details: unknown): LeaderReport | undefined {
@@ -64,13 +105,19 @@ function extractHarnessReport(details: unknown): LeaderReport | undefined {
64
105
 
65
106
  export default function (pi: ExtensionAPI) {
66
107
  if (workerBinding()) {
67
- const workerToolDisclosure = registerWorkerCapabilities(pi);
108
+ const workerToolDisclosure = registerChildCapabilities(pi);
68
109
  pi.on("session_start", async () => {
69
110
  workerToolDisclosure.reset();
70
111
  });
71
112
  pi.on("before_agent_start", async (event) => {
72
- workerToolDisclosure.update(event.prompt);
73
- return { systemPrompt: event.systemPrompt + WORKER_GUIDANCE };
113
+ // A custom start carries a message and no prompt, so the presence test keeps this correct
114
+ // against the event union; the disclosure tracks delivered text only.
115
+ workerToolDisclosure.update("prompt" in event ? event.prompt : undefined);
116
+ const options = event.systemPromptOptions;
117
+ if (!("sections" in options) || !options.sections || typeof options.sections !== "object") {
118
+ throw new Error("Agent Teams requires Pi structured prompt sections.");
119
+ }
120
+ Object.assign(options.sections, { agent_teams_worker: WORKER_GUIDANCE.trim() });
74
121
  });
75
122
  pi.on("session_shutdown", async () => {
76
123
  workerToolDisclosure.reset();
@@ -79,13 +126,20 @@ export default function (pi: ExtensionAPI) {
79
126
  }
80
127
 
81
128
  leaderPi = pi;
129
+ // The team widget and console rows share subagents' hidden-thinking policy.
130
+ configureTeamActivity(() => hiddenThinkingEnabled(pi));
82
131
  const deliveries = new WeakMap<object, LeaderReport[]>();
83
132
  pi.registerEntryRenderer(TEAMMATE_FINISHED_ENTRY_TYPE, (entry, _options, theme) => {
84
133
  const data = entry.data as { teammate?: string; agent?: string } | undefined;
85
134
  const name = data?.teammate ?? data?.agent ?? "teammate";
86
135
  return new Text(theme.fg("success", `Assignment for @${name} finished.`), 0, 0);
87
136
  });
88
- pi.registerMessageRenderer(TEAMMATE_HARNESS_MESSAGE_TYPE, (message, { expanded }, theme) => {
137
+ pi.registerEntryRenderer(TEAMMATE_HISTORICAL_ENTRY_TYPE, (entry, _options, theme) => {
138
+ const data = entry.data as { teammate?: string; agent?: string } | undefined;
139
+ const name = data?.teammate ?? data?.agent ?? "teammate";
140
+ return new Text(theme.fg("dim", `Historical report from @${name} retained for audit; it no longer instructs.`), 0, 0);
141
+ });
142
+ pi.registerMessageRenderer(AGENT_HARNESS_MESSAGE_TYPE, (message, { expanded }, theme) => {
89
143
  const report = extractHarnessReport(message.details);
90
144
  if (!report) return new Text(String(message.content), 0, 0);
91
145
  const event = report.harnessEvent;
@@ -94,7 +148,7 @@ export default function (pi: ExtensionAPI) {
94
148
  { hostComponent: ToolExecutionComponent, ui: leaderCtx?.ui, cwd: leaderCtx?.cwd },
95
149
  )(message, { expanded }, theme);
96
150
  });
97
- pi.registerMessageRenderer(TEAMMATE_REPORT_MESSAGE_TYPE, (message, { expanded, outputPad }, theme) => {
151
+ pi.registerMessageRenderer(AGENT_REPORT_MESSAGE_TYPE, (message, { expanded, outputPad }, theme) => {
98
152
  const reports = deliveries.get(message) ?? extractReports(message.details);
99
153
  if (reports.length === 0) {
100
154
  const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
@@ -106,9 +160,9 @@ export default function (pi: ExtensionAPI) {
106
160
  (opt, currentTheme) => {
107
161
  const effTheme = currentTheme ?? theme;
108
162
  if (!opt.expanded) {
109
- const groups = groupReportsByTeammate(reports);
163
+ const groups = groupReportsByAgent(reports);
110
164
  const band = renderAgentMessageBand(
111
- groups.map((group) => ({ direction: "from", teammate: group.teammate, count: group.reports.length })),
165
+ groups.map((group) => ({ direction: "from", teammate: group.agent, count: group.reports.length })),
112
166
  { theme: effTheme, fit: truncateToWidth, visibleWidth, wrapDetail: wrapTextWithAnsi, expandHint: keyHint("app.tools.expand", "to expand") },
113
167
  );
114
168
  if (!reports.some((report) => report.deliveredAfterStop)) return band;
@@ -119,9 +173,9 @@ export default function (pi: ExtensionAPI) {
119
173
  }
120
174
  const box = new Box(outputPad, 1, (text) => effTheme.bg("customMessageBg", text));
121
175
  for (const [index, report] of reports.entries()) {
122
- const teammate = report.teammate ?? report.agent ?? "teammate";
176
+ const agent = report.agent ?? "agent";
123
177
  const prefix = effTheme.fg("customMessageLabel", effTheme.bold(formatAgentMessagePrefix("from")));
124
- const name = effTheme.fg(agentColor(teammate), `@${teammate}`);
178
+ const name = effTheme.fg(agentColor(agent), `@${agent}`);
125
179
  const delivery = report.deliveredAfterStop ? effTheme.fg("warning", " (late delivery; process stopped)") : "";
126
180
  box.addChild(new Text(`${prefix}${name}${delivery}`, 0, 0));
127
181
  box.addChild(new Markdown(report.body, 0, 0, getMarkdownTheme(), {
@@ -144,8 +198,49 @@ export default function (pi: ExtensionAPI) {
144
198
  registerLeaderTools(pi);
145
199
  registerTeamCommand(pi);
146
200
 
201
+ // The tasks belongs to @fradser/pi-tasks, which registers the `task` tool
202
+ // itself. This package subscribes to what the tasks announces rather than
203
+ // being called by it, so pi-tasks never has to know a resident exists and a
204
+ // standalone tasks with nobody listening still works.
205
+ //
206
+ // Guarded because subscribing is optional: a host without an event bus must
207
+ // degrade to no coordination, not to a failed load. Tasks transitions still
208
+ // record correctly; only the noticing is skipped.
209
+ if (typeof pi.events?.on === "function") {
210
+ pi.events.on("pi-tasks:task-created", (payload) => {
211
+ onTaskCreated(payload as { id: string; replaced?: string[] });
212
+ });
213
+ pi.events.on("pi-tasks:task-taken", (payload) => {
214
+ onTaskTaken(payload as { id: string });
215
+ });
216
+ for (const channel of ["pi-tasks:task-completed", "pi-tasks:task-reopened", "pi-tasks:task-updated"]) {
217
+ pi.events.on(channel, () => onTaskChanged());
218
+ }
219
+ }
220
+
221
+ // Pi cannot retract one already-queued steer. The persisted record stays for
222
+ // audit, and this projection keeps a retired attempt's nonterminal text from
223
+ // acting as a current instruction. Deliberate trade-off: while such a record
224
+ // exists the projection is recomputed per request, so a session that retires a
225
+ // report pays a prompt/tool replay each turn. Correctness wins over cache
226
+ // warmth here; the alternative needs an SDK API to rewrite a stored message.
227
+ pi.on("context", (event) => {
228
+ let changed = false;
229
+ const messages = event.messages.flatMap((message) => {
230
+ if (message.role !== "custom" || message.customType !== AGENT_REPORT_MESSAGE_TYPE) return [message];
231
+ const reports = extractReports(message.details);
232
+ if (reports.length === 0) return [message];
233
+ const current = reports.filter((report) => !isHistoricalReport(report));
234
+ if (current.length === reports.length) return [message];
235
+ changed = true;
236
+ return current.length === 0 ? [] : [{ ...message, content: formatReports(current),
237
+ details: current.length === 1 ? current[0] : { reports: current } }];
238
+ });
239
+ return changed ? { messages } : undefined;
240
+ });
241
+
147
242
  pi.on("message_start", (event) => {
148
- if (event.message.role !== "custom" || event.message.customType !== TEAMMATE_REPORT_MESSAGE_TYPE) return;
243
+ if (event.message.role !== "custom" || event.message.customType !== AGENT_REPORT_MESSAGE_TYPE) return;
149
244
  const deliveredAt = Date.now();
150
245
  deliveries.set(event.message, extractReports(event.message.details).map((report) => annotateReportDelivery(
151
246
  report, deliveredAt, report.spawnId ? getConfirmedStopTime(report.spawnId) : undefined,
@@ -153,16 +248,34 @@ export default function (pi: ExtensionAPI) {
153
248
  });
154
249
 
155
250
  pi.on("message_end", async (event) => {
156
- if (event.message.role !== "custom" || event.message.customType !== TEAMMATE_REPORT_MESSAGE_TYPE) return;
251
+ if (event.message.role !== "custom" || event.message.customType !== AGENT_REPORT_MESSAGE_TYPE) return;
157
252
  const deliveredAt = Date.now();
158
253
  const reports = deliveries.get(event.message) ?? extractReports(event.message.details).map((report) => annotateReportDelivery(
159
254
  report, deliveredAt, report.spawnId ? getConfirmedStopTime(report.spawnId) : undefined,
160
255
  ));
161
256
  if (reports.length === 0) return;
162
257
  for (const report of reports) {
258
+ // A retired attempt's nonterminal report keeps its evidence in the session but must
259
+ // stop instructing the leader. The context projection already excludes it from
260
+ // future requests; this entry is what keeps the retained evidence visible rather
261
+ // than silently dropped, and is appended once per report at the delivery seam.
262
+ if (isHistoricalReport(report)) {
263
+ if (rememberRetainedReport(report)) {
264
+ pi.appendEntry(TEAMMATE_HISTORICAL_ENTRY_TYPE, {
265
+ agent: report.agent,
266
+ spawnId: report.spawnId,
267
+ assignmentId: report.assignmentId,
268
+ workId: report.workId,
269
+ eventId: report.eventId,
270
+ status: report.status,
271
+ timestamp: report.timestamp,
272
+ body: report.body,
273
+ });
274
+ }
275
+ continue;
276
+ }
163
277
  if (!markTeammateFinished(report)) continue;
164
278
  pi.appendEntry(TEAMMATE_FINISHED_ENTRY_TYPE, {
165
- teammate: report.teammate ?? report.agent,
166
279
  agent: report.agent,
167
280
  assignmentId: report.assignmentId,
168
281
  workId: report.workId,
@@ -185,7 +298,8 @@ export default function (pi: ExtensionAPI) {
185
298
  });
186
299
 
187
300
  pi.on("session_start", async (_event, ctx) => {
188
- clearSessionAgents();
301
+ // Session-only definitions belong to the execution extension. Clearing
302
+ // them here would discard temporary history under another package's owner.
189
303
  resetState();
190
304
  leaderCtx = ctx;
191
305
  ensureTeamWidget(ctx);
@@ -195,7 +309,10 @@ export default function (pi: ExtensionAPI) {
195
309
  refreshTeamUI(leaderCtx);
196
310
  },
197
311
  });
198
- clearPiStatus(ctx.ui, "teammate");
312
+ // The execution extension owns the tool. Hosted incarnations use our
313
+ // report/controls; standalone incarnations keep their own result owner.
314
+ publishAgentHost(ctx.sessionManager);
315
+ clearPiStatus(ctx.ui, "agent");
199
316
  refreshTeamUI(ctx);
200
317
  void cleanupExpiredStateDirs(STATE_DIR_MAX_AGE_MS);
201
318
  });
@@ -203,16 +320,18 @@ export default function (pi: ExtensionAPI) {
203
320
  pi.on("before_agent_start", async (event, ctx) => {
204
321
  leaderCtx = ctx;
205
322
  const teamIsActive = hasActiveTeamState();
206
- return {
207
- systemPrompt: event.systemPrompt + (teamIsActive
208
- ? buildTeamLeaderGuidance(ctx?.cwd ?? process.cwd())
209
- : buildIdleLeaderGuidance(ctx?.cwd ?? process.cwd())),
210
- };
323
+ const options = event.systemPromptOptions;
324
+ if (!("sections" in options) || !options.sections || typeof options.sections !== "object") {
325
+ throw new Error("Agent Teams requires Pi structured prompt sections.");
326
+ }
327
+ Object.assign(options.sections, { agent_teams_leader: (teamIsActive
328
+ ? buildTeamLeaderGuidance(ctx?.cwd ?? process.cwd())
329
+ : buildIdleLeaderGuidance(ctx?.cwd ?? process.cwd())).trim() });
211
330
  });
212
331
 
213
332
  pi.on("session_shutdown", async (_event, ctx) => {
214
333
  stopUiTimers();
215
- const diagnostics = await teardownTeammates();
334
+ const diagnostics = await teardownAgents();
216
335
  for (const message of diagnostics) {
217
336
  notifyPi(ctx.ui, message, "warning");
218
337
  }
@@ -228,6 +347,6 @@ function extractReports(details: unknown): LeaderReport[] {
228
347
  if (Array.isArray(details)) return details as LeaderReport[];
229
348
  const typed = details as LeaderReport | { reports?: LeaderReport[] } | undefined;
230
349
  if (typed && "reports" in typed && Array.isArray(typed.reports)) return typed.reports;
231
- if (typed && "teammate" in typed) return [typed as LeaderReport];
350
+ if (typed && ("agent" in typed || "teammate" in typed)) return [typed as LeaderReport];
232
351
  return [];
233
352
  }
package/src/recipient.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Teammate } from "./types.ts";
1
+ import type { Agent } from "./types.ts";
2
2
 
3
3
  export function sessionRoute(name: string): string {
4
4
  return `session:${name}`;
@@ -13,7 +13,7 @@ export function parseExactSessionRoute(route: string): { name: string; spawnId:
13
13
  return match ? { name: match[1], spawnId: match[2] } : undefined;
14
14
  }
15
15
 
16
- export function resolveExactSession(route: string, roster: readonly Teammate[]): Teammate | undefined {
16
+ export function resolveExactSession(route: string, roster: readonly Agent[]): Agent | undefined {
17
17
  const parsed = parseExactSessionRoute(route);
18
18
  if (!parsed) return undefined;
19
19
  return roster.find((teammate) => teammate.name === parsed.name && teammate.spawnId === parsed.spawnId && teammate.status !== "stopped");
@@ -24,7 +24,7 @@ export function resolveExactSession(route: string, roster: readonly Teammate[]):
24
24
  * name cannot consume mail addressed to its predecessor. */
25
25
  export function resolveRecipient(
26
26
  to: string,
27
- roster: ReadonlyArray<{ name: string; agent: string; status: string; spawnId?: string }>,
27
+ roster: ReadonlyArray<{ name: string; role: string; status: string; spawnId?: string }>,
28
28
  ): { name: string; spawnId?: string } {
29
29
  const exact = parseExactSessionRoute(to);
30
30
  if (exact) {
@@ -35,7 +35,7 @@ export function resolveRecipient(
35
35
  const explicit = to.startsWith("session:");
36
36
  const name = explicit ? to.slice("session:".length) : to;
37
37
  const living = roster.filter((entry) => entry.status !== "stopped");
38
- const recipients = living.filter((entry) => entry.name === name || (!explicit && entry.agent === to));
38
+ const recipients = living.filter((entry) => entry.name === name || (!explicit && entry.role === to));
39
39
  if (recipients.length > 1) {
40
40
  throw new Error(`Ambiguous Agent @${to}. Use a precise route: ${recipients.map((entry) => entry.spawnId ? exactSessionRoute(entry.name, entry.spawnId) : sessionRoute(entry.name)).join(", ")}.`);
41
41
  }