@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.
- package/README.md +132 -64
- package/package.json +13 -5
- package/references/agent-roles.md +23 -13
- package/src/activity.ts +20 -11
- package/src/agent-actions.ts +61 -36
- package/src/agent-host.ts +64 -0
- package/src/capability-tools.ts +34 -0
- package/src/guidance.ts +44 -38
- package/src/index.ts +70 -36
- package/src/recipient.ts +4 -4
- package/src/state.ts +163 -618
- package/src/statefile.ts +28 -244
- package/src/team-machine.ts +825 -534
- package/src/tool-copy.ts +26 -34
- package/src/tools.ts +57 -210
- package/src/types.ts +51 -159
- package/src/ui.ts +85 -59
- package/src/worker.ts +88 -66
- package/src/agents.ts +0 -296
- package/src/leader-reports.ts +0 -92
- package/src/spawner.ts +0 -751
- package/src/work-context.ts +0 -88
- package/src/worker-tools.ts +0 -4
- package/src/worktree.ts +0 -164
|
@@ -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 "
|
|
2
|
-
import {
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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 \`
|
|
47
|
-
leader or an exact peer session. It arrives at the next safe tool boundary or wakes an idle
|
|
48
|
-
|
|
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
|
-
\`
|
|
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
|
|
76
|
-
\`
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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 \`
|
|
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
|
|
96
|
-
-
|
|
97
|
-
- \`
|
|
98
|
-
- \`
|
|
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
|
|
103
|
-
An inform needs no acknowledgment unless a decision
|
|
104
|
-
Send a
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
and
|
|
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
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
131
|
+
### Yield while agents work
|
|
126
132
|
|
|
127
|
-
Continue independent work while
|
|
128
|
-
|
|
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 {
|
|
12
|
-
import { getConfirmedStopTime, initTeamMachine, markTeammateFinished, removeRuntimeDir, shutdownTeamMachine, syncLeaderContext,
|
|
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,
|
|
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 {
|
|
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,
|
|
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.
|
|
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.
|
|
70
|
-
const worker =
|
|
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
|
|
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
|
-
?
|
|
82
|
-
:
|
|
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
|
|
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 =
|
|
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
|
-
|
|
112
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
163
|
+
const groups = groupReportsByAgent(reports);
|
|
154
164
|
const band = renderAgentMessageBand(
|
|
155
|
-
groups.map((group) => ({ direction: "from", teammate: group.
|
|
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
|
|
176
|
+
const agent = report.agent ?? "agent";
|
|
167
177
|
const prefix = effTheme.fg("customMessageLabel", effTheme.bold(formatAgentMessagePrefix("from")));
|
|
168
|
-
const name = effTheme.fg(agentColor(
|
|
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 !==
|
|
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 !==
|
|
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 !==
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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;
|
|
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.
|
|
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
|
}
|