@arhen/pi-core-subagent 1.1.13 → 1.1.15
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/package.json +1 -1
- package/src/index.ts +48 -0
- package/src/peek.ts +153 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arhen/pi-core-subagent",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "pi extension: fast in-process subagents with single/parallel/chain, background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
|
|
6
6
|
"license": "MIT",
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { Type } from "typebox";
|
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { CHILD_TALK_TOOLS, createChildTools, createWatchdog, type ChildHandlers } from "./child.ts";
|
|
23
23
|
import { createMailbox, type Mailbox } from "./mailbox.ts";
|
|
24
|
+
import { createPeekPane, type PeekTask } from "./peek.ts";
|
|
24
25
|
|
|
25
26
|
const DEFAULT_CONCURRENCY = 3;
|
|
26
27
|
const MAX_CONCURRENCY = 8;
|
|
@@ -1026,6 +1027,22 @@ class SubagentManager {
|
|
|
1026
1027
|
return { run: cloneRun(run), background: true };
|
|
1027
1028
|
}
|
|
1028
1029
|
|
|
1030
|
+
/** Abort ONE task; siblings keep running. Returns false when unknown or already finished. */
|
|
1031
|
+
cancelTask(runId: string, taskId: string, ctx?: ExtensionContext): boolean {
|
|
1032
|
+
const run = this.runs.get(runId);
|
|
1033
|
+
const task = run?.tasks.find((t) => t.id === taskId);
|
|
1034
|
+
if (!run || !task || TERMINAL.includes(task.status)) return false;
|
|
1035
|
+
// Mark first: runChild's catch reads task.status to classify the outcome as aborted.
|
|
1036
|
+
task.status = "aborted";
|
|
1037
|
+
task.error = task.error || "Canceled from peek";
|
|
1038
|
+
task.endedAt = Date.now();
|
|
1039
|
+
this.liveChildren.get(`${runId}:${taskId}`)?.abort();
|
|
1040
|
+
this.mailboxes.close(`${runId}:${taskId}`);
|
|
1041
|
+
if (ctx) this.flushWidget(run, ctx);
|
|
1042
|
+
this.emit("subagent:task-aborted", { runId, taskId });
|
|
1043
|
+
return true;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1029
1046
|
cancelRun(runId: string): { aborted: number } {
|
|
1030
1047
|
const run = this.runs.get(runId);
|
|
1031
1048
|
if (!run) return { aborted: 0 };
|
|
@@ -1170,6 +1187,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
1170
1187
|
},
|
|
1171
1188
|
});
|
|
1172
1189
|
|
|
1190
|
+
/** Read-only peek: browse agents, enter to tail one. Never mutates run state. */
|
|
1191
|
+
const openPeek = async (ctx: ExtensionContext) => {
|
|
1192
|
+
if (!ctx.hasUI) return;
|
|
1193
|
+
const getTasks = (): PeekTask[] =>
|
|
1194
|
+
manager
|
|
1195
|
+
.listRuns()
|
|
1196
|
+
.flatMap((run) => run.tasks)
|
|
1197
|
+
.map((task) => ({
|
|
1198
|
+
runId: task.runId,
|
|
1199
|
+
taskId: task.id,
|
|
1200
|
+
agent: task.agent,
|
|
1201
|
+
status: task.status,
|
|
1202
|
+
running: !TERMINAL.includes(task.status),
|
|
1203
|
+
sessionFile: task.sessionFile,
|
|
1204
|
+
line: taskLine(task),
|
|
1205
|
+
}));
|
|
1206
|
+
if (getTasks().length === 0) {
|
|
1207
|
+
ctx.ui.notify("No subagents in this session.", "info");
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
await ctx.ui.custom<void>(
|
|
1211
|
+
(tui, theme, _keybindings, done) =>
|
|
1212
|
+
createPeekPane(getTasks, theme, () => tui.requestRender(), () => done(undefined), (t) => {
|
|
1213
|
+
if (manager.cancelTask(t.runId, t.taskId, ctx)) ctx.ui.notify(`Aborted subagent ${t.agent}.`, "warning");
|
|
1214
|
+
}),
|
|
1215
|
+
{ overlay: true, overlayOptions: { anchor: "center", width: "80%", maxHeight: "70%" } },
|
|
1216
|
+
);
|
|
1217
|
+
};
|
|
1218
|
+
pi.registerCommand("peek", { description: "Peek at running subagents (↑↓ move, enter tails)", handler: (_args, ctx) => openPeek(ctx) });
|
|
1219
|
+
pi.registerShortcut("ctrl+shift+s", { description: "Peek at running subagents", handler: openPeek });
|
|
1220
|
+
|
|
1173
1221
|
pi.on("agent_start", (_event, ctx) => {
|
|
1174
1222
|
if (!manager.turnActivity && !manager.hasActiveRun()) manager.clearWidget(ctx);
|
|
1175
1223
|
manager.turnActivity = false;
|
package/src/peek.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peek pane — quick, read-only look at what subagents are doing.
|
|
3
|
+
*
|
|
4
|
+
* ↑/↓ (or ←/→) move between agents, enter opens a live tail of that child's
|
|
5
|
+
* session file, esc goes back / closes. Never touches run state: no abort,
|
|
6
|
+
* no cancel, no writes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
10
|
+
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
/** Tail window: last 64KB of the child session file is plenty for a peek. */
|
|
14
|
+
const TAIL_BYTES = 64 * 1024;
|
|
15
|
+
const POLL_MS = 700;
|
|
16
|
+
|
|
17
|
+
export interface PeekTask {
|
|
18
|
+
runId: string;
|
|
19
|
+
taskId: string;
|
|
20
|
+
agent: string;
|
|
21
|
+
status: string;
|
|
22
|
+
running: boolean;
|
|
23
|
+
sessionFile?: string;
|
|
24
|
+
line: string; // pre-rendered stats line from the caller
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readTail(path: string): string {
|
|
28
|
+
const fd = openSync(path, "r");
|
|
29
|
+
try {
|
|
30
|
+
const size = statSync(path).size;
|
|
31
|
+
const start = Math.max(0, size - TAIL_BYTES);
|
|
32
|
+
const buf = Buffer.alloc(size - start);
|
|
33
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
34
|
+
return buf.toString("utf8");
|
|
35
|
+
} finally {
|
|
36
|
+
closeSync(fd);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One session-file line → one display line. Unparseable/irrelevant → null. */
|
|
41
|
+
function eventLine(raw: string): string | null {
|
|
42
|
+
let entry: any;
|
|
43
|
+
try {
|
|
44
|
+
entry = JSON.parse(raw);
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const msg = entry?.message;
|
|
49
|
+
if (!msg) return null;
|
|
50
|
+
const parts: string[] = [];
|
|
51
|
+
for (const block of msg.content ?? []) {
|
|
52
|
+
if (block.type === "text" && block.text?.trim()) parts.push(block.text.trim());
|
|
53
|
+
else if (block.type === "toolCall") parts.push(`→ ${block.name} ${JSON.stringify(block.arguments ?? {})}`);
|
|
54
|
+
else if (block.type === "thinking" && block.thinking?.trim()) parts.push(`(thinking) ${block.thinking.trim()}`);
|
|
55
|
+
}
|
|
56
|
+
if (parts.length === 0) return null;
|
|
57
|
+
const who = msg.role === "assistant" ? "" : msg.role === "toolResult" ? " ← " : `${msg.role}: `;
|
|
58
|
+
return `${who}${parts.join(" ").replace(/\s+/g, " ")}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function tailLines(path: string, max: number): string[] {
|
|
62
|
+
let text: string;
|
|
63
|
+
try {
|
|
64
|
+
text = readTail(path);
|
|
65
|
+
} catch {
|
|
66
|
+
return ["(session file not readable yet)"];
|
|
67
|
+
}
|
|
68
|
+
const lines: string[] = [];
|
|
69
|
+
// First line of a mid-file read is usually a fragment — drop it.
|
|
70
|
+
for (const raw of text.split("\n").slice(1)) {
|
|
71
|
+
const line = eventLine(raw);
|
|
72
|
+
if (line) lines.push(line);
|
|
73
|
+
}
|
|
74
|
+
return lines.slice(-max);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PeekPane {
|
|
78
|
+
render(width: number): string[];
|
|
79
|
+
handleInput(data: string): void;
|
|
80
|
+
invalidate(): void;
|
|
81
|
+
dispose(): void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the peek component. `getTasks` is polled live, so the pane keeps
|
|
86
|
+
* updating while agents run.
|
|
87
|
+
*/
|
|
88
|
+
export function createPeekPane(
|
|
89
|
+
getTasks: () => PeekTask[],
|
|
90
|
+
theme: Theme,
|
|
91
|
+
requestRender: () => void,
|
|
92
|
+
close: () => void,
|
|
93
|
+
abort: (task: PeekTask) => void,
|
|
94
|
+
): PeekPane {
|
|
95
|
+
let selected = 0;
|
|
96
|
+
let tailing = false;
|
|
97
|
+
let confirming = false;
|
|
98
|
+
const timer = setInterval(requestRender, POLL_MS);
|
|
99
|
+
|
|
100
|
+
const clamp = (n: number, len: number) => (len === 0 ? 0 : Math.max(0, Math.min(len - 1, n)));
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
render(width: number): string[] {
|
|
104
|
+
const tasks = getTasks();
|
|
105
|
+
selected = clamp(selected, tasks.length);
|
|
106
|
+
if (tasks.length === 0) return [theme.fg("dim", "No subagents in this session.")];
|
|
107
|
+
const task = tasks[selected]!;
|
|
108
|
+
const hint = confirming ? theme.fg("error", `abort ${task.agent}? y / n`) : tailing ? "esc back · x abort" : "↑↓ move · enter tail · x abort · esc close";
|
|
109
|
+
const head = `${theme.fg("accent", theme.bold(tailing ? task.agent : "Subagents"))} ${theme.fg("dim", `(${selected + 1}/${tasks.length}) · ${hint}`)}`;
|
|
110
|
+
if (!tailing) {
|
|
111
|
+
return [head, ...tasks.map((t, i) => truncateToWidth(`${i === selected ? theme.fg("accent", "❯ ") : " "}${t.line}`, width, "…"))];
|
|
112
|
+
}
|
|
113
|
+
if (!task.sessionFile) return [head, theme.fg("dim", "(no session file — agent has not started yet)")];
|
|
114
|
+
// ponytail: re-reads the tail each render (700ms poll). A watcher only pays off for files far bigger than a child session.
|
|
115
|
+
return [head, ...tailLines(task.sessionFile, 18).map((l) => truncateToWidth(` ${l}`, width, "…"))];
|
|
116
|
+
},
|
|
117
|
+
handleInput(data: string): void {
|
|
118
|
+
const tasks = getTasks();
|
|
119
|
+
const len = tasks.length;
|
|
120
|
+
if (confirming) {
|
|
121
|
+
// Abort is irreversible, so it always costs a second keystroke.
|
|
122
|
+
confirming = false;
|
|
123
|
+
if (data === "y" || data === "Y") {
|
|
124
|
+
const task = tasks[selected];
|
|
125
|
+
if (task) abort(task);
|
|
126
|
+
}
|
|
127
|
+
requestRender();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (data === "x" || data === "X") {
|
|
131
|
+
if (tasks[selected]?.running) confirming = true;
|
|
132
|
+
} else if (matchesKey(data, Key.escape)) {
|
|
133
|
+
if (tailing) tailing = false;
|
|
134
|
+
else close();
|
|
135
|
+
} else if (matchesKey(data, Key.enter) || matchesKey(data, Key.right)) {
|
|
136
|
+
tailing = true;
|
|
137
|
+
} else if (matchesKey(data, Key.left)) {
|
|
138
|
+
tailing = false;
|
|
139
|
+
} else if (matchesKey(data, Key.up)) {
|
|
140
|
+
selected = clamp(selected - 1, len);
|
|
141
|
+
} else if (matchesKey(data, Key.down)) {
|
|
142
|
+
selected = clamp(selected + 1, len);
|
|
143
|
+
}
|
|
144
|
+
requestRender();
|
|
145
|
+
},
|
|
146
|
+
invalidate(): void {
|
|
147
|
+
/* no cached strings */
|
|
148
|
+
},
|
|
149
|
+
dispose(): void {
|
|
150
|
+
clearInterval(timer);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|