@arhen/pi-core-subagent 1.3.43 → 1.3.45
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/peek.ts +118 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arhen/pi-core-subagent",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.45",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "pi extension: fast in-process subagents with a dependency-graph scheduler (needs edges gate tasks and carry upstream output into dependent prompts), plus background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
|
|
6
6
|
"license": "MIT",
|
package/src/peek.ts
CHANGED
|
@@ -38,51 +38,119 @@ function readTail(path: string): string {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
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
|
+
function stripThinking(text: string): string {
|
|
45
|
+
return text
|
|
46
|
+
.replace(/<think>[\s\S]*?<\/think>/g, " ")
|
|
47
|
+
.replace(/<\/?(?:think|thinking|reasoning)>/g, " ")
|
|
48
|
+
.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping real terminal escapes is the point
|
|
52
|
+
const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
53
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: same
|
|
54
|
+
const CONTROL = /[\x00-\x08\x0b-\x1f\x7f]/g;
|
|
55
|
+
|
|
56
|
+
/** Tool output is terminal output: it carries colour escapes and carriage returns
|
|
57
|
+
* that corrupt the pane's own styling (stray `[0m` mid-row, cursor jumps). */
|
|
41
58
|
function clip(text: string, max: number): string {
|
|
42
|
-
const flat = text.replace(/\s+/g, " ").trim();
|
|
59
|
+
const flat = text.replace(ANSI, "").replace(CONTROL, " ").replace(/\s+/g, " ").trim();
|
|
43
60
|
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
44
61
|
}
|
|
45
62
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
63
|
+
/** Long absolute paths are almost entirely prefix. The tail is what identifies
|
|
64
|
+
* the file, so shorten from the left and keep the last segments. */
|
|
65
|
+
function shortenPath(text: string): string {
|
|
66
|
+
return text.replace(/(?:\/[\w.@+-]+){3,}/g, (path) => {
|
|
67
|
+
const parts = path.split("/").filter(Boolean);
|
|
68
|
+
return parts.length <= 3 ? path : `…/${parts.slice(-3).join("/")}`;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const PATH_ARGS = ["path", "file", "filePath", "command", "pattern", "query", "url", "task", "subject"];
|
|
73
|
+
|
|
74
|
+
/** The argument that says WHAT a call does. Object.values() order is insertion
|
|
75
|
+
* order, so the old "first string wins" picked `oldText` blobs over `path`. */
|
|
76
|
+
function callSummary(args: Record<string, unknown> | undefined): string {
|
|
77
|
+
for (const key of PATH_ARGS) {
|
|
78
|
+
const value = args?.[key];
|
|
79
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
80
|
+
}
|
|
81
|
+
const first = Object.values(args ?? {}).find((v) => typeof v === "string" && v.trim() !== "");
|
|
82
|
+
return typeof first === "string" ? first : "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type PeekLine = { gutter: string; text: string; kind: "call" | "result" | "error" | "say" };
|
|
86
|
+
|
|
87
|
+
/** One session-file entry → display lines. A turn with 4 tool calls is 4 lines:
|
|
88
|
+
* keeping only the first hid the parallel calls that explain what the child did. */
|
|
89
|
+
function eventLines(raw: string): PeekLine[] {
|
|
48
90
|
let entry: any;
|
|
49
91
|
try {
|
|
50
92
|
entry = JSON.parse(raw);
|
|
51
93
|
} catch {
|
|
52
|
-
return
|
|
94
|
+
return [];
|
|
53
95
|
}
|
|
54
96
|
const msg = entry?.message;
|
|
55
|
-
if (!msg) return
|
|
56
|
-
const out: [
|
|
57
|
-
|
|
97
|
+
if (!msg) return [];
|
|
98
|
+
const out: PeekLine[] = [];
|
|
99
|
+
const isResult = msg.role === "toolResult";
|
|
100
|
+
for (const block of Array.isArray(msg.content) ? msg.content : []) {
|
|
58
101
|
if (block.type === "toolCall") {
|
|
59
|
-
const arg =
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
102
|
+
const arg = callSummary(block.arguments);
|
|
103
|
+
out.push({ gutter: "→", kind: "call", text: `${block.name}${arg ? ` ${shortenPath(clip(arg, 110))}` : ""}` });
|
|
104
|
+
} else if (block.type === "text") {
|
|
105
|
+
const text = stripThinking(block.text ?? "");
|
|
106
|
+
if (!text) continue; // a turn that was pure reasoning has nothing to show
|
|
107
|
+
if (isResult) {
|
|
108
|
+
const kind = msg.isError ? "error" : "result";
|
|
109
|
+
const name = msg.toolName ? `${msg.toolName}: ` : "";
|
|
110
|
+
out.push({ gutter: msg.isError ? "✗" : "←", kind, text: `${name}${shortenPath(clip(text, 150))}` });
|
|
111
|
+
} else {
|
|
112
|
+
out.push({ gutter: "·", kind: "say", text: clip(text, 150) });
|
|
113
|
+
}
|
|
65
114
|
}
|
|
66
115
|
}
|
|
67
|
-
return out
|
|
116
|
+
return out;
|
|
68
117
|
}
|
|
69
118
|
|
|
70
|
-
function tailLines(path: string, max: number): [
|
|
119
|
+
function tailLines(path: string, max: number): PeekLine[] {
|
|
71
120
|
let text: string;
|
|
72
121
|
try {
|
|
73
122
|
text = readTail(path);
|
|
74
123
|
} catch {
|
|
75
|
-
return [
|
|
124
|
+
return [{ gutter: "·", kind: "say", text: "(session file not readable yet)" }];
|
|
76
125
|
}
|
|
77
|
-
const lines: [
|
|
126
|
+
const lines: PeekLine[] = [];
|
|
78
127
|
// First line of a mid-file read is usually a fragment — drop it.
|
|
79
|
-
for (const raw of text.split("\n").slice(1))
|
|
80
|
-
const line = eventLine(raw);
|
|
81
|
-
if (line) lines.push(line);
|
|
82
|
-
}
|
|
128
|
+
for (const raw of text.split("\n").slice(1)) lines.push(...eventLines(raw));
|
|
83
129
|
return lines.slice(-max);
|
|
84
130
|
}
|
|
85
131
|
|
|
132
|
+
/** Gutter colour carries the line's role, so the eye can skip to failures without
|
|
133
|
+
* reading: → call, ← result, ✗ error, · the child speaking. */
|
|
134
|
+
function renderLine(line: PeekLine, theme: Theme): string {
|
|
135
|
+
if (line.kind === "error") return `${theme.fg("error", line.gutter)} ${theme.fg("error", line.text)}`;
|
|
136
|
+
if (line.kind === "call") {
|
|
137
|
+
const space = line.text.indexOf(" ");
|
|
138
|
+
const name = space === -1 ? line.text : line.text.slice(0, space);
|
|
139
|
+
const arg = space === -1 ? "" : line.text.slice(space + 1);
|
|
140
|
+
return `${theme.fg("accent", line.gutter)} ${theme.fg("toolTitle", name)}${arg ? ` ${theme.fg("muted", arg)}` : ""}`;
|
|
141
|
+
}
|
|
142
|
+
if (line.kind === "result") return `${theme.fg("dim", line.gutter)} ${theme.fg("dim", line.text)}`;
|
|
143
|
+
return `${theme.fg("dim", line.gutter)} ${line.text}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Status as a coloured word, the way pi marks tool state. */
|
|
147
|
+
function statusTag(status: string, theme: Theme): string {
|
|
148
|
+
if (status === "failed") return theme.fg("error", status);
|
|
149
|
+
if (status === "completed") return theme.fg("success", status);
|
|
150
|
+
if (status === "aborted") return theme.fg("warning", status);
|
|
151
|
+
return theme.fg("muted", status);
|
|
152
|
+
}
|
|
153
|
+
|
|
86
154
|
export interface PeekPane {
|
|
87
155
|
render(width: number): string[];
|
|
88
156
|
handleInput(data: string): void;
|
|
@@ -109,27 +177,45 @@ export function createPeekPane(
|
|
|
109
177
|
const clamp = (n: number, len: number) => (len === 0 ? 0 : Math.max(0, Math.min(len - 1, n)));
|
|
110
178
|
|
|
111
179
|
/**
|
|
112
|
-
* Every row is
|
|
113
|
-
*
|
|
180
|
+
* Every row is a full-width bordered line: │ edge, one space of padding, the
|
|
181
|
+
* text padded out, then the closing edge. Background-filled so the transcript
|
|
182
|
+
* underneath never shows through the overlay, and edged so the pane reads as
|
|
183
|
+
* one object instead of a floating block of colour.
|
|
114
184
|
*/
|
|
115
185
|
const row = (content: string, width: number): string => {
|
|
116
|
-
const inner = width - 4; //
|
|
186
|
+
const inner = width - 4; // │ + space each side
|
|
117
187
|
const text = truncateToWidth(content, Math.max(0, inner), "…");
|
|
118
188
|
const pad = Math.max(0, inner - visibleWidth(text));
|
|
119
|
-
|
|
189
|
+
const edge = theme.fg("border", "│");
|
|
190
|
+
return theme.bg("selectedBg", `${edge} ${text}${" ".repeat(pad)} ${edge}`);
|
|
120
191
|
};
|
|
121
192
|
|
|
193
|
+
/** Top/bottom cap, and the ├───┤ divider between chrome and content. */
|
|
194
|
+
const edgeRow = (width: number, left: string, right: string): string =>
|
|
195
|
+
theme.bg("selectedBg", theme.fg("border", `${left}${"─".repeat(Math.max(0, width - 2))}${right}`));
|
|
196
|
+
|
|
122
197
|
return {
|
|
123
198
|
render(width: number): string[] {
|
|
124
199
|
const tasks = getTasks();
|
|
125
200
|
selected = clamp(selected, tasks.length);
|
|
126
|
-
if (tasks.length === 0)
|
|
201
|
+
if (tasks.length === 0) {
|
|
202
|
+
return [
|
|
203
|
+
edgeRow(width, "╭", "╮"),
|
|
204
|
+
row(theme.fg("dim", "No subagents in this session."), width),
|
|
205
|
+
edgeRow(width, "╰", "╯"),
|
|
206
|
+
];
|
|
207
|
+
}
|
|
127
208
|
const task = tasks[selected]!;
|
|
128
209
|
const hint = confirming
|
|
129
210
|
? theme.fg("error", `abort ${task.agent}? y / n`)
|
|
130
211
|
: theme.fg("dim", tailing ? "esc back · x abort" : "shift+↑↓ / jk move · enter tail · x abort · esc close");
|
|
131
|
-
|
|
132
|
-
|
|
212
|
+
// Breadcrumb, not a bare name: while tailing, WHERE you are is the thing you
|
|
213
|
+
// lose track of first, and the child's status belongs next to its name.
|
|
214
|
+
const crumb = tailing
|
|
215
|
+
? `${theme.fg("muted", "Subagents")}${theme.fg("dim", " › ")}${theme.fg("accent", theme.bold(task.agent))} ${statusTag(task.status, theme)}`
|
|
216
|
+
: theme.fg("accent", theme.bold("Subagents"));
|
|
217
|
+
const title = `${crumb} ${theme.fg("muted", `${selected + 1}/${tasks.length}`)}`;
|
|
218
|
+
const lines = [edgeRow(width, "╭", "╮"), row(title, width), row(hint, width), edgeRow(width, "├", "┤")];
|
|
133
219
|
|
|
134
220
|
if (!tailing) {
|
|
135
221
|
for (const [i, t] of tasks.entries()) {
|
|
@@ -140,11 +226,11 @@ export function createPeekPane(
|
|
|
140
226
|
lines.push(row(theme.fg("dim", "(no session file — agent has not started yet)"), width));
|
|
141
227
|
} else {
|
|
142
228
|
// ponytail: re-reads the tail each render (700ms poll). A watcher only pays off for files far bigger than a child session.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
229
|
+
const tail = tailLines(task.sessionFile, TAIL_ROWS);
|
|
230
|
+
if (tail.length === 0) lines.push(row(theme.fg("dim", "(no activity yet)"), width));
|
|
231
|
+
for (const line of tail) lines.push(row(renderLine(line, theme), width));
|
|
146
232
|
}
|
|
147
|
-
lines.push(
|
|
233
|
+
lines.push(edgeRow(width, "╰", "╯"));
|
|
148
234
|
return lines;
|
|
149
235
|
},
|
|
150
236
|
handleInput(data: string): void {
|