pi-subagents 0.18.1 → 0.19.0

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.
@@ -1,3 +1,5 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import type { Theme } from "@mariozechner/pi-coding-agent";
2
4
  import type { Component, TUI } from "@mariozechner/pi-tui";
3
5
  import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
@@ -7,6 +9,10 @@ import { formatDuration, formatTokens, shortenPath } from "./formatters.js";
7
9
  import { formatScrollInfo, renderFooter, renderHeader, row } from "./render-helpers.js";
8
10
 
9
11
  const AUTO_REFRESH_MS = 2000;
12
+ const DETAIL_EVENT_LIMIT = 8;
13
+ const OUTPUT_TAIL_LINES = 20;
14
+ const DETAIL_FILE_TAIL_BYTES = 64 * 1024;
15
+ const DETAIL_VIEWPORT_HEIGHT = 18;
10
16
 
11
17
  interface StatusRow {
12
18
  kind: "section" | "run";
@@ -71,13 +77,103 @@ function buildRows(active: AsyncRunSummary[], recent: AsyncRunSummary[]): Status
71
77
  return rows;
72
78
  }
73
79
 
80
+ function resolveRunPath(asyncDir: string, filePath: string): string {
81
+ return path.isAbsolute(filePath) ? filePath : path.join(asyncDir, filePath);
82
+ }
83
+
84
+ function readTailText(filePath: string): { text?: string; warning?: string } {
85
+ let fd: number | undefined;
86
+ try {
87
+ const stat = fs.statSync(filePath);
88
+ if (!stat.isFile()) return { warning: `not a file: ${filePath}` };
89
+ const start = Math.max(0, stat.size - DETAIL_FILE_TAIL_BYTES);
90
+ const length = stat.size - start;
91
+ const buffer = Buffer.alloc(length);
92
+ fd = fs.openSync(filePath, "r");
93
+ const bytesRead = fs.readSync(fd, buffer, 0, length, start);
94
+ return { text: buffer.subarray(0, bytesRead).toString("utf-8") };
95
+ } catch (error) {
96
+ const code = typeof error === "object" && error !== null && "code" in error
97
+ ? (error as NodeJS.ErrnoException).code
98
+ : undefined;
99
+ return { warning: code === "ENOENT" ? `missing ${path.basename(filePath)}: ${filePath}` : `failed to read ${filePath}: ${error instanceof Error ? error.message : String(error)}` };
100
+ } finally {
101
+ if (fd !== undefined) {
102
+ try {
103
+ fs.closeSync(fd);
104
+ } catch {
105
+ // Best effort cleanup after a bounded detail-view read.
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ function readTailLines(filePath: string, maxLines: number): { lines: string[]; warning?: string } {
112
+ const tail = readTailText(filePath);
113
+ if (tail.warning) return { lines: [], warning: tail.warning };
114
+ const lines = (tail.text ?? "").split("\n").map((line) => line.trimEnd()).filter((line) => line.trim());
115
+ return { lines: lines.slice(Math.max(0, lines.length - maxLines)) };
116
+ }
117
+
118
+ function formatEventTimestamp(value: unknown): string | undefined {
119
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
120
+ return new Date(value).toISOString();
121
+ }
122
+
123
+ function formatEventLine(value: Record<string, unknown>): string {
124
+ const type = typeof value.type === "string" ? value.type : "event";
125
+ const ts = formatEventTimestamp(value.ts ?? value.observedAt);
126
+ const stepIndex = typeof value.stepIndex === "number" ? ` step ${value.stepIndex + 1}` : "";
127
+ const agent = typeof value.agent === "string"
128
+ ? value.agent
129
+ : typeof value.subagentAgent === "string"
130
+ ? value.subagentAgent
131
+ : undefined;
132
+ const status = typeof value.status === "string" ? value.status : undefined;
133
+ const exitCode = typeof value.exitCode === "number" ? `exit ${value.exitCode}` : undefined;
134
+ const event = value.event && typeof value.event === "object" && !Array.isArray(value.event)
135
+ ? value.event as { message?: unknown }
136
+ : undefined;
137
+ const message = typeof value.message === "string"
138
+ ? value.message
139
+ : typeof value.error === "string"
140
+ ? value.error
141
+ : typeof event?.message === "string"
142
+ ? event.message
143
+ : undefined;
144
+ return [ts, type, agent ? `${agent}${stepIndex}` : stepIndex.trim(), status, exitCode, message].filter(Boolean).join(" | ");
145
+ }
146
+
147
+ function readRecentEvents(eventsPath: string, limit: number): { events: string[]; warning?: string } {
148
+ const tail = readTailText(eventsPath);
149
+ if (tail.warning) {
150
+ return tail.warning.startsWith("missing ") ? { events: [] } : { events: [], warning: tail.warning };
151
+ }
152
+
153
+ const events: string[] = [];
154
+ const lines = (tail.text ?? "").split("\n").filter((line) => line.trim());
155
+ for (let i = lines.length - 1; i >= 0 && events.length < limit; i--) {
156
+ try {
157
+ const parsed = JSON.parse(lines[i]!);
158
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
159
+ events.push(formatEventLine(parsed as Record<string, unknown>));
160
+ } catch {
161
+ // Skip malformed event records; async writers can be interrupted mid-line.
162
+ }
163
+ }
164
+ return { events: events.reverse() };
165
+ }
166
+
74
167
  export class SubagentsStatusComponent implements Component {
75
168
  private readonly width = 84;
76
169
  private readonly viewportHeight = 12;
77
170
  private readonly listRunsForOverlay: (asyncDirRoot: string, recentLimit?: number) => AsyncRunOverlayData;
78
171
  private readonly refreshTimer: NodeJS.Timeout;
172
+ private screen: "list" | "detail" = "list";
79
173
  private cursor = 0;
80
174
  private scrollOffset = 0;
175
+ private detailScrollOffset = 0;
176
+ private detailRunId: string | undefined;
81
177
  private active: AsyncRunSummary[] = [];
82
178
  private recent: AsyncRunSummary[] = [];
83
179
  private rows: StatusRow[] = [];
@@ -165,6 +261,12 @@ export class SubagentsStatusComponent implements Component {
165
261
  if (run.sessionFile) {
166
262
  lines.push(row(`session: ${truncateToWidth(shortenPath(run.sessionFile), innerW - 9)}`, width, this.theme));
167
263
  }
264
+ lines.push(...this.renderStepRows(run, width, innerW));
265
+ return lines;
266
+ }
267
+
268
+ private renderStepRows(run: AsyncRunSummary, width: number, innerW: number): string[] {
269
+ const lines: string[] = [];
168
270
  for (const step of run.steps) {
169
271
  const model = step.model ? ` | ${step.model}` : "";
170
272
  const attempts = step.attemptedModels && step.attemptedModels.length > 1
@@ -189,11 +291,114 @@ export class SubagentsStatusComponent implements Component {
189
291
  return lines;
190
292
  }
191
293
 
294
+ private renderDetail(run: AsyncRunSummary, width: number, innerW: number): string[] {
295
+ const stepCount = run.steps.length || 1;
296
+ const stepLabel = run.currentStep !== undefined ? `step ${run.currentStep + 1}/${stepCount}` : `steps ${stepCount}`;
297
+ const duration = run.endedAt !== undefined
298
+ ? formatDuration(Math.max(0, run.endedAt - run.startedAt))
299
+ : formatDuration(Math.max(0, Date.now() - run.startedAt));
300
+ const activity = run.lastActivityAt
301
+ ? run.activityState === "needs_attention"
302
+ ? `no activity for ${formatDuration(Math.max(0, Date.now() - run.lastActivityAt))}`
303
+ : `active ${formatDuration(Math.max(0, Date.now() - run.lastActivityAt))} ago`
304
+ : undefined;
305
+
306
+ const body: string[] = [];
307
+ body.push(row(`${run.id} | ${statusColor(this.theme, run.state)} | ${run.mode} | ${stepLabel} | ${duration}`, width, this.theme));
308
+ if (activity) body.push(row(truncateToWidth(activity, innerW), width, this.theme));
309
+ body.push(row("", width, this.theme));
310
+ body.push(row(this.theme.fg("accent", "Steps"), width, this.theme));
311
+ body.push(...this.renderStepRows(run, width, innerW));
312
+
313
+ const eventsPath = path.join(run.asyncDir, "events.jsonl");
314
+ const eventResult = readRecentEvents(eventsPath, DETAIL_EVENT_LIMIT);
315
+ body.push(row("", width, this.theme));
316
+ body.push(row(this.theme.fg("accent", "Recent events"), width, this.theme));
317
+ if (eventResult.warning) body.push(row(this.theme.fg("warning", truncateToWidth(eventResult.warning, innerW)), width, this.theme));
318
+ if (eventResult.events.length === 0 && !eventResult.warning) body.push(row(this.theme.fg("dim", " No events recorded."), width, this.theme));
319
+ for (const event of eventResult.events) {
320
+ body.push(row(truncateToWidth(` ${event}`, innerW), width, this.theme));
321
+ }
322
+
323
+ body.push(row("", width, this.theme));
324
+ body.push(row(this.theme.fg("accent", "Output tail"), width, this.theme));
325
+ if (run.outputFile) {
326
+ const outputPath = resolveRunPath(run.asyncDir, run.outputFile);
327
+ const tail = readTailLines(outputPath, OUTPUT_TAIL_LINES);
328
+ if (tail.warning) body.push(row(this.theme.fg("warning", truncateToWidth(tail.warning, innerW)), width, this.theme));
329
+ else if (tail.lines.length === 0) body.push(row(this.theme.fg("dim", " No output yet."), width, this.theme));
330
+ for (const line of tail.lines) body.push(row(truncateToWidth(` ${line}`, innerW), width, this.theme));
331
+ } else {
332
+ body.push(row(this.theme.fg("dim", " No output file recorded."), width, this.theme));
333
+ }
334
+
335
+ body.push(row("", width, this.theme));
336
+ body.push(row(this.theme.fg("accent", "Paths"), width, this.theme));
337
+ body.push(row(truncateToWidth(` cwd: ${shortenPath(run.cwd ?? run.asyncDir)}`, innerW), width, this.theme));
338
+ body.push(row(truncateToWidth(` asyncDir: ${shortenPath(run.asyncDir)}`, innerW), width, this.theme));
339
+ if (run.outputFile) body.push(row(truncateToWidth(` outputFile: ${shortenPath(resolveRunPath(run.asyncDir, run.outputFile))}`, innerW), width, this.theme));
340
+ if (run.sessionFile) body.push(row(truncateToWidth(` sessionFile: ${shortenPath(run.sessionFile)}`, innerW), width, this.theme));
341
+ if (run.sessionDir) body.push(row(truncateToWidth(` sessionDir: ${shortenPath(run.sessionDir)}`, innerW), width, this.theme));
342
+ const logPath = path.join(run.asyncDir, `subagent-log-${run.id}.md`);
343
+ if (fs.existsSync(logPath)) body.push(row(truncateToWidth(` runLog: ${shortenPath(logPath)}`, innerW), width, this.theme));
344
+
345
+ const maxOffset = Math.max(0, body.length - DETAIL_VIEWPORT_HEIGHT);
346
+ this.detailScrollOffset = Math.min(this.detailScrollOffset, maxOffset);
347
+ const visibleBody = body.slice(this.detailScrollOffset, this.detailScrollOffset + DETAIL_VIEWPORT_HEIGHT);
348
+ const above = this.detailScrollOffset;
349
+ const below = Math.max(0, body.length - (this.detailScrollOffset + visibleBody.length));
350
+ const scrollInfo = formatScrollInfo(above, below);
351
+ return [
352
+ renderHeader(`Subagent Run ${run.id.slice(0, 8)}`, width, this.theme),
353
+ ...visibleBody,
354
+ scrollInfo ? row(this.theme.fg("dim", scrollInfo), width, this.theme) : row("", width, this.theme),
355
+ renderFooter(" ↑↓ scroll esc summary q close read-only detail ", width, this.theme),
356
+ ];
357
+ }
358
+
192
359
  handleInput(data: string): void {
360
+ if (this.screen === "detail" && matchesKey(data, "escape")) {
361
+ this.screen = "list";
362
+ this.detailRunId = undefined;
363
+ this.tui.requestRender();
364
+ return;
365
+ }
193
366
  if (matchesKey(data, "escape") || matchesKey(data, "q") || matchesKey(data, "ctrl+c")) {
194
367
  this.done();
195
368
  return;
196
369
  }
370
+ if (this.screen === "detail") {
371
+ if (matchesKey(data, "up")) {
372
+ this.detailScrollOffset = Math.max(0, this.detailScrollOffset - 1);
373
+ this.tui.requestRender();
374
+ return;
375
+ }
376
+ if (matchesKey(data, "down")) {
377
+ this.detailScrollOffset++;
378
+ this.tui.requestRender();
379
+ return;
380
+ }
381
+ if (matchesKey(data, "pageup")) {
382
+ this.detailScrollOffset = Math.max(0, this.detailScrollOffset - DETAIL_VIEWPORT_HEIGHT);
383
+ this.tui.requestRender();
384
+ return;
385
+ }
386
+ if (matchesKey(data, "pagedown")) {
387
+ this.detailScrollOffset += DETAIL_VIEWPORT_HEIGHT;
388
+ this.tui.requestRender();
389
+ }
390
+ return;
391
+ }
392
+ if (matchesKey(data, "return")) {
393
+ const selected = selectedRun(this.rows, this.cursor);
394
+ if (selected) {
395
+ this.screen = "detail";
396
+ this.detailRunId = selected.id;
397
+ this.detailScrollOffset = 0;
398
+ this.tui.requestRender();
399
+ }
400
+ return;
401
+ }
197
402
  if (matchesKey(data, "up")) {
198
403
  this.cursor = Math.max(0, this.cursor - 1);
199
404
  this.ensureScrollVisible();
@@ -211,9 +416,18 @@ export class SubagentsStatusComponent implements Component {
211
416
  render(width: number): string[] {
212
417
  const w = Math.min(width, this.width);
213
418
  const innerW = w - 2;
419
+ const selected = selectedRun(this.rows, this.cursor);
420
+ if (this.screen === "detail") {
421
+ const detailRun = this.rows.find((row) => row.kind === "run" && row.run?.id === this.detailRunId)?.run;
422
+ if (detailRun) return this.renderDetail(detailRun, w, innerW);
423
+ return [
424
+ renderHeader("Subagent Run", w, this.theme),
425
+ row(this.theme.fg("warning", "Selected run is no longer available."), w, this.theme),
426
+ renderFooter(" esc summary q close ", w, this.theme),
427
+ ];
428
+ }
214
429
  const lines: string[] = [renderHeader("Subagents Status", w, this.theme)];
215
430
  const rows = this.rows.length > 0 ? this.rows : [{ kind: "section" as const, label: "No async runs found" }];
216
- const selected = selectedRun(this.rows, this.cursor);
217
431
  const visibleRows = rows.slice(this.scrollOffset, this.scrollOffset + this.viewportHeight);
218
432
  for (const statusRow of visibleRows) {
219
433
  if (statusRow.kind === "section") {
@@ -239,7 +453,7 @@ export class SubagentsStatusComponent implements Component {
239
453
  lines.push(row(this.theme.fg("dim", "No runs selected."), w, this.theme));
240
454
  }
241
455
 
242
- const footer = `↑↓ select esc close summary view ${this.active.length} active / ${this.recent.length} recent`;
456
+ const footer = `↑↓ select enter detail esc close summary view ${this.active.length} active / ${this.recent.length} recent`;
243
457
  lines.push(renderFooter(truncateToWidth(footer, innerW), w, this.theme));
244
458
  return lines;
245
459
  }
package/worktree.ts CHANGED
@@ -100,17 +100,8 @@ function runGitChecked(cwd: string, args: string[]): string {
100
100
  }
101
101
 
102
102
  function resolveRepoState(cwd: string): RepoState {
103
- const repoCheck = runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
104
- if (repoCheck.status !== 0 || repoCheck.stdout.trim() !== "true") {
105
- throw new Error("worktree isolation requires a git repository");
106
- }
107
-
103
+ const cwdRelative = resolveRepoCwdRelative(cwd);
108
104
  const toplevel = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
109
- const rawPrefix = runGitChecked(cwd, ["rev-parse", "--show-prefix"]).trim();
110
- const normalizedPrefix = rawPrefix
111
- ? path.normalize(rawPrefix.replace(/[\\/]+$/, ""))
112
- : "";
113
- const cwdRelative = normalizedPrefix === "." ? "" : normalizedPrefix;
114
105
 
115
106
  const status = runGitChecked(toplevel, ["status", "--porcelain"]);
116
107
  if (status.trim().length > 0) {
@@ -165,6 +156,24 @@ function buildWorktreePath(runId: string, index: number): string {
165
156
  return path.join(os.tmpdir(), `pi-worktree-${runId}-${index}`);
166
157
  }
167
158
 
159
+ function resolveRepoCwdRelative(cwd: string): string {
160
+ const repoCheck = runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
161
+ if (repoCheck.status !== 0 || repoCheck.stdout.trim() !== "true") {
162
+ throw new Error("worktree isolation requires a git repository");
163
+ }
164
+ const rawPrefix = runGitChecked(cwd, ["rev-parse", "--show-prefix"]).trim();
165
+ const normalizedPrefix = rawPrefix
166
+ ? path.normalize(rawPrefix.replace(/[\\/]+$/, ""))
167
+ : "";
168
+ return normalizedPrefix === "." ? "" : normalizedPrefix;
169
+ }
170
+
171
+ export function resolveExpectedWorktreeAgentCwd(cwd: string, runId: string, index: number): string {
172
+ const cwdRelative = resolveRepoCwdRelative(cwd);
173
+ const worktreePath = buildWorktreePath(runId, index);
174
+ return cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath;
175
+ }
176
+
168
177
  function linkNodeModulesIfPresent(toplevel: string, worktreePath: string): boolean {
169
178
  const nodeModulesPath = path.join(toplevel, "node_modules");
170
179
  const nodeModulesLinkPath = path.join(worktreePath, "node_modules");