@fradser/pi-agent-teams 0.10.0 → 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,19 +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.
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.
45
46
 
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. Communication and submission bind to the attempt that started your turn; a different
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
49
50
  attempt, Work Item, or incarnation is rejected, not retargeted. After a submission or
50
51
  cancellation acknowledgement, end the turn.
51
52
  The runtime delivers ordinary final answers automatically for the current Work
52
53
  when execution settles as a successful candidate, not an independently verified result.
53
54
  If you cannot perform the requested work (including missing tools), you must use
54
- \`work({ action: "submit", outcome: "failed", result: "Blocker and unverified work" })\`.
55
+ \`task({ action: "complete", outcome: "failed", result: "Blocker and unverified work" })\`.
55
56
  Do not substitute an ordinary final answer describing the blocker: prose is not classified
56
57
  as failure. Ungated acceptance is not independently verified. Send intermediate events only for blockers needing a decision, changed
57
58
  constraint, or decision request; never bare
@@ -72,18 +73,18 @@ completion or repeat unchanged findings.
72
73
  export function buildIdleLeaderGuidance(cwd?: string): string {
73
74
  return `## Agent Teams
74
75
 
75
- Use \`agent\` with an explicit action: \`delegate\` for new independent Work,
76
- \`start\` for an unassigned resident, \`inspect\` for Presence, and \`stop\` with
77
- an exact returned session handle. Delegate an unknown role with an inline
78
- \`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}\`.
79
80
 
80
- Choose explicit minimal tools: ${WORKER_BUILTIN_TOOLS.join(", ")} are canonical built-ins.
81
+ Choose minimal canonical tools: ${AGENT_BUILTIN_TOOLS.join(", ")}.
81
82
  Omitted tools or [] grant coordination-only access, not file or shell access. Check the returned effective grant.
82
83
  Delegate concrete acceptance criteria and an explicit verification gate where appropriate;
83
84
  ungated completion is not independently verified. An inform needs no acknowledgment unless
84
85
  a decision or action changes. Do not narrate repeated reports.
85
86
 
86
- 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\`.
87
88
  ${LEADER_DELIVERY_GUIDANCE}
88
89
  Available agents:
89
90
  ${formatAgentGuidance(cwd)}`;
@@ -92,40 +93,45 @@ ${formatAgentGuidance(cwd)}`;
92
93
  export function buildTeamLeaderGuidance(cwd?: string): string {
93
94
  return `## Agent Teams Orchestration
94
95
 
