@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/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; // pre-rendered stats line from the caller
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; // a turn that was pure reasoning has nothing to show
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
- // First line of a mid-file read is usually a fragment — drop it.
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
- // Anchor here as well as in render(): a scroll before the next render would
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; // │ + space each side
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
- // Breadcrumb, not a bare name: while tailing, WHERE you are is the thing you
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
- // Clamp here too: the readable tail is a sliding 64KB window, so old lines
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
- // Scrolled-back views must say so: an unmoving tail is otherwise
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; // leaving the tail forgets where you were reading
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; // a freshly opened tail always starts live
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); // oldest readable line
256
+ scrollBy(Number.MAX_SAFE_INTEGER);
309
257
  } else if (data === "G" || matchesKey(data, Key.end)) {
310
- scrollback = 0; // back to live
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 for no cap (default): tasks run until done, stalled, or user-aborted. Do not add arbitrary caps — only set when a hard bound is genuinely required.",
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