pi-jar 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -15
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/WORKFLOWS.md +32 -7
- package/extensions/index.ts +127 -48
- package/package.json +1 -1
- package/src/activity-view.ts +324 -44
- package/src/advisor.ts +9 -3
- package/src/ask-tool.ts +12 -7
- package/src/changes.ts +35 -11
- package/src/child-baselines.ts +42 -0
- package/src/compact-tools.ts +58 -24
- package/src/composer.ts +39 -12
- package/src/delegate-worktree.ts +360 -0
- package/src/delegate.ts +1137 -129
- package/src/democracy.ts +192 -0
- package/src/dialogs.ts +9 -5
- package/src/diff-view.ts +13 -1
- package/src/discussion.ts +153 -0
- package/src/flame.ts +18 -5
- package/src/footer.ts +38 -19
- package/src/goal-loop.ts +8 -1
- package/src/history-ui.ts +19 -12
- package/src/history.ts +3 -1
- package/src/model-roles.ts +120 -36
- package/src/panel.ts +5 -1
- package/src/prompt-search.ts +16 -8
- package/src/roles-ui.ts +18 -10
- package/src/row-cache.ts +26 -0
- package/src/session-ui.ts +9 -4
- package/src/settings-ui.ts +46 -24
- package/src/settings.ts +9 -1
- package/src/shells.ts +2 -1
- package/src/side-model.ts +45 -25
- package/src/split-view.ts +29 -12
- package/src/status.ts +2 -1
- package/src/task-tool.ts +156 -54
- package/src/tasks-ui.ts +15 -5
- package/src/tasks.ts +224 -38
- package/src/welcome.ts +26 -13
package/src/delegate.ts
CHANGED
|
@@ -1,39 +1,82 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
5
7
|
import { Text } from "@earendil-works/pi-tui";
|
|
6
8
|
import { Type } from "typebox";
|
|
9
|
+
import type { ChangeTracker } from "./changes.ts";
|
|
10
|
+
import { CHILD_BASELINE_ENV, readChildBaseline } from "./child-baselines.ts";
|
|
7
11
|
import type { ModelRoleManager } from "./model-roles.ts";
|
|
8
|
-
import { ROLE_PREFIX } from "./status.ts";
|
|
9
|
-
import {
|
|
12
|
+
import { cleanText, ROLE_PREFIX } from "./status.ts";
|
|
13
|
+
import type { Todo } from "./tasks.ts";
|
|
14
|
+
import { DISCUSSION_FILE_ENV, SUBAGENT_KEY_ENV, SUBAGENT_NAME_ENV } from "./discussion.ts";
|
|
15
|
+
import {
|
|
16
|
+
applyDelegateWorktree,
|
|
17
|
+
CHILD_WORKTREE_ENV,
|
|
18
|
+
createDelegateWorktree,
|
|
19
|
+
disposeDelegateWorktree,
|
|
20
|
+
worktreeChangedFiles,
|
|
21
|
+
WorktreeApplyError,
|
|
22
|
+
type DelegateWorktree
|
|
23
|
+
} from "./delegate-worktree.ts";
|
|
10
24
|
|
|
11
25
|
export const DELEGATE_TOOL = "jar_delegate";
|
|
12
26
|
/** Set in child processes so a subagent never delegates again. */
|
|
13
27
|
export const CHILD_ENV = "PI_JAR_CHILD";
|
|
14
28
|
export const MAX_DELEGATES = 4;
|
|
15
|
-
export const
|
|
29
|
+
export const SUBAGENT_LIMIT_CHOICES = [2, 4, 6, 8, 16] as const;
|
|
30
|
+
/** Read-only subagents also keep jar_todo so their checklist shows in the activity view. */
|
|
31
|
+
export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "jar_todo", "jar_discuss"] as const;
|
|
32
|
+
/** Writable worktree children deliberately have no shell: edits stay inside path-guarded file tools. */
|
|
33
|
+
export const WORKTREE_TOOLS = ["read", "edit", "write", "grep", "find", "ls", "jar_todo", "jar_discuss"] as const;
|
|
34
|
+
export type DelegateMode = "scout" | "fork" | "worktree";
|
|
35
|
+
type DelegateExecutionMode = DelegateMode | "direct";
|
|
16
36
|
const MAX_OUTPUT_CHARS = 12_000;
|
|
17
37
|
const TIMEOUT_MS = 20 * 60_000;
|
|
18
38
|
const STATUS_REFRESH_MS = 20_000;
|
|
19
39
|
const LIVE_UPDATE_MS = 1000;
|
|
20
40
|
const MAX_EVENT_TEXT = 16_000;
|
|
21
|
-
|
|
22
|
-
const
|
|
41
|
+
/** Transcript entries kept per run; tool output and text are clipped so a run stays well under 2 MiB. */
|
|
42
|
+
const MAX_ENTRIES = 200;
|
|
43
|
+
const MAX_ENTRY_TEXT = 4000;
|
|
44
|
+
const MAX_ARGS_CHARS = 2000;
|
|
45
|
+
/** Tail of the assistant text still streaming, shown so a long answer never looks stuck. */
|
|
46
|
+
const MAX_LIVE_CHARS = 1200;
|
|
47
|
+
const LIVE_REPAINT_MS = 50;
|
|
48
|
+
/** One RPC record larger than this (a huge tool result) is skipped instead of buffered. */
|
|
49
|
+
const MAX_LINE_CHARS = 16 * 1024 * 1024;
|
|
50
|
+
const MAX_FILES = 50;
|
|
51
|
+
const MAX_TODOS = 50;
|
|
52
|
+
const MAX_STEER_CHARS = 4000;
|
|
53
|
+
/** Resumed assignments/ballots can include bounded evidence and multiple options. */
|
|
54
|
+
export const MAX_RESUME_CHARS = 40_000;
|
|
23
55
|
/** Finished subagents the registry keeps for the activity view after their tool call returns. */
|
|
24
56
|
const MAX_FINISHED = 8;
|
|
25
57
|
/** Abort reason for stopping one subagent from the activity view (vs. aborting the whole tool call). */
|
|
26
58
|
const STOP_REASON = "stopped";
|
|
27
59
|
/** Tool arguments that best describe a call in the transcript, most telling first. */
|
|
28
60
|
const HINT_KEYS = ["command", "path", "file_path", "pattern", "query", "url"] as const;
|
|
61
|
+
/** RPC dialogs a subagent cannot show: answered as cancelled so the child never blocks on them. */
|
|
62
|
+
const DIALOGS = new Set(["select", "confirm", "input", "editor"]);
|
|
63
|
+
const ANSI = /\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g;
|
|
64
|
+
|
|
65
|
+
export type DelegateState = "queued" | "working" | "idle" | "paused" | "stopped" | "done" | "failed";
|
|
66
|
+
export type ToolStatus = "running" | "done" | "error";
|
|
67
|
+
/** One transcript row; `rev` changes whenever the entry does, so views can cache their rendering. */
|
|
68
|
+
export type TranscriptEntry =
|
|
69
|
+
| { kind: "tool"; rev: number; id: string; name: string; hint: string; args: string; status: ToolStatus; output: string; startedAt: number; endedAt?: number }
|
|
70
|
+
| { kind: "text" | "steer" | "note"; rev: number; text: string };
|
|
71
|
+
export type ToolEntry = Extract<TranscriptEntry, { kind: "tool" }>;
|
|
29
72
|
|
|
30
|
-
export type DelegateState = "queued" | "working" | "done" | "failed";
|
|
31
73
|
export interface DelegateRun {
|
|
32
74
|
index: number;
|
|
33
75
|
name: string;
|
|
34
76
|
task: string;
|
|
35
77
|
role: string;
|
|
36
78
|
model?: string;
|
|
79
|
+
mode: DelegateExecutionMode;
|
|
37
80
|
state: DelegateState;
|
|
38
81
|
activity?: string;
|
|
39
82
|
tools: number;
|
|
@@ -43,8 +86,23 @@ export interface DelegateRun {
|
|
|
43
86
|
error?: string;
|
|
44
87
|
startedAt?: number;
|
|
45
88
|
endedAt?: number;
|
|
46
|
-
/**
|
|
47
|
-
|
|
89
|
+
/** Tool calls by name, for the report. */
|
|
90
|
+
toolCounts: Record<string, number>;
|
|
91
|
+
filesRead: string[];
|
|
92
|
+
filesEdited: string[];
|
|
93
|
+
/** Worktree changes successfully copied back into the parent workspace. */
|
|
94
|
+
appliedFiles: string[];
|
|
95
|
+
pauses: number;
|
|
96
|
+
resumes: number;
|
|
97
|
+
/** Kept only when isolated changes could not safely be applied automatically. */
|
|
98
|
+
workspace?: string;
|
|
99
|
+
/** The subagent's own jar_todo checklist, mirrored from its tool results. */
|
|
100
|
+
todos: Todo[];
|
|
101
|
+
steered: number;
|
|
102
|
+
/** Live transcript (tool calls with output, assistant text, steering); never copied into tool details. */
|
|
103
|
+
transcript: TranscriptEntry[];
|
|
104
|
+
/** Assistant text still streaming; never copied into tool details. */
|
|
105
|
+
live: string;
|
|
48
106
|
}
|
|
49
107
|
|
|
50
108
|
type Spawn = (command: string, args: string[], options: Parameters<typeof spawn>[2]) => ChildProcess;
|
|
@@ -57,19 +115,72 @@ export function piInvocation(args: string[], argv = process.argv, execPath = pro
|
|
|
57
115
|
return { command: "pi", args };
|
|
58
116
|
}
|
|
59
117
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
118
|
+
/**
|
|
119
|
+
* The parent's extension flags (`-e`, `--extension`, `-ne`), so a subagent loads the same extensions.
|
|
120
|
+
* In particular pi-jar itself, which records edit baselines for the parent's /diff and runs jar_todo.
|
|
121
|
+
*/
|
|
122
|
+
export function extensionFlags(argv = process.argv, cwd = process.cwd()): string[] {
|
|
123
|
+
const flags: string[] = [];
|
|
124
|
+
for (let index = 2; index < argv.length; index++) {
|
|
125
|
+
const arg = argv[index]!;
|
|
126
|
+
if (arg === "-ne" || arg === "--no-extensions") flags.push(arg);
|
|
127
|
+
else if ((arg === "-e" || arg === "--extension") && argv[index + 1]) flags.push("--extension", resolve(cwd, argv[++index]!));
|
|
128
|
+
else if (arg.startsWith("--extension=")) flags.push("--extension", resolve(cwd, arg.slice(12)));
|
|
129
|
+
}
|
|
130
|
+
return flags;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface DelegateForkOptions {
|
|
134
|
+
source: string;
|
|
135
|
+
sessionDir: string;
|
|
136
|
+
tools?: readonly string[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** CLI arguments for one subagent: fresh ephemeral session or a real Pi session fork. */
|
|
140
|
+
export function delegateArgs(model: string | undefined, thinking: string | undefined, write: boolean, argv = process.argv,
|
|
141
|
+
fork?: DelegateForkOptions): string[] {
|
|
142
|
+
const session = fork ? ["--fork", fork.source, "--session-dir", fork.sessionDir] : ["--no-session"];
|
|
143
|
+
const extensions = extensionFlags(argv);
|
|
144
|
+
// Worktree cwd/session forks do not inherit project extension discovery from the parent.
|
|
145
|
+
if (fork) {
|
|
146
|
+
const ownExtension = fileURLToPath(new URL("../extensions/index.ts", import.meta.url));
|
|
147
|
+
if (!extensions.includes(ownExtension)) extensions.push("--extension", ownExtension);
|
|
148
|
+
}
|
|
149
|
+
const args = ["--mode", "rpc", ...session, ...extensions];
|
|
63
150
|
if (model) args.push("--model", model);
|
|
64
151
|
if (thinking) args.push("--thinking", thinking);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
? "You may edit files. Stay strictly within the task; other subagents may be working in the same repository at the same time."
|
|
68
|
-
: "You are read-only: investigate and report, do not attempt to modify anything.";
|
|
69
|
-
args.push(`You are a focused subagent working for another agent. ${rules} Finish with a concise, self-contained report of your findings or changes (include file paths).\n\nTask: ${task}`);
|
|
152
|
+
const tools = fork?.tools ?? (!write ? READ_ONLY_TOOLS : undefined);
|
|
153
|
+
if (tools) args.push("--tools", tools.join(","));
|
|
70
154
|
return args;
|
|
71
155
|
}
|
|
72
156
|
|
|
157
|
+
/** The subagent's prompt: its rules, how to report back, then the task. */
|
|
158
|
+
export function delegatePrompt(task: string, write: boolean, mode: DelegateExecutionMode = write ? "direct" : "scout"): string {
|
|
159
|
+
const rules = mode === "worktree"
|
|
160
|
+
? "You inherit the parent conversation but work inside an isolated disposable Git worktree. You may edit only with the provided file tools; shell tools are intentionally unavailable. Stay strictly within the task. Your changes remain private across idle/pause/resume and are reconciled into the parent for /diff review only when the moderator stops you."
|
|
161
|
+
: mode === "fork"
|
|
162
|
+
? "You are a read-only fork of the parent conversation: use the inherited context, investigate, and report without modifying files."
|
|
163
|
+
: write
|
|
164
|
+
? "You may edit files. Stay strictly within the task; other subagents may be working in the same repository at the same time."
|
|
165
|
+
: "You are read-only: investigate and report, do not attempt to modify anything.";
|
|
166
|
+
return [
|
|
167
|
+
`You are a focused subagent working for another agent. ${rules}`,
|
|
168
|
+
"Track multi-step work with jar_todo (when available) so your progress is visible. The moderator may steer, pause or resume you; preserve scope across those controls.",
|
|
169
|
+
"Use jar_discuss when available for short cross-agent questions and answers. Keep discussion entries narrow; do not turn the shared paper into a transcript.",
|
|
170
|
+
"Finish with a self-contained report in these sections:",
|
|
171
|
+
"## Summary — the outcome in one to three sentences.",
|
|
172
|
+
"## Details — findings or changes, with file paths (and line numbers where useful).",
|
|
173
|
+
"## Verification — what you checked or ran, and the results.",
|
|
174
|
+
"## Open issues — risks, unknowns and follow-ups, or \"none\".",
|
|
175
|
+
"",
|
|
176
|
+
`Task: ${task}`
|
|
177
|
+
].join("\n");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Text without terminal controls, keeping line breaks; clipped to `limit`. */
|
|
181
|
+
const cleanBlock = (value: string, limit: number): string => value.slice(0, limit * 2)
|
|
182
|
+
.replace(ANSI, "").replace(/\r\n?/g, "\n").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, " ").replace(/\n{3,}/g, "\n\n").trim().slice(0, limit);
|
|
183
|
+
|
|
73
184
|
const textOf = (message: { content?: unknown }, limit = MAX_EVENT_TEXT): string => {
|
|
74
185
|
if (typeof message.content === "string") return message.content.slice(0, limit);
|
|
75
186
|
if (!Array.isArray(message.content)) return "";
|
|
@@ -85,12 +196,6 @@ const textOf = (message: { content?: unknown }, limit = MAX_EVENT_TEXT): string
|
|
|
85
196
|
return out;
|
|
86
197
|
};
|
|
87
198
|
|
|
88
|
-
/** Append transcript lines, cleaned and clipped, keeping only the newest MAX_LOG_LINES. */
|
|
89
|
-
const appendLog = (run: DelegateRun, lines: readonly string[]) => {
|
|
90
|
-
for (const raw of lines) { const line = cleanText(raw, MAX_LOG_CHARS); if (line) run.log.push(line); }
|
|
91
|
-
if (run.log.length > MAX_LOG_LINES) run.log.splice(0, run.log.length - MAX_LOG_LINES);
|
|
92
|
-
};
|
|
93
|
-
|
|
94
199
|
/** The argument that says what a tool call does: the bash command, the file path, the search pattern… */
|
|
95
200
|
const toolHint = (args: unknown): string => {
|
|
96
201
|
if (!args || typeof args !== "object") return "";
|
|
@@ -100,109 +205,511 @@ const toolHint = (args: unknown): string => {
|
|
|
100
205
|
}
|
|
101
206
|
return "";
|
|
102
207
|
};
|
|
208
|
+
const toolPath = (args: unknown): string | undefined => {
|
|
209
|
+
if (!args || typeof args !== "object") return undefined;
|
|
210
|
+
const value = (args as Record<string, unknown>).path ?? (args as Record<string, unknown>).file_path;
|
|
211
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
212
|
+
};
|
|
213
|
+
const argsText = (args: unknown): string => {
|
|
214
|
+
if (args === undefined) return "";
|
|
215
|
+
try { return cleanBlock(JSON.stringify(args, null, 1) ?? "", MAX_ARGS_CHARS); } catch { return ""; }
|
|
216
|
+
};
|
|
103
217
|
|
|
104
|
-
|
|
218
|
+
const pushEntry = (run: DelegateRun, entry: TranscriptEntry) => {
|
|
219
|
+
run.transcript.push(entry);
|
|
220
|
+
if (run.transcript.length > MAX_ENTRIES) run.transcript.splice(0, run.transcript.length - MAX_ENTRIES);
|
|
221
|
+
};
|
|
222
|
+
const addFile = (list: string[], path: string) => {
|
|
223
|
+
const clean = cleanText(path, 300);
|
|
224
|
+
if (clean && !list.includes(clean) && list.length < MAX_FILES) list.push(clean);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** A subagent's jar_todo items from a tool result, or undefined when the result is not a checklist. */
|
|
228
|
+
export function childTodos(details: unknown): Todo[] | undefined {
|
|
229
|
+
const items = details && typeof details === "object" ? (details as { items?: unknown }).items : undefined;
|
|
230
|
+
if (!Array.isArray(items)) return undefined;
|
|
231
|
+
const todos: Todo[] = [];
|
|
232
|
+
for (const raw of items.slice(0, MAX_TODOS)) {
|
|
233
|
+
if (!raw || typeof raw !== "object") continue;
|
|
234
|
+
const item = raw as Record<string, unknown>;
|
|
235
|
+
const status = item.status;
|
|
236
|
+
if (typeof item.id !== "string" || typeof item.title !== "string" || (status !== "pending" && status !== "in_progress" && status !== "completed")) continue;
|
|
237
|
+
todos.push({
|
|
238
|
+
id: cleanText(item.id, 64), title: cleanText(item.title, 120), status, done: status === "completed",
|
|
239
|
+
...(typeof item.activeForm === "string" && item.activeForm.trim() ? { activeForm: cleanText(item.activeForm, 80) } : {}),
|
|
240
|
+
...(typeof item.parentId === "string" && item.parentId ? { parentId: cleanText(item.parentId, 64) } : {})
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return todos;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Tasks that count toward completion: subtasks, and top-level tasks without subtasks. */
|
|
247
|
+
export const leafTodos = (todos: readonly Todo[]): Todo[] => {
|
|
248
|
+
const parents = new Set<string>();
|
|
249
|
+
for (const todo of todos) if (todo.parentId) parents.add(todo.parentId);
|
|
250
|
+
return todos.filter((todo) => !parents.has(todo.id));
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/** The slice of Pi's RPC stream a run reads; leaves are unknown because it is another process's output. */
|
|
105
254
|
interface ChildEvent {
|
|
106
255
|
type?: unknown;
|
|
256
|
+
id?: unknown;
|
|
257
|
+
command?: unknown;
|
|
258
|
+
success?: unknown;
|
|
259
|
+
error?: unknown;
|
|
260
|
+
method?: unknown;
|
|
261
|
+
toolCallId?: unknown;
|
|
107
262
|
toolName?: unknown;
|
|
108
263
|
args?: unknown;
|
|
264
|
+
isError?: unknown;
|
|
265
|
+
result?: { content?: unknown; details?: unknown };
|
|
266
|
+
partialResult?: { content?: unknown };
|
|
267
|
+
assistantMessageEvent?: { type?: unknown; delta?: unknown };
|
|
109
268
|
message?: { role?: unknown; content?: unknown; usage?: { cost?: { total?: unknown } }; stopReason?: unknown; errorMessage?: unknown };
|
|
110
269
|
}
|
|
111
270
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
271
|
+
export interface DelegateHooks {
|
|
272
|
+
/** Extra environment for the child (the baseline directory for /diff). */
|
|
273
|
+
env?: Record<string, string>;
|
|
274
|
+
/** A successful edit/write by the child, with the path it gave. */
|
|
275
|
+
edited?(path: string): void;
|
|
276
|
+
}
|
|
277
|
+
export interface DelegateHandle {
|
|
278
|
+
/** First point where the child is idle after its initial task. */
|
|
279
|
+
firstSettled: Promise<void>;
|
|
280
|
+
/** Process lifetime; persistent agents stay alive across many settled turns. */
|
|
281
|
+
closed: Promise<void>;
|
|
282
|
+
steer(text: string): boolean;
|
|
283
|
+
pause(): Promise<boolean>;
|
|
284
|
+
resume(text?: string): boolean;
|
|
285
|
+
ask(text: string): Promise<string | undefined>;
|
|
286
|
+
shutdown(): Promise<void>;
|
|
287
|
+
alive(): boolean;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Run one child Pi in RPC mode. Persistent children remain alive after agent_settled so the
|
|
292
|
+
* moderator can peek, steer, pause, resume and ask them questions without rebuilding context.
|
|
293
|
+
*/
|
|
294
|
+
export function startDelegate(run: DelegateRun, args: string[], prompt: string, cwd: string, signal: AbortSignal | undefined,
|
|
295
|
+
update: () => void, spawnProcess: Spawn = spawn as Spawn, hooks: DelegateHooks = {}, persistent = true): DelegateHandle {
|
|
296
|
+
let steer: (text: string) => boolean = () => false;
|
|
297
|
+
let pause: () => Promise<boolean> = async () => false;
|
|
298
|
+
let resume: (text?: string) => boolean = () => false;
|
|
299
|
+
let ask: (text: string) => Promise<string | undefined> = async () => undefined;
|
|
300
|
+
let shutdown: () => Promise<void> = async () => {};
|
|
301
|
+
let alive = false;
|
|
302
|
+
let resolveFirst!: () => void;
|
|
303
|
+
let firstResolved = false;
|
|
304
|
+
const firstSettled = new Promise<void>((resolve) => { resolveFirst = resolve; });
|
|
305
|
+
|
|
306
|
+
const closed = new Promise<void>((resolveClosed) => {
|
|
115
307
|
const invocation = piInvocation(args);
|
|
116
308
|
let child: ChildProcess;
|
|
117
309
|
try {
|
|
118
|
-
|
|
310
|
+
const env = { ...process.env };
|
|
311
|
+
for (const key of [CHILD_BASELINE_ENV, CHILD_WORKTREE_ENV, DISCUSSION_FILE_ENV, SUBAGENT_KEY_ENV, SUBAGENT_NAME_ENV]) delete env[key];
|
|
312
|
+
child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, stdio: ["pipe", "pipe", "pipe"], env: { ...env, ...hooks.env, [CHILD_ENV]: "1" } });
|
|
119
313
|
} catch (error) {
|
|
120
|
-
run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); run.endedAt = Date.now(); update();
|
|
314
|
+
run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); run.endedAt = Date.now(); update();
|
|
315
|
+
firstResolved = true; resolveFirst(); resolveClosed(); return;
|
|
121
316
|
}
|
|
317
|
+
|
|
318
|
+
alive = true;
|
|
122
319
|
run.state = "working";
|
|
123
320
|
run.startedAt = Date.now();
|
|
321
|
+
run.endedAt = undefined;
|
|
124
322
|
run.activity = "starting";
|
|
125
323
|
update();
|
|
324
|
+
|
|
126
325
|
let buffer = "";
|
|
326
|
+
let overflow = false;
|
|
327
|
+
let liveAt = 0;
|
|
328
|
+
let liveTimer: ReturnType<typeof setTimeout> | undefined;
|
|
127
329
|
let stderr = "";
|
|
128
330
|
let finished = false;
|
|
331
|
+
let settled = false;
|
|
332
|
+
let pauseRequested = false;
|
|
333
|
+
let closing = false;
|
|
334
|
+
let shutdownPromise: Promise<void> | undefined;
|
|
335
|
+
let askBusy = false;
|
|
336
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
337
|
+
let turnTimer: ReturnType<typeof setTimeout> | undefined;
|
|
338
|
+
const settleWaiters: Array<() => void> = [];
|
|
339
|
+
|
|
340
|
+
const send = (command: object): boolean => {
|
|
341
|
+
const stdin = child.stdin;
|
|
342
|
+
if (!alive || !stdin || stdin.destroyed || stdin.writableEnded) return false;
|
|
343
|
+
try { stdin.write(JSON.stringify(command) + "\n"); return true; } catch { return false; }
|
|
344
|
+
};
|
|
345
|
+
const clearTurnTimer = () => { if (turnTimer) clearTimeout(turnTimer); turnTimer = undefined; };
|
|
346
|
+
const hardStop = () => {
|
|
347
|
+
if (!alive) return;
|
|
348
|
+
child.kill("SIGTERM");
|
|
349
|
+
killTimer ??= setTimeout(() => { if (alive) child.kill("SIGKILL"); }, 3000);
|
|
350
|
+
killTimer.unref?.();
|
|
351
|
+
};
|
|
352
|
+
const armTurnTimer = () => {
|
|
353
|
+
clearTurnTimer();
|
|
354
|
+
turnTimer = setTimeout(() => {
|
|
355
|
+
hardStop();
|
|
356
|
+
finish("failed", "timed out");
|
|
357
|
+
}, TIMEOUT_MS);
|
|
358
|
+
turnTimer.unref?.();
|
|
359
|
+
};
|
|
360
|
+
const resolveSettled = () => {
|
|
361
|
+
for (const waiter of settleWaiters.splice(0)) waiter();
|
|
362
|
+
};
|
|
363
|
+
const waitSettled = () => settled || finished ? Promise.resolve() : new Promise<void>((resolve) => settleWaiters.push(resolve));
|
|
129
364
|
const finish = (state: DelegateState, error?: string) => {
|
|
130
365
|
if (finished) return;
|
|
131
366
|
finished = true;
|
|
132
|
-
|
|
367
|
+
clearTurnTimer();
|
|
368
|
+
clearTimeout(liveTimer);
|
|
133
369
|
signal?.removeEventListener("abort", abort);
|
|
370
|
+
if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
|
|
134
371
|
run.state = state;
|
|
135
372
|
if (error) run.error = error;
|
|
136
373
|
run.activity = undefined;
|
|
374
|
+
run.live = "";
|
|
375
|
+
for (const entry of run.transcript) if (entry.kind === "tool" && entry.status === "running") { entry.status = "error"; entry.endedAt = Date.now(); entry.rev++; }
|
|
137
376
|
run.endedAt = Date.now();
|
|
377
|
+
resolveSettled();
|
|
378
|
+
if (!alive && !firstResolved) { firstResolved = true; resolveFirst(); }
|
|
138
379
|
update();
|
|
139
|
-
resolve();
|
|
140
380
|
};
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const timer = setTimeout(() => { stop(); finish("failed", "timed out"); }, TIMEOUT_MS);
|
|
145
|
-
timer.unref?.();
|
|
146
|
-
if (signal?.aborted) { abort(); return; }
|
|
381
|
+
const abort = () => { hardStop(); finish("failed", signal?.reason === STOP_REASON ? "stopped" : "aborted"); };
|
|
382
|
+
|
|
383
|
+
child.stdin?.on("error", (error) => { if (!finished) stderr = (stderr + "\n" + error.message).slice(-4000); });
|
|
147
384
|
signal?.addEventListener("abort", abort, { once: true });
|
|
385
|
+
armTurnTimer();
|
|
386
|
+
|
|
387
|
+
const beginPrompt = (message: string, pauseAfter = false): boolean => {
|
|
388
|
+
const clean = cleanBlock(message, MAX_RESUME_CHARS);
|
|
389
|
+
if (finished || closing || !alive || !settled || !clean) return false;
|
|
390
|
+
if (!send({ id: "prompt-" + (run.resumes + 1), type: "prompt", message: clean })) return false;
|
|
391
|
+
settled = false;
|
|
392
|
+
run.error = undefined;
|
|
393
|
+
run.output = "";
|
|
394
|
+
pauseRequested = pauseAfter;
|
|
395
|
+
run.state = "working";
|
|
396
|
+
run.resumes++;
|
|
397
|
+
run.endedAt = undefined;
|
|
398
|
+
run.activity = "resuming";
|
|
399
|
+
armTurnTimer();
|
|
400
|
+
update();
|
|
401
|
+
return true;
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const running = new Map<string, { entry: ToolEntry; path?: string }>();
|
|
148
405
|
const line = (raw: string) => {
|
|
149
|
-
// Output that arrives after an abort or timeout must not revive a finished run's activity.
|
|
150
406
|
if (finished || !raw.trim()) return;
|
|
151
407
|
let parsed: unknown;
|
|
152
408
|
try { parsed = JSON.parse(raw); } catch { return; }
|
|
153
409
|
if (!parsed || typeof parsed !== "object") return;
|
|
154
|
-
// Parsed child output: the object check above is all the cast assumes; every field stays unknown until read.
|
|
155
410
|
const event = parsed as ChildEvent;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
411
|
+
switch (event.type) {
|
|
412
|
+
case "response":
|
|
413
|
+
if (event.success !== false) return;
|
|
414
|
+
if (event.command === "prompt") { hardStop(); finish("failed", cleanText(String(event.error ?? "prompt rejected"), 300)); return; }
|
|
415
|
+
pushEntry(run, { kind: "note", rev: 0, text: `${cleanText(String(event.command ?? "command"), 24)} rejected: ${cleanText(String(event.error ?? ""), 200)}` });
|
|
416
|
+
update();
|
|
417
|
+
return;
|
|
418
|
+
case "extension_ui_request":
|
|
419
|
+
if (DIALOGS.has(String(event.method))) send({ type: "extension_ui_response", id: event.id, cancelled: true });
|
|
420
|
+
return;
|
|
421
|
+
case "message_update": {
|
|
422
|
+
const delta = event.assistantMessageEvent;
|
|
423
|
+
if (delta?.type === "text_delta" && typeof delta.delta === "string") {
|
|
424
|
+
run.live = (run.live + delta.delta).slice(-MAX_LIVE_CHARS);
|
|
425
|
+
run.activity = "writing";
|
|
426
|
+
const now = Date.now();
|
|
427
|
+
if (now - liveAt >= LIVE_REPAINT_MS) { liveAt = now; update(); }
|
|
428
|
+
else if (!liveTimer) {
|
|
429
|
+
liveTimer = setTimeout(() => { liveTimer = undefined; liveAt = Date.now(); if (!finished) update(); }, LIVE_REPAINT_MS - (now - liveAt));
|
|
430
|
+
liveTimer.unref?.();
|
|
431
|
+
}
|
|
432
|
+
} else if (delta?.type === "thinking_start" || delta?.type === "thinking_delta") {
|
|
433
|
+
if (run.activity !== "thinking") run.activity = "thinking";
|
|
434
|
+
update();
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
case "tool_execution_start": {
|
|
439
|
+
const name = cleanText(String(event.toolName ?? "tool"), 24) || "tool";
|
|
440
|
+
const hint = cleanText(toolHint(event.args), 200);
|
|
441
|
+
const id = typeof event.toolCallId === "string" && event.toolCallId ? event.toolCallId : `call-${run.tools}`;
|
|
442
|
+
const entry: ToolEntry = { kind: "tool", rev: 0, id, name, hint, args: argsText(event.args), status: "running", output: "", startedAt: Date.now() };
|
|
443
|
+
const path = toolPath(event.args);
|
|
444
|
+
run.tools++;
|
|
445
|
+
run.toolCounts[name] = (run.toolCounts[name] ?? 0) + 1;
|
|
446
|
+
if (name === "read" && path) addFile(run.filesRead, path);
|
|
447
|
+
running.set(id, { entry, ...(path ? { path } : {}) });
|
|
448
|
+
pushEntry(run, entry);
|
|
449
|
+
run.activity = cleanText(hint ? `${name} ${hint}` : name, 60);
|
|
450
|
+
update();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
case "tool_execution_update": {
|
|
454
|
+
const call = typeof event.toolCallId === "string" ? running.get(event.toolCallId) : undefined;
|
|
455
|
+
if (!call || !event.partialResult) return;
|
|
456
|
+
call.entry.output = cleanBlock(textOf(event.partialResult, MAX_ENTRY_TEXT * 2), MAX_ENTRY_TEXT);
|
|
457
|
+
call.entry.rev++;
|
|
458
|
+
update();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
case "tool_execution_end": {
|
|
462
|
+
const call = typeof event.toolCallId === "string" ? running.get(event.toolCallId) : undefined;
|
|
463
|
+
if (call) running.delete(event.toolCallId as string);
|
|
464
|
+
const failed = event.isError === true;
|
|
465
|
+
const name = call?.entry.name ?? cleanText(String(event.toolName ?? ""), 24);
|
|
466
|
+
if (call) {
|
|
467
|
+
call.entry.status = failed ? "error" : "done";
|
|
468
|
+
call.entry.output = event.result ? cleanBlock(textOf(event.result, MAX_ENTRY_TEXT * 2), MAX_ENTRY_TEXT) : call.entry.output;
|
|
469
|
+
call.entry.endedAt = Date.now();
|
|
470
|
+
call.entry.rev++;
|
|
471
|
+
}
|
|
472
|
+
if (name === "jar_todo" && !failed) { const todos = childTodos(event.result?.details); if (todos) run.todos = todos; }
|
|
473
|
+
if ((name === "edit" || name === "write") && !failed && call?.path) {
|
|
474
|
+
addFile(run.filesEdited, call.path);
|
|
475
|
+
try { hooks.edited?.(call.path); } catch (error) { console.error("pi-jar: could not track a subagent edit", error); }
|
|
476
|
+
}
|
|
477
|
+
run.activity = "thinking";
|
|
478
|
+
update();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
case "message_end": {
|
|
482
|
+
if (event.message?.role !== "assistant") return;
|
|
483
|
+
run.turns++;
|
|
484
|
+
run.cost += Number(event.message.usage?.cost?.total) || 0;
|
|
485
|
+
const text = textOf(event.message).trim();
|
|
486
|
+
if (text) { run.output = text.slice(0, MAX_OUTPUT_CHARS); pushEntry(run, { kind: "text", rev: 0, text: cleanBlock(text, MAX_ENTRY_TEXT) }); }
|
|
487
|
+
if (event.message.stopReason === "error") run.error = cleanText(String(event.message.errorMessage ?? "assistant turn failed"), 200);
|
|
488
|
+
if (event.message.stopReason === "aborted" && !pauseRequested && !closing) run.error = "assistant turn aborted";
|
|
489
|
+
run.live = "";
|
|
490
|
+
run.activity = "thinking";
|
|
491
|
+
update();
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
case "agent_settled": {
|
|
495
|
+
settled = true;
|
|
496
|
+
clearTurnTimer();
|
|
497
|
+
for (const call of running.values()) {
|
|
498
|
+
call.entry.status = "error";
|
|
499
|
+
call.entry.endedAt = Date.now();
|
|
500
|
+
call.entry.rev++;
|
|
501
|
+
}
|
|
502
|
+
running.clear();
|
|
503
|
+
run.live = "";
|
|
504
|
+
run.activity = undefined;
|
|
505
|
+
run.endedAt = Date.now();
|
|
506
|
+
run.state = run.error ? "failed" : pauseRequested ? "paused" : "idle";
|
|
507
|
+
pauseRequested = false;
|
|
508
|
+
if (!firstResolved && !run.error) {
|
|
509
|
+
firstResolved = true;
|
|
510
|
+
resolveFirst();
|
|
511
|
+
}
|
|
512
|
+
resolveSettled();
|
|
513
|
+
update();
|
|
514
|
+
if (run.error) { hardStop(); finish("failed", run.error); }
|
|
515
|
+
else if (!persistent && child.stdin && !child.stdin.writableEnded) child.stdin.end();
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
170
518
|
}
|
|
171
519
|
};
|
|
520
|
+
|
|
521
|
+
child.stdout?.setEncoding?.("utf8");
|
|
172
522
|
child.stdout?.on("data", (chunk) => {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
523
|
+
const text = String(chunk);
|
|
524
|
+
let start = 0;
|
|
525
|
+
for (let end = text.indexOf("\n", start); end >= 0; end = text.indexOf("\n", start)) {
|
|
526
|
+
const item = buffer + text.slice(start, end);
|
|
527
|
+
if (!overflow) line(item.endsWith("\r") ? item.slice(0, -1) : item);
|
|
528
|
+
buffer = "";
|
|
529
|
+
overflow = false;
|
|
530
|
+
start = end + 1;
|
|
531
|
+
}
|
|
532
|
+
if (overflow) return;
|
|
533
|
+
buffer += text.slice(start);
|
|
534
|
+
if (buffer.length > MAX_LINE_CHARS) { buffer = ""; overflow = true; }
|
|
177
535
|
});
|
|
178
536
|
child.stderr?.on("data", (chunk) => { stderr = (stderr + String(chunk)).slice(-4000); });
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
|
|
537
|
+
const exited = (code: number | null) => {
|
|
538
|
+
alive = false;
|
|
539
|
+
clearTimeout(killTimer);
|
|
540
|
+
if (buffer && !overflow) { line(buffer); buffer = ""; }
|
|
541
|
+
if (code === 0 && settled && !run.error) finish("done");
|
|
542
|
+
else finish("failed", run.error ?? (cleanText(stderr, 300) || (settled ? `exited ${code}` : `exited ${code} before finishing`)));
|
|
543
|
+
if (!firstResolved) { firstResolved = true; resolveFirst(); }
|
|
544
|
+
resolveClosed();
|
|
545
|
+
};
|
|
546
|
+
child.on("error", (error) => { hardStop(); finish("failed", error.message); });
|
|
547
|
+
// Exit retires control immediately, but finalization waits for close so stdio drains first.
|
|
548
|
+
child.once("exit", (code) => {
|
|
549
|
+
alive = false;
|
|
550
|
+
clearTurnTimer();
|
|
551
|
+
clearTimeout(killTimer);
|
|
552
|
+
if (!finished) {
|
|
553
|
+
run.state = code === 0 && settled && !run.error ? "done" : "failed";
|
|
554
|
+
if (run.state === "failed") run.error ??= `exited ${code}`;
|
|
555
|
+
run.activity = undefined;
|
|
556
|
+
resolveSettled();
|
|
557
|
+
update();
|
|
558
|
+
}
|
|
184
559
|
});
|
|
560
|
+
child.once("close", exited);
|
|
561
|
+
|
|
562
|
+
steer = (text) => {
|
|
563
|
+
const message = cleanBlock(text, MAX_STEER_CHARS);
|
|
564
|
+
if (finished || closing || !message || run.state !== "working" || !send({ type: "steer", message })) return false;
|
|
565
|
+
pushEntry(run, { kind: "steer", rev: 0, text: message });
|
|
566
|
+
run.steered++;
|
|
567
|
+
update();
|
|
568
|
+
return true;
|
|
569
|
+
};
|
|
570
|
+
pause = async () => {
|
|
571
|
+
if (finished || closing || !alive) return false;
|
|
572
|
+
if (run.state === "paused") return true;
|
|
573
|
+
if (run.state === "idle") { run.state = "paused"; run.pauses++; update(); return true; }
|
|
574
|
+
if (run.state !== "working") return false;
|
|
575
|
+
pauseRequested = true;
|
|
576
|
+
run.pauses++;
|
|
577
|
+
run.activity = "pausing";
|
|
578
|
+
const waiting = waitSettled();
|
|
579
|
+
send({ type: "clear_queue" });
|
|
580
|
+
if (!send({ type: "abort" })) {
|
|
581
|
+
pauseRequested = false;
|
|
582
|
+
run.pauses = Math.max(0, run.pauses - 1);
|
|
583
|
+
run.activity = "thinking";
|
|
584
|
+
update();
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
update();
|
|
588
|
+
await waiting;
|
|
589
|
+
return (run.state as DelegateState) === "paused";
|
|
590
|
+
};
|
|
591
|
+
resume = (text) => {
|
|
592
|
+
if (run.state !== "paused" && run.state !== "idle") return false;
|
|
593
|
+
const message = text?.trim() || "Continue the assigned task from where you stopped. Review your current checklist and remaining work first.";
|
|
594
|
+
return beginPrompt(message);
|
|
595
|
+
};
|
|
596
|
+
ask = async (text) => {
|
|
597
|
+
const question = cleanBlock(text, MAX_STEER_CHARS);
|
|
598
|
+
if (!question || finished || closing || askBusy) return undefined;
|
|
599
|
+
askBusy = true;
|
|
600
|
+
const wasPaused = run.state === "paused";
|
|
601
|
+
try {
|
|
602
|
+
// Cross a settled boundary so the active task's report cannot answer the question.
|
|
603
|
+
if (run.state === "working" && !await pause()) return undefined;
|
|
604
|
+
const instruction = "Moderator BTW: answer only this question in one compact paragraph. Preserve your assigned task scope for the next resume. Question: " + question;
|
|
605
|
+
if (!beginPrompt(instruction, wasPaused)) return undefined;
|
|
606
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
607
|
+
const timeout = new Promise<boolean>((resolve) => {
|
|
608
|
+
timer = setTimeout(() => resolve(false), 2 * 60_000);
|
|
609
|
+
timer.unref?.();
|
|
610
|
+
});
|
|
611
|
+
const answered = await Promise.race([waitSettled().then(() => true), timeout]);
|
|
612
|
+
clearTimeout(timer);
|
|
613
|
+
if (!answered) { void pause(); return undefined; }
|
|
614
|
+
return !finished && !run.error ? run.output || undefined : undefined;
|
|
615
|
+
} finally { askBusy = false; }
|
|
616
|
+
};
|
|
617
|
+
shutdown = () => {
|
|
618
|
+
if (shutdownPromise) return shutdownPromise;
|
|
619
|
+
closing = true;
|
|
620
|
+
shutdownPromise = Promise.resolve().then(async () => {
|
|
621
|
+
if (finished) { await closed; return; }
|
|
622
|
+
if (run.state === "working") {
|
|
623
|
+
const waiting = waitSettled();
|
|
624
|
+
send({ type: "clear_queue" });
|
|
625
|
+
if (send({ type: "abort" })) {
|
|
626
|
+
let settledInTime = false;
|
|
627
|
+
const grace = new Promise<void>((resolve) => {
|
|
628
|
+
const timer = setTimeout(resolve, 3000);
|
|
629
|
+
timer.unref?.();
|
|
630
|
+
});
|
|
631
|
+
await Promise.race([waiting.then(() => { settledInTime = true; }), grace]);
|
|
632
|
+
if (!settledInTime && !finished) hardStop();
|
|
633
|
+
} else hardStop();
|
|
634
|
+
}
|
|
635
|
+
if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
|
|
636
|
+
const killer = setTimeout(() => { if (child.exitCode === null) hardStop(); }, 3000);
|
|
637
|
+
killer.unref?.();
|
|
638
|
+
await closed;
|
|
639
|
+
clearTimeout(killer);
|
|
640
|
+
});
|
|
641
|
+
return shutdownPromise;
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
if (signal?.aborted) { abort(); return; }
|
|
645
|
+
if (!send({ id: "prompt", type: "prompt", message: prompt })) { hardStop(); finish("failed", "could not send the task to the subagent"); }
|
|
185
646
|
});
|
|
647
|
+
return {
|
|
648
|
+
firstSettled,
|
|
649
|
+
closed,
|
|
650
|
+
steer: (text) => steer(text),
|
|
651
|
+
pause: () => pause(),
|
|
652
|
+
resume: (text) => resume(text),
|
|
653
|
+
ask: (text) => ask(text),
|
|
654
|
+
shutdown: () => shutdown(),
|
|
655
|
+
alive: () => alive
|
|
656
|
+
};
|
|
186
657
|
}
|
|
187
658
|
|
|
188
|
-
const
|
|
659
|
+
const isRunning = (state: DelegateState) => state === "queued" || state === "working";
|
|
660
|
+
const isRetained = (state: DelegateState) => isRunning(state) || state === "idle" || state === "paused";
|
|
189
661
|
|
|
190
|
-
export interface
|
|
662
|
+
export interface SubagentStopReport {
|
|
663
|
+
key: string;
|
|
664
|
+
name: string;
|
|
665
|
+
state: DelegateState;
|
|
666
|
+
task: string;
|
|
667
|
+
workspace?: string;
|
|
668
|
+
changed: string[];
|
|
669
|
+
applied: string[];
|
|
670
|
+
remaining: string[];
|
|
671
|
+
last: string;
|
|
672
|
+
error?: string;
|
|
673
|
+
}
|
|
674
|
+
export interface SubagentRecord {
|
|
675
|
+
key: string;
|
|
676
|
+
batch: number;
|
|
677
|
+
run: DelegateRun;
|
|
678
|
+
stop(): Promise<SubagentStopReport>;
|
|
679
|
+
pause(): Promise<boolean>;
|
|
680
|
+
resume(text?: string): boolean;
|
|
681
|
+
steer(text: string): boolean;
|
|
682
|
+
ask(text: string): Promise<string | undefined>;
|
|
683
|
+
changed(): string[];
|
|
684
|
+
discard(): void;
|
|
685
|
+
alive?(): boolean;
|
|
686
|
+
closed?(): Promise<void>;
|
|
687
|
+
}
|
|
191
688
|
|
|
192
689
|
/**
|
|
193
690
|
* Live subagents for the activity view. Tool details are saved in the session, so they stay small;
|
|
194
|
-
* the transcript and the per-run stop
|
|
691
|
+
* the transcript and the per-run stop and steer handles live only here, for this process.
|
|
195
692
|
*/
|
|
196
693
|
export class DelegateRegistry {
|
|
197
694
|
private entries = new Map<string, { record: SubagentRecord; finished?: number }>();
|
|
198
695
|
private listeners = new Set<() => void>();
|
|
199
696
|
private sequence = 0;
|
|
697
|
+
private reservations = 0;
|
|
698
|
+
private draining = new Set<SubagentRecord>();
|
|
699
|
+
|
|
700
|
+
/** Synchronous reservation prevents concurrent/reentrant batches from oversubscribing. */
|
|
701
|
+
reserve(count: number, maximum: number): (() => void) | undefined {
|
|
702
|
+
if (count < 1 || this.retained() + this.reservations + count > maximum) return undefined;
|
|
703
|
+
this.reservations += count;
|
|
704
|
+
let released = false;
|
|
705
|
+
return () => { if (!released) { released = true; this.reservations -= count; } };
|
|
706
|
+
}
|
|
200
707
|
|
|
201
708
|
subscribe(listener: () => void): () => void {
|
|
202
709
|
this.listeners.add(listener);
|
|
203
710
|
return () => { this.listeners.delete(listener); };
|
|
204
711
|
}
|
|
205
|
-
/**
|
|
712
|
+
/** Retained agents first (oldest first), then retired ones newest first. */
|
|
206
713
|
records(): SubagentRecord[] {
|
|
207
714
|
this.sweep();
|
|
208
715
|
const live: SubagentRecord[] = [];
|
|
@@ -214,159 +721,660 @@ export class DelegateRegistry {
|
|
|
214
721
|
return [...live, ...finished.map((entry) => entry.record)];
|
|
215
722
|
}
|
|
216
723
|
get(key: string): SubagentRecord | undefined { return this.entries.get(key)?.record; }
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
record.
|
|
724
|
+
resolve(ref: string): SubagentRecord | undefined {
|
|
725
|
+
const exact = this.get(ref);
|
|
726
|
+
if (exact) return exact;
|
|
727
|
+
const name = cleanText(ref, 48).toLowerCase();
|
|
728
|
+
const matches = this.records().filter((record) => record.run.name.toLowerCase() === name);
|
|
729
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
730
|
+
}
|
|
731
|
+
async stop(key: string): Promise<SubagentStopReport | undefined> {
|
|
732
|
+
const record = this.resolve(key);
|
|
733
|
+
if (!record || record.run.state === "stopped" || (record.run.state === "done" && !record.run.workspace)) return undefined;
|
|
734
|
+
const report = await record.stop();
|
|
735
|
+
this.notify();
|
|
736
|
+
return report;
|
|
737
|
+
}
|
|
738
|
+
async pause(key: string): Promise<boolean> {
|
|
739
|
+
const record = this.resolve(key);
|
|
740
|
+
if (!record || !isRetained(record.run.state)) return false;
|
|
741
|
+
const paused = await record.pause();
|
|
222
742
|
this.notify();
|
|
223
|
-
return
|
|
743
|
+
return paused;
|
|
744
|
+
}
|
|
745
|
+
resume(key: string, text?: string): boolean {
|
|
746
|
+
const record = this.resolve(key);
|
|
747
|
+
if (!record || !isRetained(record.run.state)) return false;
|
|
748
|
+
const resumed = record.resume(text);
|
|
749
|
+
if (resumed) this.notify();
|
|
750
|
+
return resumed;
|
|
751
|
+
}
|
|
752
|
+
steer(key: string, text: string): boolean {
|
|
753
|
+
const record = this.resolve(key);
|
|
754
|
+
return !!record && record.run.state === "working" && record.steer(text);
|
|
755
|
+
}
|
|
756
|
+
async ask(key: string, text: string): Promise<string | undefined> {
|
|
757
|
+
const record = this.resolve(key);
|
|
758
|
+
if (!record || !isRetained(record.run.state)) return undefined;
|
|
759
|
+
return record.ask(text);
|
|
224
760
|
}
|
|
225
761
|
running(): number {
|
|
226
762
|
let count = 0;
|
|
227
|
-
for (const { record } of this.entries.values()) if (
|
|
763
|
+
for (const { record } of this.entries.values()) if (isRunning(record.run.state)) count++;
|
|
764
|
+
return count;
|
|
765
|
+
}
|
|
766
|
+
retained(): number {
|
|
767
|
+
let count = this.draining.size;
|
|
768
|
+
for (const { record } of this.entries.values()) if (isRetained(record.run.state) || record.alive?.()) count++;
|
|
228
769
|
return count;
|
|
229
770
|
}
|
|
230
|
-
/**
|
|
771
|
+
/** Session changes discard retained workers and their private workspaces; they never auto-apply here. */
|
|
231
772
|
clear(): void {
|
|
232
|
-
const
|
|
773
|
+
const records = [...this.entries.values()].map(({ record }) => record);
|
|
233
774
|
this.entries.clear();
|
|
234
|
-
for (const
|
|
775
|
+
for (const record of records) {
|
|
776
|
+
if (record.closed && (isRetained(record.run.state) || record.alive?.())) {
|
|
777
|
+
this.draining.add(record);
|
|
778
|
+
void record.closed().finally(() => { this.draining.delete(record); this.notify(); });
|
|
779
|
+
}
|
|
780
|
+
try { record.discard(); } catch { /* best effort */ }
|
|
781
|
+
}
|
|
235
782
|
this.notify();
|
|
236
783
|
}
|
|
237
|
-
/** jar_delegate's side: list new runs. */
|
|
238
784
|
add(...records: SubagentRecord[]): void {
|
|
239
785
|
for (const record of records) this.entries.set(record.key, { record });
|
|
240
786
|
this.notify();
|
|
241
787
|
}
|
|
242
|
-
/** jar_delegate's side: a run changed. */
|
|
243
788
|
notify(): void {
|
|
244
789
|
this.sweep();
|
|
245
790
|
for (const listener of this.listeners) {
|
|
246
791
|
try { listener(); } catch { /* views are decoration; a broken one must not break the run */ }
|
|
247
792
|
}
|
|
248
793
|
}
|
|
249
|
-
/** Stamp runs in the order they finish and keep only the newest MAX_FINISHED of them. */
|
|
250
794
|
private sweep(): void {
|
|
251
795
|
let finished = 0;
|
|
252
796
|
for (const entry of this.entries.values()) {
|
|
253
|
-
if (
|
|
797
|
+
if (isRetained(entry.record.run.state) || entry.record.alive?.()) { entry.finished = undefined; continue; }
|
|
254
798
|
entry.finished ??= ++this.sequence;
|
|
255
799
|
finished++;
|
|
256
800
|
}
|
|
257
801
|
if (finished <= MAX_FINISHED) return;
|
|
258
|
-
|
|
802
|
+
// Conflicted/failed workspaces are recovery state, not disposable transcript history.
|
|
803
|
+
const oldest = [...this.entries].filter(([, entry]) => entry.finished !== undefined && !entry.record.run.workspace)
|
|
804
|
+
.sort((a, b) => a[1].finished! - b[1].finished!);
|
|
259
805
|
for (const [key] of oldest.slice(0, finished - MAX_FINISHED)) this.entries.delete(key);
|
|
260
806
|
}
|
|
261
807
|
}
|
|
262
808
|
|
|
809
|
+
/** `12s` / `3m 4s`. */
|
|
810
|
+
export const duration = (ms: number) => { const s = Math.max(0, Math.round(ms / 1000)); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`; };
|
|
811
|
+
const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
812
|
+
const fileList = (files: readonly string[], limit: number) => files.slice(0, limit).join(", ") + (files.length > limit ? ` (+${files.length - limit} more)` : "");
|
|
813
|
+
const todoLine = (todo: Todo) => `${todo.parentId ? " " : " "}${todo.status === "completed" ? "[x]" : todo.status === "in_progress" ? "[~]" : "[ ]"} ${todo.title}`;
|
|
814
|
+
|
|
815
|
+
/** What the parent agent reads back: the run's facts (time, tools, files, tasks, failures), then its own report. */
|
|
816
|
+
export function runReport(run: DelegateRun, now = Date.now()): string {
|
|
817
|
+
const counts = Object.entries(run.toolCounts).sort((a, b) => b[1] - a[1]).map(([name, count]) => `${name}×${count}`).join(", ");
|
|
818
|
+
const facts = [
|
|
819
|
+
run.startedAt !== undefined ? `took ${duration((run.endedAt ?? now) - run.startedAt)}` : "never started",
|
|
820
|
+
plural(run.turns, "turn"), `${plural(run.tools, "tool call")}${counts ? ` (${counts})` : ""}`,
|
|
821
|
+
run.cost ? `$${run.cost.toFixed(3)}` : "", run.steered ? `steered ${run.steered}×` : ""
|
|
822
|
+
].filter(Boolean).join(" · ");
|
|
823
|
+
const lines = [`## [${run.index}] ${run.name} (${run.role}${run.model ? " · " + run.model : ""}) — ${run.state}${run.error ? ": " + run.error : ""}`, `- ${facts}`];
|
|
824
|
+
if (run.mode === "fork" || run.mode === "worktree") lines.push(`- mode: ${run.mode}`);
|
|
825
|
+
if (run.filesEdited.length) lines.push(`- files edited: ${fileList(run.filesEdited, 20)}`);
|
|
826
|
+
if (run.appliedFiles.length) lines.push(`- applied to parent: ${fileList(run.appliedFiles, 20)}`);
|
|
827
|
+
if (run.workspace) lines.push(`- workspace: ${run.workspace}`);
|
|
828
|
+
if (run.filesRead.length) lines.push(`- files read: ${fileList(run.filesRead, 12)}`);
|
|
829
|
+
if (run.todos.length) {
|
|
830
|
+
const leaves = leafTodos(run.todos);
|
|
831
|
+
lines.push(`- tasks: ${leaves.filter((todo) => todo.done).length}/${leaves.length} done`, ...run.todos.map(todoLine));
|
|
832
|
+
}
|
|
833
|
+
const failures = run.transcript.filter((entry): entry is ToolEntry => entry.kind === "tool" && entry.status === "error").slice(-5);
|
|
834
|
+
if (failures.length) lines.push(`- failed tool calls: ${failures.map((entry) => cleanText(`${entry.name} ${entry.hint}`, 80)).join("; ")}`);
|
|
835
|
+
lines.push("", run.output || "(no report)");
|
|
836
|
+
return lines.join("\n");
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const DelegateModeSchema = Type.Union([Type.Literal("scout"), Type.Literal("fork"), Type.Literal("worktree")]);
|
|
263
840
|
const Parameters = Type.Object({
|
|
264
841
|
tasks: Type.Array(Type.Object({
|
|
265
|
-
task: Type.String({ description: "
|
|
842
|
+
task: Type.String({ description: "Task instruction. scout sees only this task; fork/worktree also inherit the parent's active conversation branch." }),
|
|
266
843
|
name: Type.Optional(Type.String({ description: "Short label, e.g. \"auth scout\"." })),
|
|
267
|
-
role: Type.Optional(Type.String({ description: "pi-jar role
|
|
268
|
-
|
|
269
|
-
|
|
844
|
+
role: Type.Optional(Type.String({ description: "pi-jar role override. Defaults by mode: scout→scout, fork→reviewer, worktree→worker; then role fallbacks/current model." })),
|
|
845
|
+
mode: Type.Optional(DelegateModeSchema)
|
|
846
|
+
}), { minItems: 1, maxItems: 16 }),
|
|
847
|
+
mode: Type.Optional(DelegateModeSchema),
|
|
848
|
+
write: Type.Optional(Type.Boolean({ description: "Deprecated compatibility switch. true keeps the old shared-workspace editing mode; prefer mode=\"worktree\"." }))
|
|
270
849
|
});
|
|
271
850
|
|
|
272
|
-
const glyph = (state: DelegateState) => state === "done" ? "✔" : state === "failed" ? "✖" : state === "working" ? "●" : "○";
|
|
851
|
+
const glyph = (state: DelegateState) => state === "done" ? "✔" : state === "failed" ? "✖" : state === "stopped" ? "■" : state === "working" ? "●" : "○";
|
|
273
852
|
const color = (state: DelegateState) => state === "done" ? "success" : state === "failed" ? "error" : state === "working" ? "accent" : "dim";
|
|
853
|
+
type RunDetails = Omit<DelegateRun, "transcript" | "live">;
|
|
854
|
+
|
|
855
|
+
/** Plain orchestration snapshots. id/key are the stable registry key. */
|
|
856
|
+
export interface SubagentReport {
|
|
857
|
+
id: string;
|
|
858
|
+
key: string;
|
|
859
|
+
name: string;
|
|
860
|
+
task: string;
|
|
861
|
+
mode: DelegateExecutionMode;
|
|
862
|
+
state: DelegateState;
|
|
863
|
+
output: string;
|
|
864
|
+
error?: string;
|
|
865
|
+
}
|
|
866
|
+
export interface DelegateController {
|
|
867
|
+
list(): SubagentReport[];
|
|
868
|
+
spawnScout(task: string, signal?: AbortSignal): Promise<SubagentReport>;
|
|
869
|
+
resumeScout(id: string, prompt: string, signal?: AbortSignal): Promise<SubagentReport>;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
export interface DelegateOptions {
|
|
873
|
+
/** Allowed sizes: 2, 4, 6, 8, 16; defaults to 4. Read on every launch. */
|
|
874
|
+
getMaxSubagents?: () => number;
|
|
875
|
+
/** Shares the delegate lifecycle and capacity with scout orchestration. */
|
|
876
|
+
onController?: (controller: DelegateController) => void;
|
|
877
|
+
spawnProcess?: Spawn;
|
|
878
|
+
/** The parent's change tracker: finalized worktree edits join /diff. */
|
|
879
|
+
changes?: () => ChangeTracker | undefined;
|
|
880
|
+
/** Called after a subagent edit was added to the tracker. */
|
|
881
|
+
changed?: () => void;
|
|
882
|
+
/** Session-scoped shared discussion paper. */
|
|
883
|
+
discussionFile?: () => string | undefined;
|
|
884
|
+
}
|
|
274
885
|
|
|
275
|
-
|
|
276
|
-
|
|
886
|
+
const roleForMode = (mode: DelegateExecutionMode): string =>
|
|
887
|
+
mode === "scout" ? "scout" : mode === "fork" ? "reviewer" : mode === "worktree" ? "worker" : "task";
|
|
888
|
+
|
|
889
|
+
const remainingTasks = (run: DelegateRun): string[] => {
|
|
890
|
+
const leaves = leafTodos(run.todos);
|
|
891
|
+
return leaves.filter((todo) => todo.status !== "completed").map((todo) => todo.title).slice(0, 20);
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
const controlReport = (report: SubagentStopReport): string => [
|
|
895
|
+
`${report.key} · ${report.name} · ${report.state}`,
|
|
896
|
+
`task: ${report.task}`,
|
|
897
|
+
`workspace: ${report.workspace ?? "none"}`,
|
|
898
|
+
`changed: ${report.changed.length ? report.changed.join(", ") : "none"}`,
|
|
899
|
+
`applied: ${report.applied.length ? report.applied.join(", ") : "none"}`,
|
|
900
|
+
`remaining: ${report.remaining.length ? report.remaining.join("; ") : "none explicitly tracked"}`,
|
|
901
|
+
report.error ? `error: ${report.error}` : "",
|
|
902
|
+
report.last ? `last: ${cleanBlock(report.last, 1200)}` : ""
|
|
903
|
+
].filter(Boolean).join("\n");
|
|
904
|
+
|
|
905
|
+
const peekRecord = (record: SubagentRecord): string => {
|
|
906
|
+
const run = record.run;
|
|
907
|
+
const leaves = leafTodos(run.todos);
|
|
908
|
+
const done = leaves.filter((todo) => todo.status === "completed").length;
|
|
909
|
+
const changed = (() => { try { return record.changed(); } catch { return run.filesEdited; } })();
|
|
910
|
+
const remaining = remainingTasks(run);
|
|
911
|
+
return [
|
|
912
|
+
`${record.key} · ${run.name} · ${run.state} · ${run.role}${run.model ? " · " + run.model : ""} · ${run.mode}`,
|
|
913
|
+
`task: ${cleanText(run.task, 300)}`,
|
|
914
|
+
`progress: ${leaves.length ? done + "/" + leaves.length + " tasks" : "no checklist"}${run.activity ? " · " + cleanText(run.activity, 100) : ""}`,
|
|
915
|
+
`changed: ${changed.length ? fileList(changed, 12) : "none"}`,
|
|
916
|
+
remaining.length ? `remaining: ${remaining.join("; ")}` : "remaining: none explicitly tracked",
|
|
917
|
+
run.workspace ? `workspace: ${run.workspace}` : "",
|
|
918
|
+
run.output ? `last: ${cleanBlock(run.output, 700)}` : ""
|
|
919
|
+
].filter(Boolean).join("\n");
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
/** jar_delegate plus jar_subagent: the parent remains a moderator while retained workers do the work. */
|
|
923
|
+
export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, registry: DelegateRegistry, options: DelegateOptions = {}): void {
|
|
277
924
|
if (process.env[CHILD_ENV] || typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool !== "function") return;
|
|
278
925
|
let batch = 0;
|
|
279
|
-
|
|
926
|
+
let controllerContext: ExtensionContext | undefined;
|
|
927
|
+
const maxSubagents = () => {
|
|
928
|
+
const value = options.getMaxSubagents?.() ?? MAX_DELEGATES;
|
|
929
|
+
return SUBAGENT_LIMIT_CHOICES.includes(value as never) ? value : MAX_DELEGATES;
|
|
930
|
+
};
|
|
931
|
+
pi.on?.("session_start", (_event, ctx) => { controllerContext = ctx; });
|
|
932
|
+
const roleCandidates = (role: string) => {
|
|
933
|
+
const manager = roles as ModelRoleManager & { resolveCandidates?: (name: string) => ReturnType<ModelRoleManager["resolveCandidates"]> };
|
|
934
|
+
if (typeof manager.resolveCandidates === "function") return manager.resolveCandidates(role);
|
|
935
|
+
const resolved = roles.resolve(role);
|
|
936
|
+
return resolved ? [resolved] : [];
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
let moderatorContextDirty = true;
|
|
940
|
+
registry.subscribe(() => { moderatorContextDirty = true; });
|
|
941
|
+
pi.on?.("before_agent_start", (_event, ctx) => {
|
|
942
|
+
controllerContext = ctx;
|
|
943
|
+
moderatorContextDirty = true;
|
|
944
|
+
const retained = registry.records().filter((record) => isRetained(record.run.state));
|
|
945
|
+
if (!retained.length) return;
|
|
946
|
+
const fleet = retained.map((record) =>
|
|
947
|
+
`- ${record.key} · ${record.run.name} · ${record.run.state} · ${record.run.role}: ${cleanText(record.run.task, 140)}`).join("\n");
|
|
948
|
+
return { message: { customType: "pi-jar.moderator-context", display: false, content: [
|
|
949
|
+
"[PI-JAR MODERATOR MODE]",
|
|
950
|
+
"You are the coordinator while retained subagents do delegated work. Decompose and route work, inspect with jar_subagent peek, steer only to correct direction, use ask for terse BTW questions, and use jar_discuss for structured cross-agent Q/A.",
|
|
951
|
+
"Do not duplicate work already owned by a retained subagent. Stop completed workers to obtain their handoff and reconcile worktree changes into /diff. Synthesize the final answer from their reports and evidence.",
|
|
952
|
+
"Retained fleet:",
|
|
953
|
+
fleet
|
|
954
|
+
].join("\n") } };
|
|
955
|
+
});
|
|
956
|
+
pi.on?.("context", (event) => {
|
|
957
|
+
if (!moderatorContextDirty) return;
|
|
958
|
+
const active = registry.retained() > 0;
|
|
959
|
+
let latest = -1;
|
|
960
|
+
let prune = false;
|
|
961
|
+
for (let index = event.messages.length - 1; index >= 0; index--) {
|
|
962
|
+
if ((event.messages[index] as { customType?: string }).customType !== "pi-jar.moderator-context") continue;
|
|
963
|
+
if (!active || latest >= 0) prune = true;
|
|
964
|
+
else latest = index;
|
|
965
|
+
}
|
|
966
|
+
moderatorContextDirty = false;
|
|
967
|
+
if (!prune) return;
|
|
968
|
+
return { messages: event.messages.filter((raw, index) =>
|
|
969
|
+
(raw as { customType?: string }).customType !== "pi-jar.moderator-context" || (active && index === latest)) };
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
const delegateTool: ToolDefinition<typeof Parameters> = {
|
|
280
973
|
name: DELEGATE_TOOL,
|
|
281
974
|
label: "delegate",
|
|
282
|
-
description: `
|
|
283
|
-
promptSnippet: "
|
|
975
|
+
description: `Spawn retained session-scoped subagents within the configured live pool limit. scout is fresh/read-only and defaults to the scout role; fork inherits the parent conversation read-only and defaults to reviewer; worktree inherits context, defaults to worker, and edits in an isolated Git worktree. Retained agents become idle after a turn and are controlled with jar_subagent. Worktree changes apply only when the moderator stops that agent.`,
|
|
976
|
+
promptSnippet: "Act as moderator: delegate parallel work, inspect progress with jar_subagent peek, steer only when needed, and synthesize results instead of duplicating subagent work.",
|
|
284
977
|
promptGuidelines: [
|
|
285
|
-
"
|
|
286
|
-
"
|
|
978
|
+
"When useful work can be delegated, act as the moderator: decompose, assign, monitor, resolve disagreements, and synthesize. Do not redo a delegated implementation yourself while its worker is active.",
|
|
979
|
+
"Use scout for cheap independent discovery, fork/reviewer for context-aware review, and worktree/worker for implementation. Explicit task.role overrides these mode defaults.",
|
|
980
|
+
"Retained agents become idle after each turn. Reuse them with jar_subagent resume instead of spawning replacements; pause them when priorities change; stop completed workers to reconcile their worktree into /diff.",
|
|
981
|
+
"Use jar_subagent ask for a brief BTW question to one worker. For agent-to-agent questions, have them use the bounded jar_discuss paper so answers stay structured and cheap.",
|
|
982
|
+
"Respect the configured pool limit, including idle and paused agents. Stop agents you no longer need."
|
|
287
983
|
],
|
|
288
984
|
parameters: Parameters,
|
|
289
985
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
986
|
+
controllerContext = ctx;
|
|
987
|
+
const requested = params.tasks;
|
|
988
|
+
const maximum = maxSubagents();
|
|
989
|
+
const releaseReservation = registry.reserve(requested.length, maximum);
|
|
990
|
+
if (!releaseReservation) {
|
|
991
|
+
return { content: [{ type: "text", text: `Subagent pool is full: ${registry.retained()}/${maximum} retained. Reuse or stop an existing agent with jar_subagent.` }], details: { runs: [], write: false }, isError: true };
|
|
992
|
+
}
|
|
993
|
+
try {
|
|
994
|
+
|
|
290
995
|
const id = ++batch;
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
996
|
+
const defaultMode: DelegateExecutionMode = params.mode ?? (params.write === true ? "direct" : "scout");
|
|
997
|
+
let available = new Set<string>();
|
|
998
|
+
try { available = new Set(ctx.modelRegistry.getAvailable().map((model) => model.provider + "/" + model.id)); } catch { /* current model remains the fallback */ }
|
|
999
|
+
const thinkingByRun = new Map<number, string | undefined>();
|
|
1000
|
+
const runs: DelegateRun[] = requested.map((item, index) => {
|
|
1001
|
+
const mode: DelegateExecutionMode = item.mode ?? defaultMode;
|
|
1002
|
+
const role = cleanText(item.role ?? roleForMode(mode), 32) || roleForMode(mode);
|
|
1003
|
+
const candidates = [...roleCandidates(role), ...roleCandidates("default")];
|
|
1004
|
+
const selected = candidates.find((candidate) => available.has(candidate.provider + "/" + candidate.model));
|
|
1005
|
+
const model = selected ? `${selected.provider}/${selected.model}` : ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
1006
|
+
thinkingByRun.set(index + 1, selected?.thinking);
|
|
1007
|
+
return {
|
|
1008
|
+
index: index + 1, name: cleanText(item.name ?? `${role} ${index + 1}`, 24), task: item.task, role, mode, ...(model ? { model } : {}),
|
|
1009
|
+
state: "queued", tools: 0, turns: 0, cost: 0, output: "", toolCounts: {}, filesRead: [], filesEdited: [], appliedFiles: [],
|
|
1010
|
+
pauses: 0, resumes: 0, todos: [], steered: 0, transcript: [], live: ""
|
|
1011
|
+
};
|
|
298
1012
|
});
|
|
299
|
-
|
|
1013
|
+
|
|
300
1014
|
const controllers = runs.map(() => new AbortController());
|
|
301
1015
|
const abortAll = () => { for (const controller of controllers) controller.abort(); };
|
|
302
1016
|
if (signal?.aborted) abortAll(); else signal?.addEventListener("abort", abortAll, { once: true });
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
1017
|
+
let handles: DelegateHandle[] = [];
|
|
1018
|
+
let records: SubagentRecord[] = [];
|
|
1019
|
+
const worktrees = new Map<number, DelegateWorktree>();
|
|
1020
|
+
const sessionDirs = new Map<number, string>();
|
|
1021
|
+
const parentSession = ctx.sessionManager?.getSessionFile?.();
|
|
1022
|
+
|
|
1023
|
+
let baselines: string | undefined;
|
|
1024
|
+
if (runs.some((run) => run.mode === "direct") && options.changes) {
|
|
1025
|
+
try { baselines = mkdtempSync(join(tmpdir(), "pi-jar-baselines-")); }
|
|
1026
|
+
catch (error) { console.error("pi-jar: direct subagent edits will not join /diff", error); }
|
|
1027
|
+
}
|
|
1028
|
+
const edited = (path: string) => {
|
|
1029
|
+
const tracker = options.changes?.();
|
|
1030
|
+
if (!baselines || !tracker) return;
|
|
1031
|
+
const absolute = isAbsolute(path) ? resolve(path) : resolve(ctx.cwd, path);
|
|
1032
|
+
const baseline = readChildBaseline(baselines, absolute);
|
|
1033
|
+
if (baseline !== undefined && tracker.adopt(absolute, baseline)) options.changed?.();
|
|
1034
|
+
};
|
|
1035
|
+
|
|
308
1036
|
const publish = () => {
|
|
309
1037
|
if (!ctx.hasUI) return;
|
|
310
1038
|
for (const { key, run } of records) {
|
|
311
1039
|
try {
|
|
312
|
-
ctx.ui.setStatus(`${ROLE_PREFIX}${key}`, run.state === "done" || run.state === "failed" ? undefined : JSON.stringify({
|
|
313
|
-
name: run.name, label: run.name.slice(0, 12),
|
|
1040
|
+
ctx.ui.setStatus(`${ROLE_PREFIX}${key}`, run.state === "done" || run.state === "failed" || run.state === "stopped" ? undefined : JSON.stringify({
|
|
1041
|
+
name: run.name, label: run.name.slice(0, 12),
|
|
1042
|
+
state: run.state === "queued" || run.state === "paused" ? "waiting" : run.state === "idle" ? "idle" : "working",
|
|
1043
|
+
task: cleanText(run.task, 60), expiresAt: Date.now() + 24 * 60 * 60_000
|
|
314
1044
|
}));
|
|
315
1045
|
} catch { /* status is decoration */ }
|
|
316
1046
|
}
|
|
317
1047
|
};
|
|
318
|
-
|
|
319
|
-
|
|
1048
|
+
const details = (includeOutput = true) => ({
|
|
1049
|
+
batch: id,
|
|
1050
|
+
runs: runs.map(({ transcript: _transcript, live: _live, ...run }): RunDetails => includeOutput ? run : { ...run, output: "" }),
|
|
1051
|
+
write: runs.some((run) => run.mode === "direct" || run.mode === "worktree")
|
|
1052
|
+
});
|
|
320
1053
|
let updateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
321
1054
|
let lastUpdateAt = 0;
|
|
1055
|
+
let toolReturned = false;
|
|
322
1056
|
const emitUpdate = () => {
|
|
323
1057
|
updateTimer = undefined;
|
|
324
1058
|
lastUpdateAt = Date.now();
|
|
325
1059
|
publish();
|
|
326
|
-
onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}`).join("\n") }], details: details(false) });
|
|
1060
|
+
onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}${run.activity ? " · " + run.activity : ""}`).join("\n") }], details: details(false) });
|
|
327
1061
|
};
|
|
328
1062
|
const update = () => {
|
|
329
1063
|
registry.notify();
|
|
330
|
-
if (!onUpdate) { publish(); return; }
|
|
1064
|
+
if (toolReturned || !onUpdate) { publish(); return; }
|
|
331
1065
|
const wait = Math.max(0, LIVE_UPDATE_MS - (Date.now() - lastUpdateAt));
|
|
332
|
-
if (wait === 0) { if (updateTimer)
|
|
333
|
-
if (!updateTimer) {
|
|
334
|
-
updateTimer = setTimeout(emitUpdate, wait);
|
|
335
|
-
updateTimer.unref?.();
|
|
336
|
-
}
|
|
1066
|
+
if (wait === 0) { if (updateTimer) clearTimeout(updateTimer); emitUpdate(); return; }
|
|
1067
|
+
if (!updateTimer) { updateTimer = setTimeout(emitUpdate, wait); updateTimer.unref?.(); }
|
|
337
1068
|
};
|
|
338
1069
|
const flushUpdate = () => {
|
|
339
1070
|
if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; }
|
|
340
1071
|
emitUpdate();
|
|
341
1072
|
};
|
|
342
|
-
const
|
|
343
|
-
|
|
1073
|
+
const failedHandle = (): DelegateHandle => ({
|
|
1074
|
+
firstSettled: Promise.resolve(), closed: Promise.resolve(), steer: () => false, pause: async () => false,
|
|
1075
|
+
resume: () => false, ask: async () => undefined, shutdown: async () => {}, alive: () => false
|
|
1076
|
+
});
|
|
1077
|
+
const cleanupPrivate = (index: number, discardWorktree: boolean) => {
|
|
1078
|
+
const dir = sessionDirs.get(index);
|
|
1079
|
+
if (dir) { rmSync(dir, { recursive: true, force: true }); sessionDirs.delete(index); }
|
|
1080
|
+
if (discardWorktree) {
|
|
1081
|
+
const worktree = worktrees.get(index);
|
|
1082
|
+
if (worktree) {
|
|
1083
|
+
try { disposeDelegateWorktree(worktree); } catch { /* best effort */ }
|
|
1084
|
+
worktrees.delete(index);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1088
|
+
const failBeforeStart = (run: DelegateRun, error: unknown) => {
|
|
1089
|
+
run.state = "failed";
|
|
1090
|
+
run.error = cleanText(error instanceof Error ? error.message : String(error), 300);
|
|
1091
|
+
run.endedAt = Date.now();
|
|
1092
|
+
update();
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
let discarded = false;
|
|
1096
|
+
handles = runs.map(failedHandle);
|
|
1097
|
+
const readyResolvers: Array<() => void> = [];
|
|
1098
|
+
const ready = runs.map(() => new Promise<void>((resolve) => readyResolvers.push(resolve)));
|
|
1099
|
+
const startRuns = () => runs.forEach((run, index) => {
|
|
1100
|
+
if (discarded) { run.state = "stopped"; readyResolvers[index]!(); return; }
|
|
1101
|
+
const start = (): DelegateHandle => {
|
|
1102
|
+
if ((run.mode === "fork" || run.mode === "worktree") && !parentSession) {
|
|
1103
|
+
failBeforeStart(run, "fork/worktree mode requires a persisted parent Pi session");
|
|
1104
|
+
return failedHandle();
|
|
1105
|
+
}
|
|
1106
|
+
let cwd = ctx.cwd;
|
|
1107
|
+
let fork: DelegateForkOptions | undefined;
|
|
1108
|
+
const env: Record<string, string> = {
|
|
1109
|
+
[SUBAGENT_KEY_ENV]: `delegate-${id}-${run.index}`,
|
|
1110
|
+
[SUBAGENT_NAME_ENV]: run.name
|
|
1111
|
+
};
|
|
1112
|
+
const discussion = options.discussionFile?.();
|
|
1113
|
+
if (discussion) env[DISCUSSION_FILE_ENV] = discussion;
|
|
1114
|
+
const hooks: DelegateHooks = {};
|
|
1115
|
+
const write = run.mode === "direct" || run.mode === "worktree";
|
|
1116
|
+
|
|
1117
|
+
if (run.mode === "fork" || run.mode === "worktree") {
|
|
1118
|
+
try {
|
|
1119
|
+
const sessionDir = mkdtempSync(join(tmpdir(), "pi-jar-fork-"));
|
|
1120
|
+
sessionDirs.set(run.index, sessionDir);
|
|
1121
|
+
fork = { source: parentSession!, sessionDir, ...(run.mode === "worktree" ? { tools: WORKTREE_TOOLS } : {}) };
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
failBeforeStart(run, error);
|
|
1124
|
+
return failedHandle();
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
if (run.mode === "worktree") {
|
|
1128
|
+
try {
|
|
1129
|
+
const worktree = createDelegateWorktree(ctx.cwd);
|
|
1130
|
+
worktrees.set(run.index, worktree);
|
|
1131
|
+
run.workspace = worktree.root;
|
|
1132
|
+
cwd = worktree.cwd;
|
|
1133
|
+
env[CHILD_WORKTREE_ENV] = worktree.root;
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
cleanupPrivate(run.index, true);
|
|
1136
|
+
failBeforeStart(run, error);
|
|
1137
|
+
return failedHandle();
|
|
1138
|
+
}
|
|
1139
|
+
} else if (run.mode === "direct" && baselines) {
|
|
1140
|
+
env[CHILD_BASELINE_ENV] = baselines;
|
|
1141
|
+
hooks.edited = edited;
|
|
1142
|
+
}
|
|
1143
|
+
hooks.env = env;
|
|
1144
|
+
return startDelegate(run, delegateArgs(run.model, thinkingByRun.get(run.index), write, process.argv, fork),
|
|
1145
|
+
delegatePrompt(run.task, write, run.mode), cwd, controllers[index]!.signal, update, options.spawnProcess, hooks, run.mode !== "direct");
|
|
1146
|
+
};
|
|
1147
|
+
let handle: DelegateHandle;
|
|
1148
|
+
try { handle = start(); }
|
|
1149
|
+
catch (error) { failBeforeStart(run, error); handle = failedHandle(); }
|
|
1150
|
+
handles[index] = handle;
|
|
1151
|
+
readyResolvers[index]!();
|
|
1152
|
+
void handle.closed.then(() => {
|
|
1153
|
+
cleanupPrivate(run.index, discarded || run.mode !== "worktree");
|
|
1154
|
+
update();
|
|
1155
|
+
});
|
|
1156
|
+
if (discarded) void handle.shutdown().finally(() => cleanupPrivate(run.index, true));
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
const stopping = new Map<number, Promise<SubagentStopReport>>();
|
|
1160
|
+
const stopRequested = new Set<number>();
|
|
1161
|
+
const stopRun = (index: number): Promise<SubagentStopReport> => {
|
|
1162
|
+
const existing = stopping.get(index);
|
|
1163
|
+
if (existing) return existing;
|
|
1164
|
+
stopRequested.add(index);
|
|
1165
|
+
const promise = finalizeRun(index);
|
|
1166
|
+
stopping.set(index, promise);
|
|
1167
|
+
// A reconciliation conflict is retryable after the parent drift is resolved.
|
|
1168
|
+
void promise.then(() => { if (worktrees.has(runs[index]!.index)) stopping.delete(index); });
|
|
1169
|
+
return promise;
|
|
1170
|
+
};
|
|
1171
|
+
const finalizeRun = async (index: number): Promise<SubagentStopReport> => {
|
|
1172
|
+
const run = runs[index]!;
|
|
1173
|
+
const key = `delegate-${id}-${run.index}`;
|
|
1174
|
+
const workspace = run.workspace;
|
|
1175
|
+
if (run.error?.startsWith("changes not applied:")) run.error = undefined;
|
|
1176
|
+
await ready[index];
|
|
1177
|
+
const handle = handles[index]!;
|
|
1178
|
+
await handle.shutdown();
|
|
1179
|
+
|
|
1180
|
+
let changed: string[] = [];
|
|
1181
|
+
const worktree = worktrees.get(run.index);
|
|
1182
|
+
if (worktree && !discarded) {
|
|
1183
|
+
try { changed = worktreeChangedFiles(worktree); run.filesEdited = changed; }
|
|
1184
|
+
catch (error) { run.error = "could not inspect isolated changes: " + cleanText(String(error), 240); }
|
|
1185
|
+
if (changed.length && !run.error) {
|
|
1186
|
+
try {
|
|
1187
|
+
run.appliedFiles = applyDelegateWorktree(worktree, ctx.cwd, changed, options.changes?.());
|
|
1188
|
+
if (run.appliedFiles.length) {
|
|
1189
|
+
try { options.changed?.(); } catch (error) { console.error("pi-jar: could not refresh parent change UI", error); }
|
|
1190
|
+
}
|
|
1191
|
+
disposeDelegateWorktree(worktree);
|
|
1192
|
+
worktrees.delete(run.index);
|
|
1193
|
+
run.workspace = undefined;
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
if (error instanceof WorktreeApplyError) run.appliedFiles = error.appliedFiles;
|
|
1196
|
+
run.error = "changes not applied: " + cleanText(error instanceof Error ? error.message : String(error), 240);
|
|
1197
|
+
}
|
|
1198
|
+
} else if (!changed.length) {
|
|
1199
|
+
try { disposeDelegateWorktree(worktree); } catch { /* best effort */ }
|
|
1200
|
+
worktrees.delete(run.index);
|
|
1201
|
+
run.workspace = undefined;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
cleanupPrivate(run.index, false);
|
|
1205
|
+
run.state = run.error ? "failed" : "stopped";
|
|
1206
|
+
run.endedAt ??= Date.now();
|
|
1207
|
+
update();
|
|
1208
|
+
return {
|
|
1209
|
+
key, name: run.name, state: run.state, task: run.task, ...(workspace ? { workspace } : {}),
|
|
1210
|
+
changed, applied: [...run.appliedFiles], remaining: remainingTasks(run), last: run.output, ...(run.error ? { error: run.error } : {})
|
|
1211
|
+
};
|
|
1212
|
+
};
|
|
1213
|
+
|
|
1214
|
+
records = runs.map((run, index) => ({
|
|
1215
|
+
key: `delegate-${id}-${run.index}`, batch: id, run,
|
|
1216
|
+
stop: () => stopRun(index),
|
|
1217
|
+
pause: () => discarded || stopRequested.has(index) ? Promise.resolve(false) : handles[index]!.pause(),
|
|
1218
|
+
resume: (text) => !discarded && !stopRequested.has(index) && handles[index]!.resume(text),
|
|
1219
|
+
steer: (text) => !discarded && !stopRequested.has(index) && handles[index]!.steer(text),
|
|
1220
|
+
ask: (text) => discarded || stopRequested.has(index) ? Promise.resolve(undefined) : handles[index]!.ask(text),
|
|
1221
|
+
alive: () => handles[index]!.alive(),
|
|
1222
|
+
closed: () => ready[index]!.then(() => handles[index]!.closed),
|
|
1223
|
+
changed: () => {
|
|
1224
|
+
const worktree = worktrees.get(run.index);
|
|
1225
|
+
if (!worktree) return [...run.filesEdited];
|
|
1226
|
+
try { return worktreeChangedFiles(worktree); } catch { return [...run.filesEdited]; }
|
|
1227
|
+
},
|
|
1228
|
+
discard: () => {
|
|
1229
|
+
discarded = true;
|
|
1230
|
+
// Cancellation is synchronous: a concurrent stop must never apply discarded edits.
|
|
1231
|
+
controllers[index]!.abort(STOP_REASON);
|
|
1232
|
+
void ready[index]!.then(() => handles[index]!.shutdown()).finally(() => cleanupPrivate(run.index, true));
|
|
1233
|
+
}
|
|
1234
|
+
}));
|
|
1235
|
+
// Transfer the reservation into queued records before notifying reentrant listeners.
|
|
1236
|
+
releaseReservation();
|
|
1237
|
+
registry.add(...records);
|
|
1238
|
+
startRuns();
|
|
1239
|
+
publish();
|
|
1240
|
+
|
|
344
1241
|
try {
|
|
345
|
-
await Promise.all(
|
|
1242
|
+
await Promise.all(handles.map((handle, index) => runs[index]!.mode === "direct" ? handle.closed : handle.firstSettled));
|
|
346
1243
|
} finally {
|
|
347
|
-
clearInterval(refresh);
|
|
348
1244
|
signal?.removeEventListener("abort", abortAll);
|
|
349
|
-
|
|
1245
|
+
if (baselines) rmSync(baselines, { recursive: true, force: true });
|
|
1246
|
+
for (const run of runs) {
|
|
1247
|
+
if (run.mode === "direct") cleanupPrivate(run.index, true);
|
|
1248
|
+
else if (run.state === "failed") cleanupPrivate(run.index, false);
|
|
1249
|
+
}
|
|
350
1250
|
registry.notify();
|
|
351
1251
|
flushUpdate();
|
|
1252
|
+
toolReturned = true;
|
|
352
1253
|
}
|
|
353
|
-
|
|
354
|
-
|
|
1254
|
+
return { content: [{ type: "text", text: runs.map((run) => runReport(run)).join("\n\n") }], details: details(),
|
|
1255
|
+
...(runs.some((run) => run.state === "failed") ? { isError: true } : {}) };
|
|
1256
|
+
} finally { releaseReservation(); }
|
|
355
1257
|
},
|
|
356
1258
|
renderCall(args, theme) {
|
|
357
1259
|
const count = Array.isArray(args.tasks) ? args.tasks.length : 0;
|
|
1260
|
+
const requested = args.mode ?? (args.write ? "direct" : "scout");
|
|
358
1261
|
return new Text(theme.fg("toolTitle", theme.bold("delegate")) + " " + theme.fg("accent", `${count} subagent${count === 1 ? "" : "s"}`)
|
|
359
|
-
+ theme.fg("dim",
|
|
1262
|
+
+ theme.fg("dim", ` · ${requested}`), 0, 0);
|
|
360
1263
|
},
|
|
361
1264
|
renderResult(result, { expanded }, theme) {
|
|
362
|
-
const runs = (result.details as { runs?: Array<
|
|
1265
|
+
const runs = (result.details as { runs?: Array<Partial<RunDetails> & Pick<DelegateRun, "name" | "state" | "role">> } | undefined)?.runs ?? [];
|
|
363
1266
|
const rows = runs.map((run) => {
|
|
364
|
-
const
|
|
1267
|
+
const leaves = leafTodos(run.todos ?? []);
|
|
1268
|
+
const meta = [run.role, run.mode === "fork" || run.mode === "worktree" ? run.mode : "", run.activity, run.tools ? `${run.tools} tools` : "",
|
|
1269
|
+
leaves.length ? `${leaves.filter((todo) => todo.done).length}/${leaves.length} tasks` : "",
|
|
1270
|
+
run.appliedFiles?.length ? `${run.appliedFiles.length} applied` : run.filesEdited?.length ? `${run.filesEdited.length} edited` : "",
|
|
1271
|
+
run.cost ? `$${run.cost.toFixed(3)}` : "", run.error].filter(Boolean).join(" · ");
|
|
365
1272
|
let row = theme.fg(color(run.state) as never, ` ${glyph(run.state)} ${run.name}`) + theme.fg("dim", " · " + meta);
|
|
366
1273
|
if (expanded && run.output) row += "\n" + run.output.split("\n").map((line) => theme.fg("muted", " " + line)).join("\n");
|
|
367
1274
|
return row;
|
|
368
1275
|
});
|
|
369
1276
|
return new Text(rows.join("\n") || theme.fg("dim", "No subagents."), 0, 0);
|
|
370
1277
|
}
|
|
1278
|
+
};
|
|
1279
|
+
pi.registerTool(delegateTool);
|
|
1280
|
+
|
|
1281
|
+
const snapshot = (record: SubagentRecord): SubagentReport => ({
|
|
1282
|
+
id: record.key, key: record.key, name: record.run.name, task: record.run.task,
|
|
1283
|
+
mode: record.run.mode, state: record.run.state, output: record.run.output,
|
|
1284
|
+
...(record.run.error ? { error: record.run.error } : {})
|
|
1285
|
+
});
|
|
1286
|
+
options.onController?.({
|
|
1287
|
+
list: () => registry.records().map(snapshot),
|
|
1288
|
+
async spawnScout(task, signal) {
|
|
1289
|
+
if (!controllerContext) throw new Error("Delegate controller has no active session context");
|
|
1290
|
+
if (signal?.aborted) throw new Error("Scout launch aborted");
|
|
1291
|
+
const result = await delegateTool.execute("scout", { tasks: [{ task, mode: "scout" }] }, signal, undefined, controllerContext);
|
|
1292
|
+
const launched = (result.details as { batch?: number }).batch;
|
|
1293
|
+
if (!launched) throw new Error(result.content.map((part) => "text" in part ? part.text : "").join("\n"));
|
|
1294
|
+
const record = registry.get(`delegate-${launched}-1`);
|
|
1295
|
+
if (!record) throw new Error("Scout session was discarded");
|
|
1296
|
+
if (record.run.error || record.run.state !== "idle") throw new Error(record.run.error ?? "Scout failed to settle");
|
|
1297
|
+
return snapshot(record);
|
|
1298
|
+
},
|
|
1299
|
+
async resumeScout(id, prompt, signal) {
|
|
1300
|
+
const record = registry.get(id);
|
|
1301
|
+
if (!record || record.run.mode !== "scout") throw new Error("Only fresh read-only scouts can vote");
|
|
1302
|
+
if (!prompt.trim()) throw new Error("Scout resume requires a prompt");
|
|
1303
|
+
if (signal?.aborted) throw new Error("Scout resume aborted");
|
|
1304
|
+
const result = new Promise<SubagentReport>((resolve, reject) => {
|
|
1305
|
+
let unsubscribe = () => {};
|
|
1306
|
+
const cleanup = () => { unsubscribe(); signal?.removeEventListener("abort", abort); };
|
|
1307
|
+
const check = () => {
|
|
1308
|
+
if (registry.get(id) !== record) { cleanup(); reject(new Error("Scout session was discarded")); return; }
|
|
1309
|
+
if (isRunning(record.run.state)) return;
|
|
1310
|
+
cleanup();
|
|
1311
|
+
if (record.run.error || !isRetained(record.run.state)) reject(new Error(record.run.error ?? "Scout exited before reporting"));
|
|
1312
|
+
else resolve(snapshot(record));
|
|
1313
|
+
};
|
|
1314
|
+
const abort = () => { cleanup(); void registry.stop(id); reject(new Error("Scout resume aborted")); };
|
|
1315
|
+
unsubscribe = registry.subscribe(check);
|
|
1316
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
1317
|
+
if (!registry.resume(id, prompt)) { cleanup(); reject(new Error("Scout is not idle or paused")); }
|
|
1318
|
+
else check();
|
|
1319
|
+
});
|
|
1320
|
+
return result;
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
pi.registerTool({
|
|
1325
|
+
name: "jar_subagent",
|
|
1326
|
+
label: "subagent",
|
|
1327
|
+
description: "Moderator control for retained subagents: peek high-level progress, steer a working agent, ask a brief BTW question, pause without losing context, resume the same agent, or stop it and receive a progress/change/remaining-work handoff. Stopping a worktree agent safely applies reviewable changes to the parent.",
|
|
1328
|
+
promptSnippet: "Use jar_subagent as the moderator control plane. Prefer peek over rereading transcripts; reuse paused/idle agents with resume; stop finished workers to reconcile their work.",
|
|
1329
|
+
promptGuidelines: [
|
|
1330
|
+
"Use peek periodically instead of polling constantly. It returns task progress, current activity, changed files, remaining checklist items and last report.",
|
|
1331
|
+
"Use steer to correct direction while an agent is working. Use ask for a short BTW question; it waits for the agent's next compact answer.",
|
|
1332
|
+
"Pause when an agent should stop spending tokens but keep its context/workspace. Resume the same agent later instead of spawning a replacement.",
|
|
1333
|
+
"Stop when an agent is no longer needed. Stop aborts any active turn, retires the process, reports progress/workspace/changes/remaining work, and reconciles safe worktree changes into /diff."
|
|
1334
|
+
],
|
|
1335
|
+
parameters: Type.Object({
|
|
1336
|
+
action: Type.Union([
|
|
1337
|
+
Type.Literal("peek"), Type.Literal("steer"), Type.Literal("ask"),
|
|
1338
|
+
Type.Literal("pause"), Type.Literal("resume"), Type.Literal("stop")
|
|
1339
|
+
]),
|
|
1340
|
+
agent: Type.Optional(Type.String({ description: "Subagent key or unique name. Omit only for peek to show all retained agents." })),
|
|
1341
|
+
message: Type.Optional(Type.String({ description: "Direction, BTW question, or resume instruction." }))
|
|
1342
|
+
}),
|
|
1343
|
+
async execute(_id, params) {
|
|
1344
|
+
const result = await (async () => {
|
|
1345
|
+
if (params.action === "peek") {
|
|
1346
|
+
const targets = params.agent ? [registry.resolve(params.agent)].filter((item): item is SubagentRecord => !!item)
|
|
1347
|
+
: registry.records().filter((record) => isRetained(record.run.state));
|
|
1348
|
+
if (!targets.length) return { content: [{ type: "text", text: params.agent ? "Subagent not found: " + params.agent : "No retained subagents." }] };
|
|
1349
|
+
return { content: [{ type: "text", text: targets.map(peekRecord).join("\n\n") }] };
|
|
1350
|
+
}
|
|
1351
|
+
const agent = params.agent ?? "";
|
|
1352
|
+
const record = registry.resolve(agent);
|
|
1353
|
+
if (!record) return { content: [{ type: "text", text: "Subagent not found or name is ambiguous: " + agent }], isError: true };
|
|
1354
|
+
if (params.action === "steer") {
|
|
1355
|
+
const message = cleanBlock(params.message ?? "", MAX_STEER_CHARS);
|
|
1356
|
+
if (!message) return { content: [{ type: "text", text: "steer requires message." }], isError: true };
|
|
1357
|
+
return { content: [{ type: "text", text: registry.steer(record.key, message) ? "Steered " + record.run.name + "." : "Subagent is not currently working; use resume instead." }] };
|
|
1358
|
+
}
|
|
1359
|
+
if (params.action === "ask") {
|
|
1360
|
+
const message = cleanBlock(params.message ?? "", MAX_STEER_CHARS);
|
|
1361
|
+
if (!message) return { content: [{ type: "text", text: "ask requires message." }], isError: true };
|
|
1362
|
+
const answer = await registry.ask(record.key, message);
|
|
1363
|
+
return { content: [{ type: "text", text: answer ? record.run.name + ": " + cleanBlock(answer, 2000) : "No answer arrived before the BTW timeout." }] };
|
|
1364
|
+
}
|
|
1365
|
+
if (params.action === "pause") {
|
|
1366
|
+
const paused = await registry.pause(record.key);
|
|
1367
|
+
return { content: [{ type: "text", text: paused ? "Paused " + record.run.name + "; context and workspace are retained." : "Could not pause " + record.run.name + "." }] };
|
|
1368
|
+
}
|
|
1369
|
+
if (params.action === "resume") {
|
|
1370
|
+
const resumed = registry.resume(record.key, params.message);
|
|
1371
|
+
return { content: [{ type: "text", text: resumed ? "Resumed " + record.run.name + "." : record.run.name + " is not idle or paused." }] };
|
|
1372
|
+
}
|
|
1373
|
+
const report = await registry.stop(record.key);
|
|
1374
|
+
if (!report) return { content: [{ type: "text", text: "Subagent is already retired: " + record.run.name }] };
|
|
1375
|
+
return { content: [{ type: "text", text: controlReport(report) }], details: report };
|
|
1376
|
+
})();
|
|
1377
|
+
return { ...result, content: result.content.map((part) => ({ ...part, type: "text" as const })), details: result.details };
|
|
1378
|
+
}
|
|
371
1379
|
});
|
|
372
1380
|
}
|