95
- The current session coordinates through three tools only:
96
- - \`agent\`: strict \`delegate\`, \`start\`, \`inspect\`, and exact-session \`stop\` actions.
97
- - \`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.
98
- - \`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.
99
100
 
100
101
  Delegate independent Work once with concrete acceptance criteria and an explicit verification gate
101
102
  where appropriate. Ungated completion is not independently verified. Check the returned effective
102
- tool grant: omitted tools or [] are coordination-only, with no default bash or file access.
103
- An inform needs no acknowledgment unless a decision or action changes; do not narrate repeated reports.
104
- Send a current Work Session new information that
105
- changes the worker's assignment. Do not ask for progress reports or repeat instructions.
106
- The worker autonomously completes its assignment. Results, verification outcomes, and actionable failures arrive automatically; continue independent work or yield rather than
107
- polling, status requests, or repeated guidance.
108
-
109
- Agent session handles are incarnation-bound. Work IDs are stable; Work assignment,
110
- release, completed-only reopen, and supersession are explicit leader \`work\` actions,
111
- while claim and submit belong to the worker \`work\` interface. Automatic final answers
112
- and worker \`work\` submit share the same Work acceptance pipeline. Use \`agent_event\`
113
- 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.
114
114
 
115
115
  ${LEADER_DELIVERY_GUIDANCE}
116
116
  ### Teammates are autonomous: recover, never punish
117
117
 
118
- 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.
119
123
 
120
- 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.
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.
121
126
 
122
- Failed or interrupted Work returns as \`pending/recovery-required\` and waits for an explicit
123
- \`work assign\`; retired attempts' reports stop instructing you.
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.
124
130
 
125
- ### Yield while teammates work
131
+ ### Yield while agents work
126
132
 
127
- Continue independent work while teammates run. If none remains, end the turn; results resume the session automatically.
128
- 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.
129
135
 
130
136
  Available agents:
131
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 { getTask, getTeammate, 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. */
@@ -45,7 +47,7 @@ const HISTORICAL_ENTRY_LIMIT = 512;
45
47
  const retainedHistoricalReports = new Set<string>();
46
48
  function rememberRetainedReport(report: LeaderReport): boolean {
47
49
  const key = [
48
- report.teammate ?? report.agent ?? "",
50
+ report.agent ?? "",
49
51
  report.spawnId ?? "",
50
52
  report.eventId ?? "",
51
53
  report.timestamp ?? 0,
@@ -66,20 +68,20 @@ function rememberRetainedReport(report: LeaderReport): boolean {
66
68
  function isHistoricalReport(report: LeaderReport): boolean {
67
69
  if (report.origin === "harness" || report.harnessEvent || report.finished
68
70
  || report.status === "completed" || report.status === "failed") return false;
69
- if (!report.spawnId || !report.teammate) return false;
70
- const worker = getTeammate(report.teammate);
71
+ if (!report.spawnId || !report.agent) return false;
72
+ const worker = getAgent(report.agent);
71
73
  if (!worker || worker.spawnId !== report.spawnId || worker.status === "stopped") return true;
72
74
  if (report.assignmentId !== worker.assignment?.id || worker.assignment?.closed || worker.reportSequenceEnded) return true;
73
75
  const task = worker.currentTaskId ? getTask(worker.currentTaskId) : undefined;
74
- return task?.status === "superseded" || task?.status === "completed";
76
+ return isTaskReplaced(task) || task?.status === "completed";
75
77
  }
76
78
 
77
79
  function sendLeaderReport(report: LeaderReport): void {
78
80
  try {
79
81
  leaderPi?.sendMessage({
80
82
  customType: report.origin === "harness" || report.harnessEvent
81
- ? TEAMMATE_HARNESS_MESSAGE_TYPE
82
- : TEAMMATE_REPORT_MESSAGE_TYPE,
83
+ ? AGENT_HARNESS_MESSAGE_TYPE
84
+ : AGENT_REPORT_MESSAGE_TYPE,
83
85
  content: formatReports([report]),
84
86
  display: true,
85
87
  details: report,
@@ -93,7 +95,7 @@ function sendLeaderReport(report: LeaderReport): void {
93
95
  }
94
96
 
95
97
  export function hasActiveTeamState(): boolean {
96
- return livingTeammates().length > 0 || listTasks().length > 0;
98
+ return livingAgents().length > 0 || listTasks().length > 0;
97
99
  }
98
100
 
99
101
  function extractHarnessReport(details: unknown): LeaderReport | undefined {
@@ -103,13 +105,19 @@ function extractHarnessReport(details: unknown): LeaderReport | undefined {
103
105
 
104
106
  export default function (pi: ExtensionAPI) {
105
107
  if (workerBinding()) {
106
- const workerToolDisclosure = registerWorkerCapabilities(pi);
108
+ const workerToolDisclosure = registerChildCapabilities(pi);
107
109
  pi.on("session_start", async () => {
108
110
  workerToolDisclosure.reset();
109
111
  });
110
112
  pi.on("before_agent_start", async (event) => {
111
- workerToolDisclosure.update(event.prompt);
112
- 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() });
113
121
  });
114
122
  pi.on("session_shutdown", async () => {
115
123
  workerToolDisclosure.reset();
@@ -118,6 +126,8 @@ export default function (pi: ExtensionAPI) {
118
126
  }
119
127
 
120
128
  leaderPi = pi;
129
+ // The team widget and console rows share subagents' hidden-thinking policy.
130
+ configureTeamActivity(() => hiddenThinkingEnabled(pi));
121
131
  const deliveries = new WeakMap<object, LeaderReport[]>();
122
132
  pi.registerEntryRenderer(TEAMMATE_FINISHED_ENTRY_TYPE, (entry, _options, theme) => {
123
133
  const data = entry.data as { teammate?: string; agent?: string } | undefined;
@@ -129,7 +139,7 @@ export default function (pi: ExtensionAPI) {
129
139
  const name = data?.teammate ?? data?.agent ?? "teammate";
130
140
  return new Text(theme.fg("dim", `Historical report from @${name} retained for audit; it no longer instructs.`), 0, 0);
131
141
  });
132
- pi.registerMessageRenderer(TEAMMATE_HARNESS_MESSAGE_TYPE, (message, { expanded }, theme) => {
142
+ pi.registerMessageRenderer(AGENT_HARNESS_MESSAGE_TYPE, (message, { expanded }, theme) => {
133
143
  const report = extractHarnessReport(message.details);
134
144
  if (!report) return new Text(String(message.content), 0, 0);
135
145
  const event = report.harnessEvent;
@@ -138,7 +148,7 @@ export default function (pi: ExtensionAPI) {
138
148
  { hostComponent: ToolExecutionComponent, ui: leaderCtx?.ui, cwd: leaderCtx?.cwd },
139
149
  )(message, { expanded }, theme);
140
150
  });
141
- pi.registerMessageRenderer(TEAMMATE_REPORT_MESSAGE_TYPE, (message, { expanded, outputPad }, theme) => {
151
+ pi.registerMessageRenderer(AGENT_REPORT_MESSAGE_TYPE, (message, { expanded, outputPad }, theme) => {
142
152
  const reports = deliveries.get(message) ?? extractReports(message.details);
143
153
  if (reports.length === 0) {
144
154
  const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
@@ -150,9 +160,9 @@ export default function (pi: ExtensionAPI) {
150
160
  (opt, currentTheme) => {
151
161
  const effTheme = currentTheme ?? theme;
152
162
  if (!opt.expanded) {
153
- const groups = groupReportsByTeammate(reports);
163
+ const groups = groupReportsByAgent(reports);
154
164
  const band = renderAgentMessageBand(
155
- 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 })),
156
166
  { theme: effTheme, fit: truncateToWidth, visibleWidth, wrapDetail: wrapTextWithAnsi, expandHint: keyHint("app.tools.expand", "to expand") },
157
167
  );
158
168
  if (!reports.some((report) => report.deliveredAfterStop)) return band;
@@ -163,9 +173,9 @@ export default function (pi: ExtensionAPI) {
163
173
  }
164
174
  const box = new Box(outputPad, 1, (text) => effTheme.bg("customMessageBg", text));
165
175
  for (const [index, report] of reports.entries()) {
166
- const teammate = report.teammate ?? report.agent ?? "teammate";
176
+ const agent = report.agent ?? "agent";
167
177
  const prefix = effTheme.fg("customMessageLabel", effTheme.bold(formatAgentMessagePrefix("from")));
168
- const name = effTheme.fg(agentColor(teammate), `@${teammate}`);
178
+ const name = effTheme.fg(agentColor(agent), `@${agent}`);
169
179
  const delivery = report.deliveredAfterStop ? effTheme.fg("warning", " (late delivery; process stopped)") : "";
170
180
  box.addChild(new Text(`${prefix}${name}${delivery}`, 0, 0));
171
181
  box.addChild(new Markdown(report.body, 0, 0, getMarkdownTheme(), {
@@ -188,6 +198,26 @@ export default function (pi: ExtensionAPI) {
188
198
  registerLeaderTools(pi);
189
199
  registerTeamCommand(pi);
190
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
+
191
221
  // Pi cannot retract one already-queued steer. The persisted record stays for
192
222
  // audit, and this projection keeps a retired attempt's nonterminal text from
193
223
  // acting as a current instruction. Deliberate trade-off: while such a record
@@ -197,7 +227,7 @@ export default function (pi: ExtensionAPI) {
197
227
  pi.on("context", (event) => {
198
228
  let changed = false;
199
229
  const messages = event.messages.flatMap((message) => {
200
- if (message.role !== "custom" || message.customType !== TEAMMATE_REPORT_MESSAGE_TYPE) return [message];
230
+ if (message.role !== "custom" || message.customType !== AGENT_REPORT_MESSAGE_TYPE) return [message];
201
231
  const reports = extractReports(message.details);
202
232
  if (reports.length === 0) return [message];
203
233
  const current = reports.filter((report) => !isHistoricalReport(report));
@@ -210,7 +240,7 @@ export default function (pi: ExtensionAPI) {
210
240
  });
211
241
 
212
242
  pi.on("message_start", (event) => {
213
- 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;
214
244
  const deliveredAt = Date.now();
215
245
  deliveries.set(event.message, extractReports(event.message.details).map((report) => annotateReportDelivery(
216
246
  report, deliveredAt, report.spawnId ? getConfirmedStopTime(report.spawnId) : undefined,
@@ -218,7 +248,7 @@ export default function (pi: ExtensionAPI) {
218
248
  });
219
249
 
220
250
  pi.on("message_end", async (event) => {
221
- 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;
222
252
  const deliveredAt = Date.now();
223
253
  const reports = deliveries.get(event.message) ?? extractReports(event.message.details).map((report) => annotateReportDelivery(
224
254
  report, deliveredAt, report.spawnId ? getConfirmedStopTime(report.spawnId) : undefined,
@@ -232,7 +262,6 @@ export default function (pi: ExtensionAPI) {
232
262
  if (isHistoricalReport(report)) {
233
263
  if (rememberRetainedReport(report)) {
234
264
  pi.appendEntry(TEAMMATE_HISTORICAL_ENTRY_TYPE, {
235
- teammate: report.teammate ?? report.agent,
236
265
  agent: report.agent,
237
266
  spawnId: report.spawnId,
238
267
  assignmentId: report.assignmentId,
@@ -247,7 +276,6 @@ export default function (pi: ExtensionAPI) {
247
276
  }
248
277
  if (!markTeammateFinished(report)) continue;
249
278
  pi.appendEntry(TEAMMATE_FINISHED_ENTRY_TYPE, {
250
- teammate: report.teammate ?? report.agent,
251
279
  agent: report.agent,
252
280
  assignmentId: report.assignmentId,
253
281
  workId: report.workId,
@@ -270,7 +298,8 @@ export default function (pi: ExtensionAPI) {
270
298
  });
271
299
 
272
300
  pi.on("session_start", async (_event, ctx) => {
273
- clearSessionAgents();
301
+ // Session-only definitions belong to the execution extension. Clearing
302
+ // them here would discard temporary history under another package's owner.
274
303
  resetState();
275
304
  leaderCtx = ctx;
276
305
  ensureTeamWidget(ctx);
@@ -280,7 +309,10 @@ export default function (pi: ExtensionAPI) {
280
309
  refreshTeamUI(leaderCtx);
281
310
  },
282
311
  });
283
- 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");
284
316
  refreshTeamUI(ctx);
285
317
  void cleanupExpiredStateDirs(STATE_DIR_MAX_AGE_MS);
286
318
  });
@@ -288,16 +320,18 @@ export default function (pi: ExtensionAPI) {
288
320
  pi.on("before_agent_start", async (event, ctx) => {
289
321
  leaderCtx = ctx;
290
322
  const teamIsActive = hasActiveTeamState();
291
- return {
292
- systemPrompt: event.systemPrompt + (teamIsActive
293
- ? buildTeamLeaderGuidance(ctx?.cwd ?? process.cwd())
294
- : buildIdleLeaderGuidance(ctx?.cwd ?? process.cwd())),
295
- };
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() });
296
330
  });
297
331
 
298
332
  pi.on("session_shutdown", async (_event, ctx) => {
299
333
  stopUiTimers();
300
- const diagnostics = await teardownTeammates();
334
+ const diagnostics = await teardownAgents();
301
335
  for (const message of diagnostics) {
302
336
  notifyPi(ctx.ui, message, "warning");
303
337
  }
@@ -313,6 +347,6 @@ function extractReports(details: unknown): LeaderReport[] {
313
347
  if (Array.isArray(details)) return details as LeaderReport[];
314
348
  const typed = details as LeaderReport | { reports?: LeaderReport[] } | undefined;
315
349
  if (typed && "reports" in typed && Array.isArray(typed.reports)) return typed.reports;
316
- if (typed && "teammate" in typed) return [typed as LeaderReport];
350
+ if (typed && ("agent" in typed || "teammate" in typed)) return [typed as LeaderReport];
317
351
  return [];
318
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
  }