@arhen/pi-core-subagent 1.3.49 → 1.3.50
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 +5 -6
- package/package.json +1 -1
- package/src/agentfile.ts +4 -41
- package/src/child.ts +2 -17
- package/src/format.ts +11 -58
- package/src/graph.ts +7 -36
- package/src/index.ts +16 -56
- package/src/mailbox.ts +0 -7
- package/src/manager.ts +93 -348
- package/src/peek.ts +13 -69
- package/src/schemas.ts +1 -4
- package/src/types.ts +0 -19
- package/src/worktree.ts +37 -164
package/src/peek.ts
CHANGED
|
@@ -1,16 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Peek pane — quick, read-only look at what subagents are doing.
|
|
3
|
-
*
|
|
4
|
-
* shift+↑/↓ (or j/k) move between agents, enter opens a live tail of that
|
|
5
|
-
* child's session file, esc goes back / closes. Never touches run state:
|
|
6
|
-
* no abort except the explicit x + y confirmation.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
1
|
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
10
2
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
11
3
|
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
12
4
|
|
|
13
|
-
/** Tail window: last 64KB of the child session file is plenty for a peek. */
|
|
14
5
|
const TAIL_BYTES = 64 * 1024;
|
|
15
6
|
const POLL_MS = 700;
|
|
16
7
|
const TAIL_ROWS = 18;
|
|
@@ -22,7 +13,7 @@ export interface PeekTask {
|
|
|
22
13
|
status: string;
|
|
23
14
|
running: boolean;
|
|
24
15
|
sessionFile?: string;
|
|
25
|
-
line: string;
|
|
16
|
+
line: string;
|
|
26
17
|
}
|
|
27
18
|
|
|
28
19
|
function readTail(path: string): string {
|
|
@@ -38,9 +29,6 @@ function readTail(path: string): string {
|
|
|
38
29
|
}
|
|
39
30
|
}
|
|
40
31
|
|
|
41
|
-
/** Reasoning markup is the model talking to itself, and empty `<think></think>`
|
|
42
|
-
* pairs are the commonest single line in a transcript — they filled the pane with
|
|
43
|
-
* rows carrying no information at all. */
|
|
44
32
|
function stripThinking(text: string): string {
|
|
45
33
|
return text
|
|
46
34
|
.replace(/<think>[\s\S]*?<\/think>/g, " ")
|
|
@@ -51,15 +39,11 @@ function stripThinking(text: string): string {
|
|
|
51
39
|
const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
52
40
|
const CONTROL = /[\x00-\x08\x0b-\x1f\x7f]/g;
|
|
53
41
|
|
|
54
|
-
/** Tool output is terminal output: it carries colour escapes and carriage returns
|
|
55
|
-
* that corrupt the pane's own styling (stray `[0m` mid-row, cursor jumps). */
|
|
56
42
|
function clip(text: string, max: number): string {
|
|
57
43
|
const flat = text.replace(ANSI, "").replace(CONTROL, " ").replace(/\s+/g, " ").trim();
|
|
58
44
|
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
59
45
|
}
|
|
60
46
|
|
|
61
|
-
/** Long absolute paths are almost entirely prefix. The tail is what identifies
|
|
62
|
-
* the file, so shorten from the left and keep the last segments. */
|
|
63
47
|
function shortenPath(text: string): string {
|
|
64
48
|
return text.replace(/(?:\/[\w.@+-]+){3,}/g, (path) => {
|
|
65
49
|
const parts = path.split("/").filter(Boolean);
|
|
@@ -69,8 +53,6 @@ function shortenPath(text: string): string {
|
|
|
69
53
|
|
|
70
54
|
const PATH_ARGS = ["path", "file", "filePath", "command", "pattern", "query", "url", "task", "subject"];
|
|
71
55
|
|
|
72
|
-
/** The argument that says WHAT a call does. Object.values() order is insertion
|
|
73
|
-
* order, so the old "first string wins" picked `oldText` blobs over `path`. */
|
|
74
56
|
function callSummary(args: Record<string, unknown> | undefined): string {
|
|
75
57
|
for (const key of PATH_ARGS) {
|
|
76
58
|
const value = args?.[key];
|
|
@@ -82,8 +64,6 @@ function callSummary(args: Record<string, unknown> | undefined): string {
|
|
|
82
64
|
|
|
83
65
|
export type PeekLine = { gutter: string; text: string; kind: "call" | "result" | "error" | "say" };
|
|
84
66
|
|
|
85
|
-
/** One session-file entry → display lines. A turn with 4 tool calls is 4 lines:
|
|
86
|
-
* keeping only the first hid the parallel calls that explain what the child did. */
|
|
87
67
|
function eventLines(raw: string): PeekLine[] {
|
|
88
68
|
let entry: any;
|
|
89
69
|
try {
|
|
@@ -101,7 +81,7 @@ function eventLines(raw: string): PeekLine[] {
|
|
|
101
81
|
out.push({ gutter: "→", kind: "call", text: `${block.name}${arg ? ` ${shortenPath(clip(arg, 110))}` : ""}` });
|
|
102
82
|
} else if (block.type === "text") {
|
|
103
83
|
const text = stripThinking(block.text ?? "");
|
|
104
|
-
if (!text) continue;
|
|
84
|
+
if (!text) continue;
|
|
105
85
|
if (isResult) {
|
|
106
86
|
const kind = msg.isError ? "error" : "result";
|
|
107
87
|
const name = msg.toolName ? `${msg.toolName}: ` : "";
|
|
@@ -114,7 +94,6 @@ function eventLines(raw: string): PeekLine[] {
|
|
|
114
94
|
return out;
|
|
115
95
|
}
|
|
116
96
|
|
|
117
|
-
/** The whole readable tail, unsliced — the viewport decides what is shown. */
|
|
118
97
|
function tailLines(path: string): PeekLine[] {
|
|
119
98
|
let text: string;
|
|
120
99
|
try {
|
|
@@ -123,13 +102,11 @@ function tailLines(path: string): PeekLine[] {
|
|
|
123
102
|
return [{ gutter: "·", kind: "say", text: "(session file not readable yet)" }];
|
|
124
103
|
}
|
|
125
104
|
const lines: PeekLine[] = [];
|
|
126
|
-
|
|
105
|
+
|
|
127
106
|
for (const raw of text.split("\n").slice(1)) lines.push(...eventLines(raw));
|
|
128
107
|
return lines;
|
|
129
108
|
}
|
|
130
109
|
|
|
131
|
-
/** Gutter colour carries the line's role, so the eye can skip to failures without
|
|
132
|
-
* reading: → call, ← result, ✗ error, · the child speaking. */
|
|
133
110
|
function renderLine(line: PeekLine, theme: Theme): string {
|
|
134
111
|
if (line.kind === "error") return `${theme.fg("error", line.gutter)} ${theme.fg("error", line.text)}`;
|
|
135
112
|
if (line.kind === "call") {
|
|
@@ -142,7 +119,6 @@ function renderLine(line: PeekLine, theme: Theme): string {
|
|
|
142
119
|
return `${theme.fg("dim", line.gutter)} ${line.text}`;
|
|
143
120
|
}
|
|
144
121
|
|
|
145
|
-
/** Status as a coloured word, the way pi marks tool state. */
|
|
146
122
|
function statusTag(status: string, theme: Theme): string {
|
|
147
123
|
if (status === "failed") return theme.fg("error", status);
|
|
148
124
|
if (status === "completed") return theme.fg("success", status);
|
|
@@ -157,10 +133,6 @@ export interface PeekPane {
|
|
|
157
133
|
dispose(): void;
|
|
158
134
|
}
|
|
159
135
|
|
|
160
|
-
/**
|
|
161
|
-
* Build the peek component. `getTasks` is polled live, so the pane keeps
|
|
162
|
-
* updating while agents run.
|
|
163
|
-
*/
|
|
164
136
|
export function createPeekPane(
|
|
165
137
|
getTasks: () => PeekTask[],
|
|
166
138
|
theme: Theme,
|
|
@@ -171,48 +143,29 @@ export function createPeekPane(
|
|
|
171
143
|
let selected = 0;
|
|
172
144
|
let tailing = false;
|
|
173
145
|
let confirming = false;
|
|
174
|
-
/** Rows scrolled back from the newest line. 0 = pinned to live output.
|
|
175
|
-
* A tail that keeps jumping to the bottom while you read is unusable, so any
|
|
176
|
-
* scroll detaches from live and the 700ms poll stops moving the view. */
|
|
177
146
|
let scrollback = 0;
|
|
178
|
-
/** Content rows the last render could show — page keys need the real viewport,
|
|
179
|
-
* which only render() knows (it is given the width and derives the height). */
|
|
180
147
|
let viewport = TAIL_ROWS;
|
|
181
|
-
/** Tail length at the previous render, to keep a scrolled-back view pinned to
|
|
182
|
-
* the SAME lines as the child appends new ones. Without this the window is
|
|
183
|
-
* anchored to the end of a growing list, so the text you are reading crawls
|
|
184
|
-
* upward every poll. */
|
|
185
148
|
let lastTotal = 0;
|
|
186
149
|
const timer = setInterval(requestRender, POLL_MS);
|
|
187
150
|
|
|
188
151
|
const clamp = (n: number, len: number) => (len === 0 ? 0 : Math.max(0, Math.min(len - 1, n)));
|
|
189
|
-
/** Scrolling past either end is a no-op, never a wrap: the newest line is a
|
|
190
|
-
* hard floor, the oldest readable line a hard ceiling. */
|
|
191
152
|
const scrollBy = (rows: number) => {
|
|
192
153
|
const task = getTasks()[selected];
|
|
193
154
|
if (!tailing || !task?.sessionFile) return;
|
|
194
155
|
const total = tailLines(task.sessionFile).length;
|
|
195
|
-
|
|
196
|
-
// otherwise be read as "the file grew by its whole length" and jump the view.
|
|
156
|
+
|
|
197
157
|
lastTotal = total;
|
|
198
158
|
scrollback = Math.max(0, Math.min(Math.max(0, total - viewport), scrollback + rows));
|
|
199
159
|
};
|
|
200
160
|
|
|
201
|
-
/**
|
|
202
|
-
* Every row is a full-width bordered line: │ edge, one space of padding, the
|
|
203
|
-
* text padded out, then the closing edge. Background-filled so the transcript
|
|
204
|
-
* underneath never shows through the overlay, and edged so the pane reads as
|
|
205
|
-
* one object instead of a floating block of colour.
|
|
206
|
-
*/
|
|
207
161
|
const row = (content: string, width: number): string => {
|
|
208
|
-
const inner = width - 4;
|
|
162
|
+
const inner = width - 4;
|
|
209
163
|
const text = truncateToWidth(content, Math.max(0, inner), "…");
|
|
210
164
|
const pad = Math.max(0, inner - visibleWidth(text));
|
|
211
165
|
const edge = theme.fg("border", "│");
|
|
212
166
|
return theme.bg("selectedBg", `${edge} ${text}${" ".repeat(pad)} ${edge}`);
|
|
213
167
|
};
|
|
214
168
|
|
|
215
|
-
/** Top/bottom cap, and the ├───┤ divider between chrome and content. */
|
|
216
169
|
const edgeRow = (width: number, left: string, right: string): string =>
|
|
217
170
|
theme.bg("selectedBg", theme.fg("border", `${left}${"─".repeat(Math.max(0, width - 2))}${right}`));
|
|
218
171
|
|
|
@@ -236,8 +189,7 @@ export function createPeekPane(
|
|
|
236
189
|
? "↑↓ / jk scroll · ⇧ page · g/G top·live · esc back · x abort"
|
|
237
190
|
: "shift+↑↓ / jk move · enter tail · x abort · esc close",
|
|
238
191
|
);
|
|
239
|
-
|
|
240
|
-
// lose track of first, and the child's status belongs next to its name.
|
|
192
|
+
|
|
241
193
|
const crumb = tailing
|
|
242
194
|
? `${theme.fg("muted", "Subagents")}${theme.fg("dim", " › ")}${theme.fg("accent", theme.bold(task.agent))} ${statusTag(task.status, theme)}`
|
|
243
195
|
: theme.fg("accent", theme.bold("Subagents"));
|
|
@@ -252,20 +204,17 @@ export function createPeekPane(
|
|
|
252
204
|
} else if (!task.sessionFile) {
|
|
253
205
|
lines.push(row(theme.fg("dim", "(no session file — agent has not started yet)"), width));
|
|
254
206
|
} else {
|
|
255
|
-
// ponytail: re-reads the tail each render (700ms poll). A watcher only pays off for files far bigger than a child session.
|
|
256
207
|
const tail = tailLines(task.sessionFile);
|
|
257
208
|
viewport = TAIL_ROWS;
|
|
258
209
|
if (scrollback > 0 && lastTotal > 0 && tail.length > lastTotal) scrollback += tail.length - lastTotal;
|
|
259
210
|
lastTotal = tail.length;
|
|
260
|
-
|
|
261
|
-
// fall off the front and a deep scrollback would render a blank window.
|
|
211
|
+
|
|
262
212
|
scrollback = Math.max(0, Math.min(Math.max(0, tail.length - viewport), scrollback));
|
|
263
213
|
const end = tail.length - scrollback;
|
|
264
214
|
const window = tail.slice(Math.max(0, end - viewport), end);
|
|
265
215
|
if (window.length === 0) lines.push(row(theme.fg("dim", "(no activity yet)"), width));
|
|
266
216
|
for (const line of window) lines.push(row(renderLine(line, theme), width));
|
|
267
|
-
|
|
268
|
-
// indistinguishable from a stalled child.
|
|
217
|
+
|
|
269
218
|
if (scrollback > 0) {
|
|
270
219
|
lines.push(edgeRow(width, "├", "┤"));
|
|
271
220
|
lines.push(row(theme.fg("warning", `↑ ${scrollback} older · G / end → live`), width));
|
|
@@ -278,7 +227,6 @@ export function createPeekPane(
|
|
|
278
227
|
const tasks = getTasks();
|
|
279
228
|
const len = tasks.length;
|
|
280
229
|
if (confirming) {
|
|
281
|
-
// Abort is irreversible, so it always costs a second keystroke.
|
|
282
230
|
confirming = false;
|
|
283
231
|
if (data === "y" || data === "Y") {
|
|
284
232
|
const task = tasks[selected];
|
|
@@ -292,11 +240,11 @@ export function createPeekPane(
|
|
|
292
240
|
} else if (matchesKey(data, Key.escape)) {
|
|
293
241
|
if (tailing) {
|
|
294
242
|
tailing = false;
|
|
295
|
-
scrollback = 0;
|
|
243
|
+
scrollback = 0;
|
|
296
244
|
} else close();
|
|
297
245
|
} else if (matchesKey(data, Key.enter) || matchesKey(data, Key.right)) {
|
|
298
246
|
tailing = true;
|
|
299
|
-
scrollback = 0;
|
|
247
|
+
scrollback = 0;
|
|
300
248
|
} else if (matchesKey(data, Key.left)) {
|
|
301
249
|
tailing = false;
|
|
302
250
|
scrollback = 0;
|
|
@@ -305,12 +253,10 @@ export function createPeekPane(
|
|
|
305
253
|
} else if (matchesKey(data, Key.pageDown)) {
|
|
306
254
|
scrollBy(-viewport);
|
|
307
255
|
} else if (data === "g") {
|
|
308
|
-
scrollBy(Number.MAX_SAFE_INTEGER);
|
|
256
|
+
scrollBy(Number.MAX_SAFE_INTEGER);
|
|
309
257
|
} else if (data === "G" || matchesKey(data, Key.end)) {
|
|
310
|
-
scrollback = 0;
|
|
258
|
+
scrollback = 0;
|
|
311
259
|
} else if (matchesKey(data, "shift+up") || matchesKey(data, Key.up) || data === "k") {
|
|
312
|
-
// In the tail the same keys scroll; in the list they move between agents.
|
|
313
|
-
// shift+↑↓ and j/k are the reliable pair: bare arrows can be eaten by prompt history.
|
|
314
260
|
if (tailing) scrollBy(1);
|
|
315
261
|
else selected = clamp(selected - 1, len);
|
|
316
262
|
} else if (matchesKey(data, "shift+down") || matchesKey(data, Key.down) || data === "j") {
|
|
@@ -319,9 +265,7 @@ export function createPeekPane(
|
|
|
319
265
|
}
|
|
320
266
|
requestRender();
|
|
321
267
|
},
|
|
322
|
-
invalidate(): void {
|
|
323
|
-
/* no cached strings */
|
|
324
|
-
},
|
|
268
|
+
invalidate(): void {},
|
|
325
269
|
dispose(): void {
|
|
326
270
|
clearInterval(timer);
|
|
327
271
|
},
|
package/src/schemas.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/** Tool schemas — single source of truth. TaskInput/SubagentParamsShape are
|
|
2
|
-
* derived from them, so the shapes can never drift from what the model sees. */
|
|
3
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
4
2
|
import { type Static, Type } from "typebox";
|
|
5
3
|
import { DEFAULT_CONCURRENCY, MAX_CONCURRENCY } from "./manager.ts";
|
|
@@ -52,7 +50,7 @@ export const SubagentParams = Type.Object({
|
|
|
52
50
|
maxRuntimeMs: Type.Optional(
|
|
53
51
|
Type.Number({
|
|
54
52
|
description:
|
|
55
|
-
"Per-task timeout, ms. Omit
|
|
53
|
+
"Per-task timeout, ms. Omit unless a hard bound is genuinely required — a safety ceiling always applies (6 h, or 1 h with `/subagents auto-limit on`).",
|
|
56
54
|
}),
|
|
57
55
|
),
|
|
58
56
|
autoAwait: Type.Optional(
|
|
@@ -69,7 +67,6 @@ export const SubagentParams = Type.Object({
|
|
|
69
67
|
),
|
|
70
68
|
});
|
|
71
69
|
|
|
72
|
-
/** Derived from the schemas — single source of truth, no hand-maintained mirror. */
|
|
73
70
|
export type TaskInput = Static<typeof TaskItem>;
|
|
74
71
|
export type SubagentParamsShape = Static<typeof SubagentParams>;
|
|
75
72
|
|
package/src/types.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
/** Shared run/task types for the subagent extension. No imports. */
|
|
2
|
-
|
|
3
1
|
export type RunMode = "single" | "parallel" | "chain";
|
|
4
2
|
export type TaskStatus = "queued" | "starting" | "running" | "awaiting_parent" | "completed" | "failed" | "aborted";
|
|
5
3
|
export type RunStatus = "queued" | "running" | "awaiting_parent" | "completed" | "failed" | "aborted";
|
|
6
4
|
|
|
7
5
|
export const TERMINAL: TaskStatus[] = ["completed", "failed", "aborted"];
|
|
8
6
|
|
|
9
|
-
/** Widget/command cap on rendered tasks; scheduler cap on spawned tasks. */
|
|
10
7
|
export const MAX_TASKS = 16;
|
|
11
8
|
|
|
12
9
|
export interface UsageStats {
|
|
@@ -25,7 +22,6 @@ export interface TaskSnapshot {
|
|
|
25
22
|
task: string;
|
|
26
23
|
cwd: string;
|
|
27
24
|
status: TaskStatus;
|
|
28
|
-
/** Resolved dependency edges (task ids). Empty/absent = wave 1. */
|
|
29
25
|
needs?: string[];
|
|
30
26
|
sessionId?: string;
|
|
31
27
|
sessionFile?: string;
|
|
@@ -36,33 +32,19 @@ export interface TaskSnapshot {
|
|
|
36
32
|
finalText?: string;
|
|
37
33
|
error?: string;
|
|
38
34
|
model?: string;
|
|
39
|
-
/** Why `model` is not what was requested: preflight failed and the session's
|
|
40
|
-
* model took over. Silent substitution is worse than a slow spawn. */
|
|
41
35
|
modelNote?: string;
|
|
42
|
-
/** Set when explicit per-call tools/write displaced a matched file's tools. */
|
|
43
36
|
toolsNote?: string;
|
|
44
37
|
thinking?: string;
|
|
45
38
|
tools?: string[];
|
|
46
39
|
usage: UsageStats;
|
|
47
|
-
/** Sibling addresses for intercom tools (send_agent_message targets). */
|
|
48
40
|
roster?: string;
|
|
49
|
-
/** Path of the agent file that drove this child (inline definition otherwise).
|
|
50
|
-
* Surfaced so the leader can audit which file took over — a description-only
|
|
51
|
-
* match must never be invisible. */
|
|
52
41
|
agentFile?: string;
|
|
53
|
-
/** Git worktree isolation (write agents): branch + diff of the child's changes. */
|
|
54
42
|
branch?: string;
|
|
55
43
|
diffStat?: string;
|
|
56
44
|
changedFiles?: string[];
|
|
57
|
-
/** How a write child's edits were applied. "in-place" means NO branch: the
|
|
58
|
-
* changes are already in the leader's tree — always surfaced, never silent. */
|
|
59
45
|
isolation?: "worktree" | "in-place";
|
|
60
46
|
isolationReason?: string;
|
|
61
|
-
/** Upstream branch this one was built on top of. Merge that one FIRST — these
|
|
62
|
-
* are stacked, not independent. Absent = branched from the base tree. */
|
|
63
47
|
stackedOn?: string;
|
|
64
|
-
/** Worktree commit/diff trouble. Kept apart from `error` so a completed task
|
|
65
|
-
* still reports its answer. */
|
|
66
48
|
worktreeError?: string;
|
|
67
49
|
}
|
|
68
50
|
|
|
@@ -77,7 +59,6 @@ export interface RunSnapshot {
|
|
|
77
59
|
concurrency: number;
|
|
78
60
|
tasks: TaskSnapshot[];
|
|
79
61
|
aggregateUsage: UsageStats;
|
|
80
|
-
/** True once the parent awaited this run — completion notices are redundant then. */
|
|
81
62
|
awaited?: boolean;
|
|
82
63
|
}
|
|
83
64
|
|