@arhen/pi-core-subagent 1.1.13 → 1.1.14
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 +21 -0
- package/src/peek.ts +130 -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.14",
|
|
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;
|
|
@@ -1170,6 +1171,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
1170
1171
|
},
|
|
1171
1172
|
});
|
|
1172
1173
|
|
|
1174
|
+
/** Read-only peek: browse agents, enter to tail one. Never mutates run state. */
|
|
1175
|
+
const openPeek = async (ctx: ExtensionContext) => {
|
|
1176
|
+
if (!ctx.hasUI) return;
|
|
1177
|
+
const getTasks = (): PeekTask[] =>
|
|
1178
|
+
manager
|
|
1179
|
+
.listRuns()
|
|
1180
|
+
.flatMap((run) => run.tasks)
|
|
1181
|
+
.map((task) => ({ agent: task.agent, status: task.status, sessionFile: task.sessionFile, line: taskLine(task) }));
|
|
1182
|
+
if (getTasks().length === 0) {
|
|
1183
|
+
ctx.ui.notify("No subagents in this session.", "info");
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
await ctx.ui.custom<void>(
|
|
1187
|
+
(tui, theme, _keybindings, done) => createPeekPane(getTasks, theme, () => tui.requestRender(), () => done(undefined)),
|
|
1188
|
+
{ overlay: true, overlayOptions: { anchor: "center", width: "80%", maxHeight: "70%" } },
|
|
1189
|
+
);
|
|
1190
|
+
};
|
|
1191
|
+
pi.registerCommand("peek", { description: "Peek at running subagents (↑↓ move, enter tails)", handler: (_args, ctx) => openPeek(ctx) });
|
|
1192
|
+
pi.registerShortcut("ctrl+shift+s", { description: "Peek at running subagents", handler: openPeek });
|
|
1193
|
+
|
|
1173
1194
|
pi.on("agent_start", (_event, ctx) => {
|
|
1174
1195
|
if (!manager.turnActivity && !manager.hasActiveRun()) manager.clearWidget(ctx);
|
|
1175
1196
|
manager.turnActivity = false;
|
package/src/peek.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
agent: string;
|
|
19
|
+
status: string;
|
|
20
|
+
sessionFile?: string;
|
|
21
|
+
line: string; // pre-rendered stats line from the caller
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readTail(path: string): string {
|
|
25
|
+
const fd = openSync(path, "r");
|
|
26
|
+
try {
|
|
27
|
+
const size = statSync(path).size;
|
|
28
|
+
const start = Math.max(0, size - TAIL_BYTES);
|
|
29
|
+
const buf = Buffer.alloc(size - start);
|
|
30
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
31
|
+
return buf.toString("utf8");
|
|
32
|
+
} finally {
|
|
33
|
+
closeSync(fd);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One session-file line → one display line. Unparseable/irrelevant → null. */
|
|
38
|
+
function eventLine(raw: string): string | null {
|
|
39
|
+
let entry: any;
|
|
40
|
+
try {
|
|
41
|
+
entry = JSON.parse(raw);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const msg = entry?.message;
|
|
46
|
+
if (!msg) return null;
|
|
47
|
+
const parts: string[] = [];
|
|
48
|
+
for (const block of msg.content ?? []) {
|
|
49
|
+
if (block.type === "text" && block.text?.trim()) parts.push(block.text.trim());
|
|
50
|
+
else if (block.type === "toolCall") parts.push(`→ ${block.name} ${JSON.stringify(block.arguments ?? {})}`);
|
|
51
|
+
else if (block.type === "thinking" && block.thinking?.trim()) parts.push(`(thinking) ${block.thinking.trim()}`);
|
|
52
|
+
}
|
|
53
|
+
if (parts.length === 0) return null;
|
|
54
|
+
const who = msg.role === "assistant" ? "" : msg.role === "toolResult" ? " ← " : `${msg.role}: `;
|
|
55
|
+
return `${who}${parts.join(" ").replace(/\s+/g, " ")}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function tailLines(path: string, max: number): string[] {
|
|
59
|
+
let text: string;
|
|
60
|
+
try {
|
|
61
|
+
text = readTail(path);
|
|
62
|
+
} catch {
|
|
63
|
+
return ["(session file not readable yet)"];
|
|
64
|
+
}
|
|
65
|
+
const lines: string[] = [];
|
|
66
|
+
// First line of a mid-file read is usually a fragment — drop it.
|
|
67
|
+
for (const raw of text.split("\n").slice(1)) {
|
|
68
|
+
const line = eventLine(raw);
|
|
69
|
+
if (line) lines.push(line);
|
|
70
|
+
}
|
|
71
|
+
return lines.slice(-max);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PeekPane {
|
|
75
|
+
render(width: number): string[];
|
|
76
|
+
handleInput(data: string): void;
|
|
77
|
+
invalidate(): void;
|
|
78
|
+
dispose(): void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build the peek component. `getTasks` is polled live, so the pane keeps
|
|
83
|
+
* updating while agents run.
|
|
84
|
+
*/
|
|
85
|
+
export function createPeekPane(getTasks: () => PeekTask[], theme: Theme, requestRender: () => void, close: () => void): PeekPane {
|
|
86
|
+
let selected = 0;
|
|
87
|
+
let tailing = false;
|
|
88
|
+
const timer = setInterval(requestRender, POLL_MS);
|
|
89
|
+
|
|
90
|
+
const clamp = (n: number, len: number) => (len === 0 ? 0 : Math.max(0, Math.min(len - 1, n)));
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
render(width: number): string[] {
|
|
94
|
+
const tasks = getTasks();
|
|
95
|
+
selected = clamp(selected, tasks.length);
|
|
96
|
+
if (tasks.length === 0) return [theme.fg("dim", "No subagents in this session.")];
|
|
97
|
+
const task = tasks[selected]!;
|
|
98
|
+
const hint = tailing ? "esc back" : "↑↓ move · enter tail · esc close";
|
|
99
|
+
const head = `${theme.fg("accent", theme.bold(tailing ? task.agent : "Subagents"))} ${theme.fg("dim", `(${selected + 1}/${tasks.length}) · ${hint}`)}`;
|
|
100
|
+
if (!tailing) {
|
|
101
|
+
return [head, ...tasks.map((t, i) => truncateToWidth(`${i === selected ? theme.fg("accent", "❯ ") : " "}${t.line}`, width, "…"))];
|
|
102
|
+
}
|
|
103
|
+
if (!task.sessionFile) return [head, theme.fg("dim", "(no session file — agent has not started yet)")];
|
|
104
|
+
// ponytail: re-reads the tail each render (700ms poll). A watcher only pays off for files far bigger than a child session.
|
|
105
|
+
return [head, ...tailLines(task.sessionFile, 18).map((l) => truncateToWidth(` ${l}`, width, "…"))];
|
|
106
|
+
},
|
|
107
|
+
handleInput(data: string): void {
|
|
108
|
+
const len = getTasks().length;
|
|
109
|
+
if (matchesKey(data, Key.escape)) {
|
|
110
|
+
if (tailing) tailing = false;
|
|
111
|
+
else close();
|
|
112
|
+
} else if (matchesKey(data, Key.enter) || matchesKey(data, Key.right)) {
|
|
113
|
+
tailing = true;
|
|
114
|
+
} else if (matchesKey(data, Key.left)) {
|
|
115
|
+
tailing = false;
|
|
116
|
+
} else if (matchesKey(data, Key.up)) {
|
|
117
|
+
selected = clamp(selected - 1, len);
|
|
118
|
+
} else if (matchesKey(data, Key.down)) {
|
|
119
|
+
selected = clamp(selected + 1, len);
|
|
120
|
+
}
|
|
121
|
+
requestRender();
|
|
122
|
+
},
|
|
123
|
+
invalidate(): void {
|
|
124
|
+
/* no cached strings */
|
|
125
|
+
},
|
|
126
|
+
dispose(): void {
|
|
127
|
+
clearInterval(timer);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|