@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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +48 -0
  3. 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.13",
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
+ }