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.
- package/CHANGELOG.md +11 -0
- package/README.md +2 -2
- package/agent-manager-chain-detail.ts +50 -6
- package/agent-manager-detail.ts +15 -2
- package/agent-manager.ts +76 -23
- package/async-execution.ts +45 -18
- package/chain-execution.ts +12 -2
- package/execution.ts +2 -4
- package/index.ts +1 -1
- package/intercom-bridge.ts +3 -3
- package/package.json +5 -1
- package/prompts/parallel-review.md +8 -0
- package/schemas.ts +4 -1
- package/settings.ts +5 -0
- package/slash-commands.ts +27 -28
- package/subagent-executor.ts +102 -18
- package/subagent-runner.ts +8 -0
- package/subagents-status.ts +216 -2
- package/worktree.ts +19 -10
package/subagents-status.ts
CHANGED
|
@@ -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
|
|
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");
|