@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.
- package/.memory/MEMORY.md +1 -0
- package/.memory/attempt-bound-authority.md +19 -0
- package/README.md +137 -58
- 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 +49 -37
- package/src/index.ts +149 -30
- package/src/recipient.ts +4 -4
- package/src/state.ts +163 -607
- package/src/statefile.ts +28 -244
- package/src/team-machine.ts +827 -535
- package/src/tool-copy.ts +26 -34
- package/src/tools.ts +57 -210
- package/src/types.ts +56 -157
- package/src/ui.ts +85 -59
- package/src/worker.ts +152 -79
- 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,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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
\`
|
|
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
|
|
73
|
-
\`
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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 \`
|
|
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
|
|
93
|
-
-
|
|
94
|
-
- \`
|
|
95
|
-
- \`
|
|
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
|
|
100
|
-
An inform needs no acknowledgment unless a decision
|
|
101
|
-
Send a
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
and
|
|
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
|
|
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
|
-
|
|
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
|
|
131
|
+
### Yield while agents work
|
|
120
132
|
|
|
121
|
-
Continue independent work while
|
|
122
|
-
|
|
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 {
|
|
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 {
|
|
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. */
|
|
@@ -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
|
-
?
|
|
43
|
-
:
|
|
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
|
|
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 =
|
|
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
|
-
|
|
73
|
-
|
|
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.
|
|
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(
|
|
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 =
|
|
163
|
+
const groups = groupReportsByAgent(reports);
|
|
110
164
|
const band = renderAgentMessageBand(
|
|
111
|
-
groups.map((group) => ({ direction: "from", teammate: group.
|
|
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
|
|
176
|
+
const agent = report.agent ?? "agent";
|
|
123
177
|
const prefix = effTheme.fg("customMessageLabel", effTheme.bold(formatAgentMessagePrefix("from")));
|
|
124
|
-
const name = effTheme.fg(agentColor(
|
|
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 !==
|
|
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 !==
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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 {
|
|
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
|
}
|