pum-agent 0.1.0-beta.3
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/LICENSE +21 -0
- package/README.md +196 -0
- package/package.json +69 -0
- package/src/agent-selector.tsx +217 -0
- package/src/agent-usage.ts +93 -0
- package/src/animation.tsx +476 -0
- package/src/app.tsx +1953 -0
- package/src/apply-patch.ts +583 -0
- package/src/cancel-confirmation.ts +14 -0
- package/src/check-mode.ts +630 -0
- package/src/commands.ts +45 -0
- package/src/config.ts +24 -0
- package/src/explanation-strength.ts +47 -0
- package/src/git-branch.ts +54 -0
- package/src/help-popup.tsx +279 -0
- package/src/history.ts +57 -0
- package/src/image-paste.ts +204 -0
- package/src/index.tsx +133 -0
- package/src/login-controller.ts +267 -0
- package/src/login-flow.ts +170 -0
- package/src/login-popup.tsx +154 -0
- package/src/platform.ts +94 -0
- package/src/prompt-stash.ts +130 -0
- package/src/replay.ts +188 -0
- package/src/session-history-popup.tsx +68 -0
- package/src/settings-popup.tsx +283 -0
- package/src/settings.ts +81 -0
- package/src/shutdown.ts +23 -0
- package/src/stash-batch.ts +28 -0
- package/src/status-bar.tsx +143 -0
- package/src/status-metadata.ts +110 -0
- package/src/subagents/manager.ts +1196 -0
- package/src/subagents/types.ts +86 -0
- package/src/syntax.ts +60 -0
- package/src/theme.ts +346 -0
- package/src/tool-line.ts +72 -0
- package/src/transcript.tsx +393 -0
- package/src/web-search.ts +157 -0
- package/src/worktree-command.ts +39 -0
- package/src/worktree.ts +219 -0
- package/src/writing-style.ts +54 -0
|
@@ -0,0 +1,1196 @@
|
|
|
1
|
+
import type { ImageContent, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
createAgentSessionFromServices,
|
|
4
|
+
createAgentSessionServices,
|
|
5
|
+
type AgentSession,
|
|
6
|
+
type ExtensionAPI,
|
|
7
|
+
type ExtensionContext,
|
|
8
|
+
type InlineExtension,
|
|
9
|
+
type ModelRuntime,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import {
|
|
16
|
+
addTurnUsage,
|
|
17
|
+
emptyAgentUsage,
|
|
18
|
+
normalizeAgentUsage,
|
|
19
|
+
usageFromEntries,
|
|
20
|
+
} from "../agent-usage";
|
|
21
|
+
import { replayEntries } from "../replay";
|
|
22
|
+
import { isRejectedToolResult } from "../check-mode";
|
|
23
|
+
import {
|
|
24
|
+
observeSearchCalls,
|
|
25
|
+
persistSearchCall,
|
|
26
|
+
withSearchRoute,
|
|
27
|
+
} from "../web-search";
|
|
28
|
+
import { editCounts, toolArg, type ToolCall } from "../tool-line";
|
|
29
|
+
import { applyPatchExtension } from "../apply-patch";
|
|
30
|
+
import {
|
|
31
|
+
resolvePendingDelivery,
|
|
32
|
+
settleTranscriptMessage,
|
|
33
|
+
type Line,
|
|
34
|
+
type PendingLine,
|
|
35
|
+
} from "../transcript";
|
|
36
|
+
import {
|
|
37
|
+
createWorktree,
|
|
38
|
+
listWorktrees,
|
|
39
|
+
mergeWorktree,
|
|
40
|
+
removeWorktree,
|
|
41
|
+
worktreeStatus,
|
|
42
|
+
type WorktreeRecord,
|
|
43
|
+
} from "../worktree";
|
|
44
|
+
import {
|
|
45
|
+
AGENT_MESSAGE_CUSTOM_TYPE,
|
|
46
|
+
AGENT_MESSAGE_DISPLAY_TYPE,
|
|
47
|
+
SUBAGENT_CUSTOM_TYPE,
|
|
48
|
+
TOOL_EVENT_CUSTOM_TYPE,
|
|
49
|
+
type AgentMessageData,
|
|
50
|
+
type AgentTranscript,
|
|
51
|
+
type SpawnSubagentOptions,
|
|
52
|
+
type SubagentManagerEvent,
|
|
53
|
+
type SubagentRegistryEvent,
|
|
54
|
+
type SubagentSnapshot,
|
|
55
|
+
type SubagentStatus,
|
|
56
|
+
} from "./types";
|
|
57
|
+
|
|
58
|
+
export const MAX_ACTIVE_SUBAGENTS = 5;
|
|
59
|
+
const MAX_RETAINED_AGENTS = 8;
|
|
60
|
+
const MAX_MESSAGE_LENGTH = 12_000;
|
|
61
|
+
const ACTIVE_SUBAGENT_STATUSES = new Set<SubagentStatus>(["starting", "running"]);
|
|
62
|
+
|
|
63
|
+
export const SUBAGENT_COMMUNICATION_SYSTEM_PROMPT = `## Inter-agent communication
|
|
64
|
+
|
|
65
|
+
- Use finish_subagent as the only final completion report. It sends the sole completion notification to the direct spawner after the status changes.
|
|
66
|
+
- Do not send a final summary, test report, done message, or completion status through message_agent.
|
|
67
|
+
- Use message_agent for questions, blockers, coordination, or intermediate information that needs action before completion.
|
|
68
|
+
- Do not automatically reply to an acknowledgement, status-only message, or completion notice.
|
|
69
|
+
- Never echo a peer message repeatedly.
|
|
70
|
+
- Send one acknowledgement only when acknowledgement is necessary.
|
|
71
|
+
- Reply again only when the new message contains a question, new information, or a new action.
|
|
72
|
+
- If two agents start acknowledging each other, stop the exchange immediately.`;
|
|
73
|
+
|
|
74
|
+
export const SUBAGENT_COORDINATION_SYSTEM_PROMPT = `## Background subagent coordination
|
|
75
|
+
|
|
76
|
+
- spawn_subagent returns after setup. The subagent continues in the background.
|
|
77
|
+
- Count only starting and running subagents as active. The active limit is five.
|
|
78
|
+
- For follow-up implementation work, prefer a new managed worktree subagent when fewer than five subagents are active.
|
|
79
|
+
- When five subagents are active, use message_agent to queue follow-up work for an appropriate related running subagent.
|
|
80
|
+
- message_agent uses the durable recipient-side message and steering queue. Do not create a shell queue or another hidden queue.
|
|
81
|
+
- Do not send unrelated work to an arbitrary subagent. If no appropriate recipient is clear, state the capacity issue and keep the work pending for deliberate routing.
|
|
82
|
+
- Never wait for subagents with bash sleep, shell polling loops, repeated list_subagents calls, or repeated worktree status calls.
|
|
83
|
+
- After you spawn all currently independent subagents, finish the current turn and yield the main agent loop.
|
|
84
|
+
- A directly spawned subagent completion notification will automatically start or steer a later main-agent turn.
|
|
85
|
+
- A normal 'Message from <agent>' is not a completion notification. Do not merge until the agent status is completed.
|
|
86
|
+
- Treat "wait for every subagent" as yielding until completion notifications arrive, not as active polling.
|
|
87
|
+
- Use list_subagents only for explicit user requests, recovery after a missing notification, or one status check before a final merge.
|
|
88
|
+
- For a coordinated batch, track unfinished agents from completion notifications.
|
|
89
|
+
- Merge each successful agent as soon as it settles.
|
|
90
|
+
- Wait to merge only when another unfinished task has a concrete dependency, a known conflict risk, or a required integration order. State that reason explicitly.
|
|
91
|
+
- If a notification does not arrive, report the notification fault instead of creating a sleep loop.`;
|
|
92
|
+
|
|
93
|
+
export function buildSubagentCapacityPrompt(activeCount: number): string {
|
|
94
|
+
const available = Math.max(0, MAX_ACTIVE_SUBAGENTS - activeCount);
|
|
95
|
+
if (available > 0) {
|
|
96
|
+
return `Current subagent capacity: ${activeCount}/${MAX_ACTIVE_SUBAGENTS} active; ${available} slot${available === 1 ? "" : "s"} available. Prefer spawn_subagent for follow-up implementation work that can run in parallel.`;
|
|
97
|
+
}
|
|
98
|
+
return `Current subagent capacity: ${activeCount}/${MAX_ACTIVE_SUBAGENTS} active; no slots available. Queue follow-up work with message_agent only when an appropriate related running subagent is clear. Otherwise, state the capacity issue and keep the work pending for deliberate routing.`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function countActiveSubagents(agents: Iterable<Pick<SubagentSnapshot, "status">>): number {
|
|
102
|
+
let count = 0;
|
|
103
|
+
for (const agent of agents) {
|
|
104
|
+
if (ACTIVE_SUBAGENT_STATUSES.has(agent.status)) count += 1;
|
|
105
|
+
}
|
|
106
|
+
return count;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Prevent the common duplicate where an agent reports done, then finish_subagent reports it again. */
|
|
110
|
+
export function isCompletionOnlyMessage(text: string): boolean {
|
|
111
|
+
const message = text.trim();
|
|
112
|
+
if (!message) return false;
|
|
113
|
+
const requestsAction = /\?|\b(?:please|need|blocked|blocking|conflict|question|review|start|spawn|coordinate|help)\b/i.test(message);
|
|
114
|
+
if (requestsAction) return false;
|
|
115
|
+
return /^(?:completed|finished|done\b|implemented\b|task complete\b|work complete\b|all requested .* complete)/i.test(message);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function activeLimitError(): Error {
|
|
119
|
+
return new Error(
|
|
120
|
+
`All ${MAX_ACTIVE_SUBAGENTS} subagent slots are active (starting or running). ` +
|
|
121
|
+
"Queue follow-up work to an appropriate related running subagent with message_agent. " +
|
|
122
|
+
"If no appropriate recipient is clear, keep the task pending and state the capacity issue.",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type RuntimeRecord = {
|
|
127
|
+
snapshot: SubagentSnapshot;
|
|
128
|
+
session?: AgentSession;
|
|
129
|
+
api?: ExtensionAPI;
|
|
130
|
+
unsubscribe?: () => void;
|
|
131
|
+
unsubscribeSearch?: () => void;
|
|
132
|
+
dispose?: () => Promise<void> | void;
|
|
133
|
+
finishRequested?: string;
|
|
134
|
+
userInstructionNotices?: Map<string, string>;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
type ManagerOptions = {
|
|
138
|
+
modelRuntime: ModelRuntime;
|
|
139
|
+
agentDir: string;
|
|
140
|
+
childExtensionFactories?: InlineExtension[];
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const emptyTranscript = (): AgentTranscript => ({ lines: [], stream: null, pending: [] });
|
|
144
|
+
|
|
145
|
+
function flushTranscript(transcript: AgentTranscript): AgentTranscript {
|
|
146
|
+
if (!transcript.stream?.text.trim()) return { ...transcript, stream: null };
|
|
147
|
+
return {
|
|
148
|
+
lines: [
|
|
149
|
+
...transcript.lines,
|
|
150
|
+
{ kind: "text", role: transcript.stream.kind, text: transcript.stream.text.trim() },
|
|
151
|
+
],
|
|
152
|
+
stream: null,
|
|
153
|
+
pending: transcript.pending,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function snapshotMetadata(snapshot: SubagentSnapshot): Omit<SubagentSnapshot, "transcript"> {
|
|
158
|
+
const { transcript: _transcript, ...metadata } = snapshot;
|
|
159
|
+
return metadata;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function cloneSnapshot(record: RuntimeRecord): SubagentSnapshot {
|
|
163
|
+
return {
|
|
164
|
+
...record.snapshot,
|
|
165
|
+
worktree: { ...record.snapshot.worktree },
|
|
166
|
+
usage: { ...record.snapshot.usage },
|
|
167
|
+
transcript: {
|
|
168
|
+
lines: [...record.snapshot.transcript.lines],
|
|
169
|
+
stream: record.snapshot.transcript.stream
|
|
170
|
+
? { ...record.snapshot.transcript.stream }
|
|
171
|
+
: null,
|
|
172
|
+
pending: record.snapshot.transcript.pending.map((pending) => ({
|
|
173
|
+
...pending,
|
|
174
|
+
line: { ...pending.line },
|
|
175
|
+
})),
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function textResult(text: string, details: unknown = {}) {
|
|
181
|
+
return { content: [{ type: "text" as const, text }], details };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export class SubagentManager {
|
|
185
|
+
private readonly modelRuntime: ModelRuntime;
|
|
186
|
+
private readonly agentDir: string;
|
|
187
|
+
private readonly childExtensionFactories: InlineExtension[];
|
|
188
|
+
private readonly records = new Map<string, RuntimeRecord>();
|
|
189
|
+
private readonly listeners = new Set<(event: SubagentManagerEvent) => void>();
|
|
190
|
+
private mainApi?: ExtensionAPI;
|
|
191
|
+
private mainSessionManager?: ExtensionContext["sessionManager"];
|
|
192
|
+
private mainCwd = process.cwd();
|
|
193
|
+
private parentSessionId = "detached";
|
|
194
|
+
private mainRunning = false;
|
|
195
|
+
private worktreeQueue: Promise<void> = Promise.resolve();
|
|
196
|
+
private readonly messageTimes = new Map<string, number[]>();
|
|
197
|
+
|
|
198
|
+
constructor(options: ManagerOptions) {
|
|
199
|
+
this.modelRuntime = options.modelRuntime;
|
|
200
|
+
this.agentDir = options.agentDir;
|
|
201
|
+
this.childExtensionFactories = [applyPatchExtension, ...(options.childExtensionFactories ?? [])];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
subscribe(listener: (event: SubagentManagerEvent) => void): () => void {
|
|
205
|
+
this.listeners.add(listener);
|
|
206
|
+
return () => this.listeners.delete(listener);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private emit(event: SubagentManagerEvent = { type: "changed" }): void {
|
|
210
|
+
for (const listener of this.listeners) listener(event);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
getAgents(): SubagentSnapshot[] {
|
|
214
|
+
return [...this.records.values()]
|
|
215
|
+
.map(cloneSnapshot)
|
|
216
|
+
.sort((a, b) => a.startedAt - b.startedAt);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
getAgent(id: string): SubagentSnapshot | undefined {
|
|
220
|
+
const record = this.records.get(id);
|
|
221
|
+
return record ? cloneSnapshot(record) : undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private persist(event: SubagentRegistryEvent): void {
|
|
225
|
+
this.mainApi?.appendEntry(SUBAGENT_CUSTOM_TYPE, event);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
persistToolEvent(call: ToolCall): void {
|
|
229
|
+
this.mainApi?.appendEntry(TOOL_EVENT_CUSTOM_TYPE, {
|
|
230
|
+
id: call.id,
|
|
231
|
+
name: call.name,
|
|
232
|
+
arg: call.arg,
|
|
233
|
+
state: call.state,
|
|
234
|
+
detail: call.detail,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async attachMain(
|
|
239
|
+
pi: ExtensionAPI,
|
|
240
|
+
sessionManager: ExtensionContext["sessionManager"],
|
|
241
|
+
cwd: string,
|
|
242
|
+
): Promise<void> {
|
|
243
|
+
const sessionId = sessionManager.getSessionId();
|
|
244
|
+
if (this.mainApi === pi && this.parentSessionId === sessionId && this.mainSessionManager) return;
|
|
245
|
+
await this.stopAll("interrupted", false);
|
|
246
|
+
this.records.clear();
|
|
247
|
+
this.messageTimes.clear();
|
|
248
|
+
this.mainApi = pi;
|
|
249
|
+
this.mainSessionManager = sessionManager;
|
|
250
|
+
this.mainCwd = cwd;
|
|
251
|
+
this.parentSessionId = sessionId;
|
|
252
|
+
this.mainRunning = false;
|
|
253
|
+
|
|
254
|
+
const restored = new Map<string, Omit<SubagentSnapshot, "transcript">>();
|
|
255
|
+
for (const entry of sessionManager.getEntries()) {
|
|
256
|
+
if (entry.type !== "custom" || entry.customType !== SUBAGENT_CUSTOM_TYPE) continue;
|
|
257
|
+
const data = entry.data as SubagentRegistryEvent | undefined;
|
|
258
|
+
if (!data || typeof data.id !== "string") continue;
|
|
259
|
+
if (data.event === "spawned" && data.snapshot) restored.set(data.id, data.snapshot);
|
|
260
|
+
else if (data.event === "removed") restored.delete(data.id);
|
|
261
|
+
else if (data.event === "status") {
|
|
262
|
+
const current = restored.get(data.id);
|
|
263
|
+
if (current && data.status) {
|
|
264
|
+
current.status = data.status;
|
|
265
|
+
current.summary = data.summary ?? current.summary;
|
|
266
|
+
current.updatedAt = data.at;
|
|
267
|
+
}
|
|
268
|
+
} else if (data.event === "usage") {
|
|
269
|
+
const current = restored.get(data.id);
|
|
270
|
+
if (current && data.usage) current.usage = data.usage;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
for (const restoredSnapshot of restored.values()) {
|
|
275
|
+
const snapshot = {
|
|
276
|
+
...restoredSnapshot,
|
|
277
|
+
parentAgentId: restoredSnapshot.parentAgentId ?? null,
|
|
278
|
+
usage: normalizeAgentUsage(restoredSnapshot.usage),
|
|
279
|
+
} as SubagentSnapshot;
|
|
280
|
+
let transcript = emptyTranscript();
|
|
281
|
+
if (snapshot.sessionFile && existsSync(snapshot.sessionFile)) {
|
|
282
|
+
try {
|
|
283
|
+
const childManager = (await import("@earendil-works/pi-coding-agent")).SessionManager.open(
|
|
284
|
+
snapshot.sessionFile,
|
|
285
|
+
);
|
|
286
|
+
transcript = {
|
|
287
|
+
lines: replayEntries(childManager.buildContextEntries(), snapshot.worktree.path, true),
|
|
288
|
+
stream: null,
|
|
289
|
+
pending: [],
|
|
290
|
+
};
|
|
291
|
+
const retainedUsage = restoredSnapshot.usage as any;
|
|
292
|
+
if (!retainedUsage || typeof retainedUsage.outgoing !== "number") {
|
|
293
|
+
snapshot.usage = usageFromEntries(
|
|
294
|
+
childManager.getEntries(),
|
|
295
|
+
this.resolveModel(snapshot.modelId).contextWindow,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
// Keep metadata even if an old subagent session cannot be opened.
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const status: SubagentStatus = ["running", "starting"].includes(snapshot.status)
|
|
303
|
+
? "interrupted"
|
|
304
|
+
: snapshot.status;
|
|
305
|
+
this.records.set(snapshot.id, {
|
|
306
|
+
snapshot: { ...snapshot, status, transcript },
|
|
307
|
+
userInstructionNotices: new Map(),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
this.emit();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async bindMainSession(
|
|
314
|
+
sessionManager: ExtensionContext["sessionManager"],
|
|
315
|
+
cwd: string,
|
|
316
|
+
): Promise<void> {
|
|
317
|
+
if (!this.mainApi) throw new Error("Subagent extension API is unavailable");
|
|
318
|
+
await this.attachMain(this.mainApi, sessionManager, cwd);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async detachMain(): Promise<void> {
|
|
322
|
+
await this.stopAll("interrupted", true);
|
|
323
|
+
this.mainApi = undefined;
|
|
324
|
+
this.mainSessionManager = undefined;
|
|
325
|
+
this.records.clear();
|
|
326
|
+
this.emit();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private updateStatus(record: RuntimeRecord, status: SubagentStatus, summary?: string): void {
|
|
330
|
+
if (status === "running" && record.snapshot.status !== "running") {
|
|
331
|
+
record.snapshot.runStartedAt = Date.now();
|
|
332
|
+
}
|
|
333
|
+
record.snapshot.status = status;
|
|
334
|
+
record.snapshot.updatedAt = Date.now();
|
|
335
|
+
if (summary) record.snapshot.summary = summary;
|
|
336
|
+
this.persist({
|
|
337
|
+
event: "status",
|
|
338
|
+
id: record.snapshot.id,
|
|
339
|
+
at: record.snapshot.updatedAt,
|
|
340
|
+
status,
|
|
341
|
+
summary,
|
|
342
|
+
});
|
|
343
|
+
this.emit();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private updateTranscript(record: RuntimeRecord, update: (value: AgentTranscript) => AgentTranscript): void {
|
|
347
|
+
record.snapshot.transcript = update(record.snapshot.transcript);
|
|
348
|
+
record.snapshot.updatedAt = Date.now();
|
|
349
|
+
this.emit();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private appendLine(record: RuntimeRecord, line: Line): void {
|
|
353
|
+
this.updateTranscript(record, (value) => {
|
|
354
|
+
const flushed = flushTranscript(value);
|
|
355
|
+
return { ...flushed, lines: [...flushed.lines, line] };
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private addPending(record: RuntimeRecord, pending: PendingLine): void {
|
|
360
|
+
this.updateTranscript(record, (value) => ({
|
|
361
|
+
...value,
|
|
362
|
+
pending: [...value.pending, pending],
|
|
363
|
+
}));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private resolvePending(record: RuntimeRecord, id: string): void {
|
|
367
|
+
this.updateTranscript(record, (value) => resolvePendingDelivery(value, id));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private resolvePendingText(record: RuntimeRecord, text: string): PendingLine | undefined {
|
|
371
|
+
const pending = record.snapshot.transcript.pending.find((item) => item.deliveryText === text);
|
|
372
|
+
if (pending) this.resolvePending(record, pending.id);
|
|
373
|
+
return pending;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private dropPending(record: RuntimeRecord, id: string): void {
|
|
377
|
+
this.updateTranscript(record, (value) => ({
|
|
378
|
+
...value,
|
|
379
|
+
pending: value.pending.filter((item) => item.id !== id),
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private patchTool(record: RuntimeRecord, id: string, patch: Partial<ToolCall>): void {
|
|
384
|
+
this.updateTranscript(record, (value) => ({
|
|
385
|
+
...value,
|
|
386
|
+
lines: value.lines.map((line) =>
|
|
387
|
+
line.kind === "tool" && line.call.id === id
|
|
388
|
+
? { kind: "tool", call: { ...line.call, ...patch } }
|
|
389
|
+
: line,
|
|
390
|
+
),
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private processSessionEvent(record: RuntimeRecord, event: any): void {
|
|
395
|
+
switch (event.type) {
|
|
396
|
+
case "message_start": {
|
|
397
|
+
const message = event.message;
|
|
398
|
+
if (message?.role === "custom" && message.customType === AGENT_MESSAGE_CUSTOM_TYPE) {
|
|
399
|
+
const id = message.details?.id;
|
|
400
|
+
if (typeof id === "string") this.resolvePending(record, id);
|
|
401
|
+
} else if (message?.role === "user") {
|
|
402
|
+
const text = typeof message.content === "string"
|
|
403
|
+
? message.content
|
|
404
|
+
: Array.isArray(message.content)
|
|
405
|
+
? message.content
|
|
406
|
+
.filter((block: any) => block?.type === "text")
|
|
407
|
+
.map((block: any) => block.text)
|
|
408
|
+
.join("")
|
|
409
|
+
.trim()
|
|
410
|
+
: "";
|
|
411
|
+
if (text) {
|
|
412
|
+
const pending = this.resolvePendingText(record, text);
|
|
413
|
+
if (pending) {
|
|
414
|
+
const instruction = record.userInstructionNotices?.get(pending.id);
|
|
415
|
+
if (instruction !== undefined) {
|
|
416
|
+
record.userInstructionNotices?.delete(pending.id);
|
|
417
|
+
this.notifyMainOfUserInstruction(record, instruction);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
case "message_end":
|
|
425
|
+
if (event.message?.role === "assistant") {
|
|
426
|
+
this.updateTranscript(record, (value) => settleTranscriptMessage(value));
|
|
427
|
+
}
|
|
428
|
+
break;
|
|
429
|
+
case "message_update": {
|
|
430
|
+
const update = event.assistantMessageEvent;
|
|
431
|
+
const kind = update.type === "text_delta" ? "assistant" : update.type === "thinking_delta" ? "thinking" : null;
|
|
432
|
+
if (!kind) return;
|
|
433
|
+
this.updateTranscript(record, (value) => {
|
|
434
|
+
if (value.stream?.kind === kind) {
|
|
435
|
+
return { ...value, stream: { kind, text: value.stream.text + update.delta } };
|
|
436
|
+
}
|
|
437
|
+
const flushed = flushTranscript(value);
|
|
438
|
+
return { ...flushed, stream: { kind, text: update.delta } };
|
|
439
|
+
});
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
case "tool_execution_start":
|
|
443
|
+
this.appendLine(record, {
|
|
444
|
+
kind: "tool",
|
|
445
|
+
call: {
|
|
446
|
+
id: event.toolCallId,
|
|
447
|
+
name: event.toolName,
|
|
448
|
+
arg: toolArg(event.toolName, event.args, record.snapshot.worktree.path),
|
|
449
|
+
state: "running",
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
break;
|
|
453
|
+
case "tool_execution_end":
|
|
454
|
+
this.patchTool(record, event.toolCallId, {
|
|
455
|
+
state: isRejectedToolResult(event.result)
|
|
456
|
+
? "rejected"
|
|
457
|
+
: event.isError
|
|
458
|
+
? "error"
|
|
459
|
+
: "ok",
|
|
460
|
+
detail: event.toolName === "edit" || event.toolName === "apply_patch"
|
|
461
|
+
? editCounts(event.result)
|
|
462
|
+
: undefined,
|
|
463
|
+
});
|
|
464
|
+
break;
|
|
465
|
+
case "agent_start":
|
|
466
|
+
this.updateStatus(record, "running");
|
|
467
|
+
break;
|
|
468
|
+
case "turn_end": {
|
|
469
|
+
const usage = event.message?.usage;
|
|
470
|
+
if (!usage) break;
|
|
471
|
+
record.snapshot.usage = addTurnUsage(
|
|
472
|
+
record.snapshot.usage,
|
|
473
|
+
usage,
|
|
474
|
+
record.session?.agent.state.model.contextWindow,
|
|
475
|
+
);
|
|
476
|
+
record.snapshot.updatedAt = Date.now();
|
|
477
|
+
this.persist({
|
|
478
|
+
event: "usage",
|
|
479
|
+
id: record.snapshot.id,
|
|
480
|
+
at: record.snapshot.updatedAt,
|
|
481
|
+
usage: record.snapshot.usage,
|
|
482
|
+
});
|
|
483
|
+
this.emit();
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
case "agent_settled": {
|
|
487
|
+
this.updateTranscript(record, flushTranscript);
|
|
488
|
+
const error = record.session?.agent.state.errorMessage;
|
|
489
|
+
const status: SubagentStatus = error
|
|
490
|
+
? "failed"
|
|
491
|
+
: record.finishRequested !== undefined
|
|
492
|
+
? "completed"
|
|
493
|
+
: "idle";
|
|
494
|
+
const summary = record.finishRequested || error || record.snapshot.summary;
|
|
495
|
+
this.updateStatus(record, status, summary);
|
|
496
|
+
void this.notifySpawner(record, status, summary);
|
|
497
|
+
record.finishRequested = undefined;
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private childExtension(agentId: string): InlineExtension {
|
|
504
|
+
return {
|
|
505
|
+
name: `pum-subagent-${agentId}`,
|
|
506
|
+
factory: (pi) => {
|
|
507
|
+
// Capture immediately because inline extensions can load after the
|
|
508
|
+
// child session_start event on some session creation paths.
|
|
509
|
+
const initialRecord = this.records.get(agentId);
|
|
510
|
+
if (initialRecord) initialRecord.api = pi;
|
|
511
|
+
pi.on("session_start", (_event, ctx) => {
|
|
512
|
+
const record = this.records.get(agentId);
|
|
513
|
+
if (record) record.api = pi;
|
|
514
|
+
void ctx;
|
|
515
|
+
});
|
|
516
|
+
pi.on("before_agent_start", (event) => {
|
|
517
|
+
const record = this.records.get(agentId);
|
|
518
|
+
if (!record) return;
|
|
519
|
+
return {
|
|
520
|
+
systemPrompt: `${event.systemPrompt}\n\nYou are subagent ${record.snapshot.name} (${agentId}). ` +
|
|
521
|
+
`Work only in ${record.snapshot.worktree.path} on branch ${record.snapshot.worktree.branch}. ` +
|
|
522
|
+
"Use message_agent only for questions, blockers, coordination, or intermediate information that needs action. " +
|
|
523
|
+
"Never send the final summary through message_agent. " +
|
|
524
|
+
"Commit completed changes before finishing. Call finish_subagent exactly once with the final summary; it sends the sole completion notification after status changes.\n\n" +
|
|
525
|
+
SUBAGENT_COMMUNICATION_SYSTEM_PROMPT,
|
|
526
|
+
};
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
pi.registerTool({
|
|
530
|
+
name: "spawn_subagent",
|
|
531
|
+
label: "Spawn Subagent",
|
|
532
|
+
description: "Start a nonblocking child subagent in a new Git worktree.",
|
|
533
|
+
promptSnippet: "Start a child subagent in an isolated Git worktree",
|
|
534
|
+
parameters: Type.Object({
|
|
535
|
+
task: Type.String({ description: "Complete task for the child subagent" }),
|
|
536
|
+
name: Type.Optional(Type.String({ description: "Optional worktree and agent name" })),
|
|
537
|
+
}),
|
|
538
|
+
execute: async (_id, params) => {
|
|
539
|
+
const parent = this.records.get(agentId);
|
|
540
|
+
if (!parent) throw new Error("Spawner subagent no longer exists");
|
|
541
|
+
const snapshot = await this.spawn({
|
|
542
|
+
task: params.task,
|
|
543
|
+
name: params.name,
|
|
544
|
+
modelId: parent.snapshot.modelId,
|
|
545
|
+
thinkingLevel: parent.snapshot.thinkingLevel,
|
|
546
|
+
parentAgentId: agentId,
|
|
547
|
+
});
|
|
548
|
+
return textResult(`Spawned ${snapshot.name}\nid: ${snapshot.id}`, snapshot);
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
pi.registerTool({
|
|
553
|
+
name: "message_agent",
|
|
554
|
+
label: "Message Agent",
|
|
555
|
+
description: "Send a question, blocker, coordination request, or actionable intermediate message. Never use this tool for a final completion report; use finish_subagent instead.",
|
|
556
|
+
promptSnippet: "Send a message to the main agent or another subagent",
|
|
557
|
+
parameters: Type.Object({
|
|
558
|
+
target: Type.String({ description: 'Target agent id/name, or "main"' }),
|
|
559
|
+
message: Type.String({ description: "Message to send" }),
|
|
560
|
+
}),
|
|
561
|
+
execute: async (_id, params) => {
|
|
562
|
+
if (isCompletionOnlyMessage(params.message)) {
|
|
563
|
+
throw new Error("Use finish_subagent for the final summary. message_agent does not send completion-only reports.");
|
|
564
|
+
}
|
|
565
|
+
await this.routeMessage(agentId, params.target, params.message);
|
|
566
|
+
return textResult(`Message delivered to ${params.target}`);
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
pi.registerTool({
|
|
571
|
+
name: "list_subagents",
|
|
572
|
+
label: "List Subagents",
|
|
573
|
+
description: "List active and completed subagents and their worktrees.",
|
|
574
|
+
parameters: Type.Object({}),
|
|
575
|
+
execute: async () => textResult(this.formatAgentList()),
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
pi.registerTool({
|
|
579
|
+
name: "finish_subagent",
|
|
580
|
+
label: "Finish Subagent",
|
|
581
|
+
description: "Mark this task complete and send the sole final summary to the direct spawner after the agent status changes. Do not send the summary with message_agent first.",
|
|
582
|
+
parameters: Type.Object({
|
|
583
|
+
summary: Type.String({ description: "Summary of completed work, tests, and remaining concerns" }),
|
|
584
|
+
}),
|
|
585
|
+
execute: async (_id, params) => {
|
|
586
|
+
const record = this.records.get(agentId);
|
|
587
|
+
if (!record) throw new Error("Subagent no longer exists");
|
|
588
|
+
record.finishRequested = params.summary;
|
|
589
|
+
return {
|
|
590
|
+
...textResult("Completion recorded."),
|
|
591
|
+
terminate: true,
|
|
592
|
+
};
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
mainExtension(): InlineExtension {
|
|
600
|
+
return {
|
|
601
|
+
name: "pum-subagents",
|
|
602
|
+
factory: (pi) => {
|
|
603
|
+
// Capture the API immediately. Some session creation paths load inline
|
|
604
|
+
// extensions after session_start, so every tool also binds lazily.
|
|
605
|
+
this.mainApi = pi;
|
|
606
|
+
pi.on("before_agent_start", (event) => ({
|
|
607
|
+
systemPrompt: `${event.systemPrompt}\n\n${SUBAGENT_COORDINATION_SYSTEM_PROMPT}\n\n${buildSubagentCapacityPrompt(this.activeCount())}`,
|
|
608
|
+
}));
|
|
609
|
+
pi.on("agent_start", () => {
|
|
610
|
+
this.mainRunning = true;
|
|
611
|
+
});
|
|
612
|
+
pi.on("agent_settled", () => {
|
|
613
|
+
this.mainRunning = false;
|
|
614
|
+
});
|
|
615
|
+
pi.on("message_start", (event) => {
|
|
616
|
+
const message = event.message;
|
|
617
|
+
if (message.role !== "custom" || message.customType !== AGENT_MESSAGE_CUSTOM_TYPE) return;
|
|
618
|
+
const id = (message.details as AgentMessageData | undefined)?.id;
|
|
619
|
+
if (typeof id === "string") this.emit({ type: "main-pending-resolve", id });
|
|
620
|
+
});
|
|
621
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
622
|
+
await this.attachMain(pi, ctx.sessionManager, ctx.cwd);
|
|
623
|
+
});
|
|
624
|
+
pi.on("session_shutdown", async () => {
|
|
625
|
+
await this.detachMain();
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
pi.registerTool({
|
|
629
|
+
name: "spawn_subagent",
|
|
630
|
+
label: "Spawn Subagent",
|
|
631
|
+
description: "Start a nonblocking subagent in a new Git worktree. The subagent runs in parallel and reports when it stops. Five starting or running subagents can be active.",
|
|
632
|
+
promptSnippet: "Start a parallel subagent in an isolated Git worktree",
|
|
633
|
+
promptGuidelines: [
|
|
634
|
+
"Use spawn_subagent for independent tasks that can run in parallel.",
|
|
635
|
+
"For follow-up implementation work, prefer spawn_subagent while fewer than five subagents are starting or running.",
|
|
636
|
+
"At five active subagents, queue related follow-up work through message_agent instead of spawning a sixth.",
|
|
637
|
+
"Do not route unrelated work to an arbitrary subagent. Keep it pending when no appropriate recipient is clear.",
|
|
638
|
+
"Give each spawn_subagent call a complete, self-contained task.",
|
|
639
|
+
"After spawning background agents, end the current turn. Never poll with bash sleep or status loops.",
|
|
640
|
+
"Merge each successful agent when it settles unless a concrete dependency or conflict requires waiting.",
|
|
641
|
+
],
|
|
642
|
+
parameters: Type.Object({
|
|
643
|
+
task: Type.String({ description: "Complete task for the subagent" }),
|
|
644
|
+
name: Type.Optional(Type.String({ description: "Optional worktree and agent name" })),
|
|
645
|
+
}),
|
|
646
|
+
execute: async (_id, params, _signal, _update, ctx) => {
|
|
647
|
+
await this.attachMain(pi, ctx.sessionManager, ctx.cwd);
|
|
648
|
+
if (!ctx.model) throw new Error("No model is selected");
|
|
649
|
+
const snapshot = await this.spawn({
|
|
650
|
+
task: params.task,
|
|
651
|
+
name: params.name,
|
|
652
|
+
modelId: `${ctx.model.provider}/${ctx.model.id}`,
|
|
653
|
+
thinkingLevel: ctx.thinkingLevel ?? "off",
|
|
654
|
+
});
|
|
655
|
+
return textResult(
|
|
656
|
+
`Spawned ${snapshot.name}\n` +
|
|
657
|
+
`id: ${snapshot.id}\nbranch: ${snapshot.worktree.branch}\nworktree: ${snapshot.worktree.path}`,
|
|
658
|
+
snapshot,
|
|
659
|
+
);
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
pi.registerTool({
|
|
664
|
+
name: "message_agent",
|
|
665
|
+
label: "Message Agent",
|
|
666
|
+
description: "Send a durable queued message from the main agent to a subagent. At capacity, use this for related follow-up work when an appropriate running recipient is clear.",
|
|
667
|
+
promptSnippet: "Queue an instruction or question to an appropriate subagent",
|
|
668
|
+
parameters: Type.Object({
|
|
669
|
+
target: Type.String({ description: "Subagent id or name" }),
|
|
670
|
+
message: Type.String({ description: "Message to send" }),
|
|
671
|
+
}),
|
|
672
|
+
execute: async (_id, params, _signal, _update, ctx) => {
|
|
673
|
+
await this.attachMain(pi, ctx.sessionManager, ctx.cwd);
|
|
674
|
+
await this.routeMessage("main", params.target, params.message);
|
|
675
|
+
return textResult(`Message delivered to ${params.target}`);
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
pi.registerTool({
|
|
680
|
+
name: "list_subagents",
|
|
681
|
+
label: "List Subagents",
|
|
682
|
+
description: "List subagents, status, branch, and worktree.",
|
|
683
|
+
parameters: Type.Object({}),
|
|
684
|
+
execute: async (_id, _params, _signal, _update, ctx) => {
|
|
685
|
+
await this.attachMain(pi, ctx.sessionManager, ctx.cwd);
|
|
686
|
+
return textResult(this.formatAgentList());
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
pi.registerTool({
|
|
691
|
+
name: "stop_subagent",
|
|
692
|
+
label: "Stop Subagent",
|
|
693
|
+
description: "Abort and stop a subagent.",
|
|
694
|
+
parameters: Type.Object({ target: Type.String({ description: "Subagent id or name" }) }),
|
|
695
|
+
execute: async (_id, params, _signal, _update, ctx) => {
|
|
696
|
+
await this.attachMain(pi, ctx.sessionManager, ctx.cwd);
|
|
697
|
+
const record = this.findRecord(params.target);
|
|
698
|
+
if (!record) throw new Error(`Unknown subagent: ${params.target}`);
|
|
699
|
+
await this.stop(record.snapshot.id, "stopped");
|
|
700
|
+
return textResult(`Stopped ${record.snapshot.name}`);
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
pi.registerTool({
|
|
705
|
+
name: "worktree",
|
|
706
|
+
label: "Worktree",
|
|
707
|
+
description: "Create, list, inspect, merge, or remove PUM Git worktrees.",
|
|
708
|
+
promptSnippet: "Manage isolated Git worktrees under .pum/worktrees",
|
|
709
|
+
parameters: Type.Object({
|
|
710
|
+
action: Type.String({ description: "create, list, status, merge, or remove" }),
|
|
711
|
+
target: Type.Optional(Type.String({ description: "Worktree id or name" })),
|
|
712
|
+
name: Type.Optional(Type.String({ description: "Name for a new worktree" })),
|
|
713
|
+
force: Type.Optional(Type.Boolean({ description: "Force removal of an unmerged worktree" })),
|
|
714
|
+
}),
|
|
715
|
+
execute: async (_id, params, _signal, _update, ctx) => {
|
|
716
|
+
await this.attachMain(pi, ctx.sessionManager, ctx.cwd);
|
|
717
|
+
return this.worktreeAction(ctx.cwd, params.action, params.target, params.name, params.force);
|
|
718
|
+
},
|
|
719
|
+
});
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
private async withWorktreeLock<T>(operation: () => Promise<T>): Promise<T> {
|
|
725
|
+
let release!: () => void;
|
|
726
|
+
const next = new Promise<void>((resolve) => { release = resolve; });
|
|
727
|
+
const previous = this.worktreeQueue;
|
|
728
|
+
this.worktreeQueue = previous.then(() => next);
|
|
729
|
+
await previous;
|
|
730
|
+
try {
|
|
731
|
+
return await operation();
|
|
732
|
+
} finally {
|
|
733
|
+
release();
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async createStandaloneWorktree(name?: string): Promise<WorktreeRecord> {
|
|
738
|
+
return this.withWorktreeLock(() => createWorktree(this.mainCwd, name));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
private resolveModel(ref: string): Model<any> {
|
|
742
|
+
const slash = ref.indexOf("/");
|
|
743
|
+
if (slash <= 0) throw new Error(`Invalid model reference: ${ref}`);
|
|
744
|
+
const model = this.modelRuntime.getModel(ref.slice(0, slash), ref.slice(slash + 1));
|
|
745
|
+
if (!model) throw new Error(`Model is unavailable: ${ref}`);
|
|
746
|
+
return model;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
activeCount(): number {
|
|
750
|
+
return countActiveSubagents([...this.records.values()].map((record) => record.snapshot));
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async spawn(options: SpawnSubagentOptions): Promise<SubagentSnapshot> {
|
|
754
|
+
const record = await this.withWorktreeLock(async () => {
|
|
755
|
+
if (this.activeCount() >= MAX_ACTIVE_SUBAGENTS) throw activeLimitError();
|
|
756
|
+
if (this.records.size >= MAX_RETAINED_AGENTS) throw new Error(`At most ${MAX_RETAINED_AGENTS} subagents can be retained`);
|
|
757
|
+
|
|
758
|
+
const worktree = await createWorktree(this.mainCwd, options.name);
|
|
759
|
+
const id = randomUUID().slice(0, 8);
|
|
760
|
+
const now = Date.now();
|
|
761
|
+
const snapshot: SubagentSnapshot = {
|
|
762
|
+
id,
|
|
763
|
+
name: worktree.name,
|
|
764
|
+
task: options.task,
|
|
765
|
+
status: "starting",
|
|
766
|
+
worktree,
|
|
767
|
+
parentAgentId: options.parentAgentId ?? null,
|
|
768
|
+
modelId: options.modelId,
|
|
769
|
+
thinkingLevel: options.thinkingLevel,
|
|
770
|
+
transcript: emptyTranscript(),
|
|
771
|
+
startedAt: now,
|
|
772
|
+
updatedAt: now,
|
|
773
|
+
usage: emptyAgentUsage(),
|
|
774
|
+
};
|
|
775
|
+
const created: RuntimeRecord = { snapshot, userInstructionNotices: new Map() };
|
|
776
|
+
this.records.set(id, created);
|
|
777
|
+
this.persist({ event: "spawned", id, at: now, snapshot: snapshotMetadata(snapshot) });
|
|
778
|
+
this.emit();
|
|
779
|
+
return created;
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
try {
|
|
783
|
+
await this.ensureRuntime(record);
|
|
784
|
+
this.appendLine(record, { kind: "text", role: "user", text: options.task });
|
|
785
|
+
this.updateStatus(record, "running");
|
|
786
|
+
void withSearchRoute(record.session!.sessionId, () => record.session!.prompt(options.task)).catch((error) => {
|
|
787
|
+
this.updateStatus(record, "failed", String(error));
|
|
788
|
+
void this.notifySpawner(record, "failed", String(error));
|
|
789
|
+
});
|
|
790
|
+
return cloneSnapshot(record);
|
|
791
|
+
} catch (error) {
|
|
792
|
+
this.updateStatus(record, "failed", String(error));
|
|
793
|
+
throw error;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
private async ensureRuntime(record: RuntimeRecord): Promise<void> {
|
|
798
|
+
if (record.session) return;
|
|
799
|
+
if (!existsSync(record.snapshot.worktree.path)) throw new Error(`Missing worktree: ${record.snapshot.worktree.path}`);
|
|
800
|
+
|
|
801
|
+
const model = this.resolveModel(record.snapshot.modelId);
|
|
802
|
+
const sessionDir = join(this.agentDir, "subagents", this.parentSessionId);
|
|
803
|
+
const SessionManagerClass = (await import("@earendil-works/pi-coding-agent")).SessionManager;
|
|
804
|
+
const sessionManager = record.snapshot.sessionFile && existsSync(record.snapshot.sessionFile)
|
|
805
|
+
? SessionManagerClass.open(record.snapshot.sessionFile, sessionDir)
|
|
806
|
+
: SessionManagerClass.create(record.snapshot.worktree.path, sessionDir);
|
|
807
|
+
const services = await createAgentSessionServices({
|
|
808
|
+
cwd: record.snapshot.worktree.path,
|
|
809
|
+
agentDir: this.agentDir,
|
|
810
|
+
modelRuntime: this.modelRuntime,
|
|
811
|
+
resourceLoaderOptions: {
|
|
812
|
+
extensionFactories: [...this.childExtensionFactories, this.childExtension(record.snapshot.id)],
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
const result = await createAgentSessionFromServices({
|
|
816
|
+
services,
|
|
817
|
+
sessionManager,
|
|
818
|
+
model,
|
|
819
|
+
thinkingLevel: record.snapshot.thinkingLevel as any,
|
|
820
|
+
tools: [
|
|
821
|
+
"read", "write", "edit", "apply_patch", "bash",
|
|
822
|
+
"spawn_subagent", "message_agent", "list_subagents", "finish_subagent",
|
|
823
|
+
],
|
|
824
|
+
});
|
|
825
|
+
record.session = result.session;
|
|
826
|
+
record.snapshot.sessionFile = result.session.sessionFile;
|
|
827
|
+
record.unsubscribe = result.session.subscribe((event) => this.processSessionEvent(record, event));
|
|
828
|
+
record.unsubscribeSearch = observeSearchCalls(result.session.sessionId, (call) => {
|
|
829
|
+
if (call.phase === "start") {
|
|
830
|
+
this.appendLine(record, {
|
|
831
|
+
kind: "tool",
|
|
832
|
+
call: { id: call.id, name: "web_search", arg: call.query, state: "running" },
|
|
833
|
+
});
|
|
834
|
+
} else {
|
|
835
|
+
this.patchTool(record, call.id, {
|
|
836
|
+
state: call.ok ? "ok" : "error",
|
|
837
|
+
...(call.query ? { arg: call.query } : {}),
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
persistSearchCall(result.session.sessionManager, call);
|
|
841
|
+
});
|
|
842
|
+
record.dispose = async () => {
|
|
843
|
+
record.unsubscribe?.();
|
|
844
|
+
record.unsubscribe = undefined;
|
|
845
|
+
record.unsubscribeSearch?.();
|
|
846
|
+
record.unsubscribeSearch = undefined;
|
|
847
|
+
await result.session.abort().catch(() => {});
|
|
848
|
+
result.session.dispose();
|
|
849
|
+
record.session = undefined;
|
|
850
|
+
record.api = undefined;
|
|
851
|
+
};
|
|
852
|
+
this.persist({
|
|
853
|
+
event: "spawned",
|
|
854
|
+
id: record.snapshot.id,
|
|
855
|
+
at: Date.now(),
|
|
856
|
+
snapshot: snapshotMetadata(record.snapshot),
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
private findRecord(target: string): RuntimeRecord | undefined {
|
|
861
|
+
return this.records.get(target) ?? [...this.records.values()].find((record) => record.snapshot.name === target);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async sendUserMessage(
|
|
865
|
+
id: string,
|
|
866
|
+
text: string,
|
|
867
|
+
images: ImageContent[] = [],
|
|
868
|
+
displayText = text,
|
|
869
|
+
): Promise<void> {
|
|
870
|
+
const record = this.findRecord(id);
|
|
871
|
+
if (!record) throw new Error(`Unknown subagent: ${id}`);
|
|
872
|
+
await this.ensureRuntime(record);
|
|
873
|
+
const pending: PendingLine = {
|
|
874
|
+
id: randomUUID().slice(0, 12),
|
|
875
|
+
line: { kind: "text", role: "user", text: displayText },
|
|
876
|
+
deliveryText: text,
|
|
877
|
+
};
|
|
878
|
+
this.addPending(record, pending);
|
|
879
|
+
record.userInstructionNotices ??= new Map();
|
|
880
|
+
record.userInstructionNotices.set(pending.id, displayText);
|
|
881
|
+
this.updateStatus(record, "running");
|
|
882
|
+
if (record.session!.isStreaming) {
|
|
883
|
+
try {
|
|
884
|
+
await withSearchRoute(record.session!.sessionId, () => record.session!.steer(text, images));
|
|
885
|
+
} catch (error) {
|
|
886
|
+
record.userInstructionNotices.delete(pending.id);
|
|
887
|
+
this.dropPending(record, pending.id);
|
|
888
|
+
throw error;
|
|
889
|
+
}
|
|
890
|
+
} else void withSearchRoute(
|
|
891
|
+
record.session!.sessionId,
|
|
892
|
+
() => record.session!.prompt(text, { images }),
|
|
893
|
+
).catch((error) => {
|
|
894
|
+
record.userInstructionNotices?.delete(pending.id);
|
|
895
|
+
this.dropPending(record, pending.id);
|
|
896
|
+
this.updateStatus(record, "failed", String(error));
|
|
897
|
+
void this.notifySpawner(record, "failed", String(error));
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async abortAgent(id: string): Promise<void> {
|
|
902
|
+
const record = this.findRecord(id);
|
|
903
|
+
if (!record?.session) return;
|
|
904
|
+
const queued = record.session.clearQueue();
|
|
905
|
+
const cancelled = new Set([...queued.steering, ...queued.followUp]);
|
|
906
|
+
if (cancelled.size > 0) {
|
|
907
|
+
for (const pending of record.snapshot.transcript.pending) {
|
|
908
|
+
if (pending.deliveryText && cancelled.has(pending.deliveryText)) {
|
|
909
|
+
record.userInstructionNotices?.delete(pending.id);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
this.updateTranscript(record, (value) => ({
|
|
913
|
+
...value,
|
|
914
|
+
pending: value.pending.filter(
|
|
915
|
+
(pending) => !pending.deliveryText || !cancelled.has(pending.deliveryText),
|
|
916
|
+
),
|
|
917
|
+
}));
|
|
918
|
+
}
|
|
919
|
+
await record.session.abort();
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
private agentMessageLine(data: AgentMessageData): Extract<Line, { kind: "agent-message" }> {
|
|
923
|
+
return {
|
|
924
|
+
kind: "agent-message",
|
|
925
|
+
sender: data.sender,
|
|
926
|
+
recipient: data.recipient,
|
|
927
|
+
text: data.text,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async routeMessage(senderTarget: string, recipientTarget: string, text: string): Promise<void> {
|
|
932
|
+
const message = text.trim();
|
|
933
|
+
if (!message) throw new Error("Message cannot be empty");
|
|
934
|
+
if (message.length > MAX_MESSAGE_LENGTH) throw new Error(`Message exceeds ${MAX_MESSAGE_LENGTH} characters`);
|
|
935
|
+
const now = Date.now();
|
|
936
|
+
const recent = (this.messageTimes.get(senderTarget) ?? []).filter((time) => now - time < 60_000);
|
|
937
|
+
if (recent.length >= 20) throw new Error("Agent message rate limit exceeded");
|
|
938
|
+
recent.push(now);
|
|
939
|
+
this.messageTimes.set(senderTarget, recent);
|
|
940
|
+
|
|
941
|
+
const sender = senderTarget === "main" ? undefined : this.findRecord(senderTarget);
|
|
942
|
+
const recipient = recipientTarget === "main" ? undefined : this.findRecord(recipientTarget);
|
|
943
|
+
if (senderTarget !== "main" && !sender) throw new Error(`Unknown sender: ${senderTarget}`);
|
|
944
|
+
if (recipientTarget !== "main" && !recipient) throw new Error(`Unknown recipient: ${recipientTarget}`);
|
|
945
|
+
if (sender && recipient && sender.snapshot.id === recipient.snapshot.id) {
|
|
946
|
+
throw new Error("An agent cannot message itself");
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const data: AgentMessageData = {
|
|
950
|
+
id: randomUUID().slice(0, 12),
|
|
951
|
+
sender: sender?.snapshot.name ?? "main",
|
|
952
|
+
recipient: recipient?.snapshot.name ?? "main",
|
|
953
|
+
text: message,
|
|
954
|
+
at: now,
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
const line = this.agentMessageLine(data);
|
|
958
|
+
const pending: PendingLine = { id: data.id, line };
|
|
959
|
+
if (sender) {
|
|
960
|
+
sender.session?.sessionManager.appendCustomEntry(AGENT_MESSAGE_DISPLAY_TYPE, data);
|
|
961
|
+
this.appendLine(sender, line);
|
|
962
|
+
} else {
|
|
963
|
+
this.mainApi?.appendEntry(AGENT_MESSAGE_DISPLAY_TYPE, data);
|
|
964
|
+
this.emit({ type: "main-line", line });
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const customMessage = {
|
|
968
|
+
customType: AGENT_MESSAGE_CUSTOM_TYPE,
|
|
969
|
+
content: `Message from ${data.sender}:\n${message}`,
|
|
970
|
+
display: true,
|
|
971
|
+
details: data,
|
|
972
|
+
};
|
|
973
|
+
if (recipientTarget === "main") {
|
|
974
|
+
if (!this.mainApi) throw new Error("Main agent is unavailable");
|
|
975
|
+
this.emit({ type: "main-pending-add", pending });
|
|
976
|
+
if (!this.wakeMain(customMessage, customMessage.content)) {
|
|
977
|
+
this.emit({ type: "main-pending-drop", id: pending.id });
|
|
978
|
+
throw new Error("Main agent is unavailable");
|
|
979
|
+
}
|
|
980
|
+
} else if (recipient) {
|
|
981
|
+
await this.ensureRuntime(recipient);
|
|
982
|
+
if (!recipient.api) throw new Error(`Agent message API is unavailable: ${recipient.snapshot.name}`);
|
|
983
|
+
this.addPending(recipient, pending);
|
|
984
|
+
withSearchRoute(recipient.session!.sessionId, () => {
|
|
985
|
+
recipient.api!.sendMessage(customMessage, { deliverAs: "steer", triggerTurn: true });
|
|
986
|
+
});
|
|
987
|
+
this.updateStatus(recipient, "running");
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
private wakeMain(
|
|
992
|
+
message: {
|
|
993
|
+
customType: string;
|
|
994
|
+
content: string;
|
|
995
|
+
display: boolean;
|
|
996
|
+
details?: unknown;
|
|
997
|
+
},
|
|
998
|
+
_fallback: string,
|
|
999
|
+
deliverAs: "steer" | "followUp" = "followUp",
|
|
1000
|
+
): boolean {
|
|
1001
|
+
const api = this.mainApi;
|
|
1002
|
+
if (!api) return false;
|
|
1003
|
+
// The explicit main-session binding makes the structured custom message a
|
|
1004
|
+
// reliable wake signal. Do not add a user-message fallback because it
|
|
1005
|
+
// creates a second visible turn after the custom message already wakes one.
|
|
1006
|
+
try {
|
|
1007
|
+
withSearchRoute(this.parentSessionId, () => {
|
|
1008
|
+
api.sendMessage(message, { deliverAs, triggerTurn: true });
|
|
1009
|
+
});
|
|
1010
|
+
return true;
|
|
1011
|
+
} catch {
|
|
1012
|
+
return false;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
private notifyMainOfUserInstruction(record: RuntimeRecord, instruction: string): void {
|
|
1017
|
+
if (!this.mainApi) return;
|
|
1018
|
+
const text = `User added instructions to subagent ${record.snapshot.name}:\n${instruction}`;
|
|
1019
|
+
const data: AgentMessageData = {
|
|
1020
|
+
id: randomUUID().slice(0, 12),
|
|
1021
|
+
sender: "user",
|
|
1022
|
+
recipient: "main",
|
|
1023
|
+
text,
|
|
1024
|
+
at: Date.now(),
|
|
1025
|
+
};
|
|
1026
|
+
const pending: PendingLine = { id: data.id, line: this.agentMessageLine(data) };
|
|
1027
|
+
this.emit({ type: "main-pending-add", pending });
|
|
1028
|
+
const delivered = this.wakeMain(
|
|
1029
|
+
{
|
|
1030
|
+
customType: AGENT_MESSAGE_CUSTOM_TYPE,
|
|
1031
|
+
content: text,
|
|
1032
|
+
display: true,
|
|
1033
|
+
details: data,
|
|
1034
|
+
},
|
|
1035
|
+
text,
|
|
1036
|
+
this.mainRunning ? "steer" : "followUp",
|
|
1037
|
+
);
|
|
1038
|
+
if (!delivered) this.emit({ type: "main-pending-drop", id: pending.id });
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
private async notifySpawner(
|
|
1042
|
+
record: RuntimeRecord,
|
|
1043
|
+
status: SubagentStatus,
|
|
1044
|
+
summary?: string,
|
|
1045
|
+
): Promise<void> {
|
|
1046
|
+
const content = [
|
|
1047
|
+
`Subagent ${record.snapshot.name} ${status}.`,
|
|
1048
|
+
`id: ${record.snapshot.id}`,
|
|
1049
|
+
`branch: ${record.snapshot.worktree.branch}`,
|
|
1050
|
+
`worktree: ${record.snapshot.worktree.path}`,
|
|
1051
|
+
summary ? `summary: ${summary}` : "",
|
|
1052
|
+
].filter(Boolean).join("\n");
|
|
1053
|
+
const parentAgentId = record.snapshot.parentAgentId;
|
|
1054
|
+
|
|
1055
|
+
if (parentAgentId === null) {
|
|
1056
|
+
if (!this.mainApi) return;
|
|
1057
|
+
const data: AgentMessageData = {
|
|
1058
|
+
id: randomUUID().slice(0, 12),
|
|
1059
|
+
sender: record.snapshot.name,
|
|
1060
|
+
recipient: "main",
|
|
1061
|
+
text: content,
|
|
1062
|
+
at: Date.now(),
|
|
1063
|
+
};
|
|
1064
|
+
this.wakeMain(
|
|
1065
|
+
{
|
|
1066
|
+
customType: AGENT_MESSAGE_CUSTOM_TYPE,
|
|
1067
|
+
content,
|
|
1068
|
+
display: true,
|
|
1069
|
+
details: data,
|
|
1070
|
+
},
|
|
1071
|
+
content,
|
|
1072
|
+
);
|
|
1073
|
+
this.emit({ type: "main-line", line: this.agentMessageLine(data) });
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const parent = this.records.get(parentAgentId);
|
|
1078
|
+
if (!parent) return;
|
|
1079
|
+
let pending: PendingLine | undefined;
|
|
1080
|
+
try {
|
|
1081
|
+
await this.ensureRuntime(parent);
|
|
1082
|
+
if (!parent.api || !parent.session) return;
|
|
1083
|
+
|
|
1084
|
+
const data: AgentMessageData = {
|
|
1085
|
+
id: randomUUID().slice(0, 12),
|
|
1086
|
+
sender: record.snapshot.name,
|
|
1087
|
+
recipient: parent.snapshot.name,
|
|
1088
|
+
text: content,
|
|
1089
|
+
at: Date.now(),
|
|
1090
|
+
};
|
|
1091
|
+
pending = { id: data.id, line: this.agentMessageLine(data) };
|
|
1092
|
+
this.addPending(parent, pending);
|
|
1093
|
+
withSearchRoute(parent.session.sessionId, () => {
|
|
1094
|
+
parent.api!.sendMessage(
|
|
1095
|
+
{
|
|
1096
|
+
customType: AGENT_MESSAGE_CUSTOM_TYPE,
|
|
1097
|
+
content,
|
|
1098
|
+
display: true,
|
|
1099
|
+
details: data,
|
|
1100
|
+
},
|
|
1101
|
+
{
|
|
1102
|
+
deliverAs: parent.session!.isStreaming ? "steer" : "followUp",
|
|
1103
|
+
triggerTurn: true,
|
|
1104
|
+
},
|
|
1105
|
+
);
|
|
1106
|
+
});
|
|
1107
|
+
this.updateStatus(parent, "running");
|
|
1108
|
+
} catch {
|
|
1109
|
+
if (pending) this.dropPending(parent, pending.id);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
private formatAgentList(): string {
|
|
1114
|
+
const agents = this.getAgents();
|
|
1115
|
+
if (!agents.length) return "No subagents.";
|
|
1116
|
+
return agents.map((agent) =>
|
|
1117
|
+
`${agent.id} ${agent.name} ${agent.status}\n ${agent.worktree.branch}\n ${agent.worktree.path}`,
|
|
1118
|
+
).join("\n");
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
async stop(id: string, status: SubagentStatus = "stopped", persist = true): Promise<void> {
|
|
1122
|
+
const record = this.findRecord(id);
|
|
1123
|
+
if (!record) return;
|
|
1124
|
+
if (record.dispose) await record.dispose();
|
|
1125
|
+
record.snapshot.transcript.pending = [];
|
|
1126
|
+
if (persist) this.updateStatus(record, status);
|
|
1127
|
+
else {
|
|
1128
|
+
record.snapshot.status = status;
|
|
1129
|
+
record.snapshot.updatedAt = Date.now();
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
private async stopAll(status: SubagentStatus, persist: boolean): Promise<void> {
|
|
1134
|
+
for (const record of this.records.values()) {
|
|
1135
|
+
if (record.dispose) await record.dispose();
|
|
1136
|
+
if (!["starting", "running"].includes(record.snapshot.status)) continue;
|
|
1137
|
+
if (persist) this.updateStatus(record, status);
|
|
1138
|
+
else {
|
|
1139
|
+
record.snapshot.status = status;
|
|
1140
|
+
record.snapshot.updatedAt = Date.now();
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
private forgetManagedAgent(record: RuntimeRecord): void {
|
|
1146
|
+
this.records.delete(record.snapshot.id);
|
|
1147
|
+
this.persist({ event: "removed", id: record.snapshot.id, at: Date.now() });
|
|
1148
|
+
this.emit();
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
private async worktreeAction(
|
|
1152
|
+
cwd: string,
|
|
1153
|
+
action: string,
|
|
1154
|
+
target?: string,
|
|
1155
|
+
name?: string,
|
|
1156
|
+
force = false,
|
|
1157
|
+
) {
|
|
1158
|
+
if (action === "create") {
|
|
1159
|
+
const record = await this.withWorktreeLock(() => createWorktree(cwd, name));
|
|
1160
|
+
return textResult(`Created ${record.name}\nbranch: ${record.branch}\npath: ${record.path}`, record);
|
|
1161
|
+
}
|
|
1162
|
+
if (action === "list") {
|
|
1163
|
+
const records = await listWorktrees(cwd);
|
|
1164
|
+
return textResult(records.length ? records.map((record) => `${record.name} ${record.branch}\n ${record.path}`).join("\n") : "No PUM worktrees.", { records });
|
|
1165
|
+
}
|
|
1166
|
+
if (!target) throw new Error(`worktree ${action} requires target`);
|
|
1167
|
+
const managedAgent = this.findRecord(target);
|
|
1168
|
+
if (managedAgent && ["starting", "running"].includes(managedAgent.snapshot.status)) {
|
|
1169
|
+
throw new Error(`Stop ${managedAgent.snapshot.name} before ${action}`);
|
|
1170
|
+
}
|
|
1171
|
+
const record = managedAgent?.snapshot.worktree
|
|
1172
|
+
?? (await listWorktrees(cwd)).find((item) => item.name === target || item.branch === target);
|
|
1173
|
+
if (!record) throw new Error(`Unknown worktree: ${target}`);
|
|
1174
|
+
if (action === "status") return textResult(await worktreeStatus(cwd, record), record);
|
|
1175
|
+
if (action === "merge") {
|
|
1176
|
+
return this.withWorktreeLock(async () => {
|
|
1177
|
+
const output = (await mergeWorktree(cwd, record)) || `Merged ${record.branch}`;
|
|
1178
|
+
if (!managedAgent) return textResult(output, record);
|
|
1179
|
+
|
|
1180
|
+
await this.stop(managedAgent.snapshot.id, "stopped");
|
|
1181
|
+
await removeWorktree(cwd, record);
|
|
1182
|
+
this.forgetManagedAgent(managedAgent);
|
|
1183
|
+
return textResult(`${output}\nClosed ${managedAgent.snapshot.name} and removed its worktree.`, record);
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
if (action === "remove") {
|
|
1187
|
+
return this.withWorktreeLock(async () => {
|
|
1188
|
+
if (managedAgent) await this.stop(managedAgent.snapshot.id, "stopped");
|
|
1189
|
+
await removeWorktree(cwd, record, force);
|
|
1190
|
+
if (managedAgent) this.forgetManagedAgent(managedAgent);
|
|
1191
|
+
return textResult(`Removed ${record.name}`, record);
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
throw new Error(`Unknown worktree action: ${action}`);
|
|
1195
|
+
}
|
|
1196
|
+
}
|