pi-subagents 0.35.0 → 0.36.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 +59 -0
- package/README.md +132 -24
- package/agents/advisor.md +73 -0
- package/package.json +8 -12
- package/skills/pi-subagents/SKILL.md +22 -9
- package/src/agents/agents.ts +22 -5
- package/src/api/delegation.ts +125 -0
- package/src/extension/config.ts +7 -1
- package/src/extension/index.ts +50 -38
- package/src/extension/rpc.ts +27 -2
- package/src/extension/schemas.ts +22 -2
- package/src/extension/tool-description.ts +2 -2
- package/src/intercom/intercom-bridge.ts +1 -1
- package/src/intercom/native-supervisor-channel.ts +45 -6
- package/src/intercom/result-intercom.ts +7 -0
- package/src/runs/background/async-execution.ts +34 -4
- package/src/runs/background/async-job-tracker.ts +4 -0
- package/src/runs/background/async-resume.ts +27 -5
- package/src/runs/background/async-status.ts +76 -3
- package/src/runs/background/chain-append.ts +2 -0
- package/src/runs/background/completion-batcher.ts +6 -4
- package/src/runs/background/completion-dedupe.ts +2 -11
- package/src/runs/background/fleet-view.ts +9 -4
- package/src/runs/background/notify.ts +132 -120
- package/src/runs/background/result-watcher.ts +138 -78
- package/src/runs/background/run-status.ts +3 -1
- package/src/runs/background/subagent-runner.ts +225 -43
- package/src/runs/background/subagent-wait.ts +130 -4
- package/src/runs/background/wait-tool.ts +2 -2
- package/src/runs/foreground/chain-execution.ts +176 -111
- package/src/runs/foreground/execution.ts +90 -36
- package/src/runs/foreground/foreground-control.ts +90 -0
- package/src/runs/foreground/subagent-executor.ts +394 -163
- package/src/runs/shared/acceptance.ts +55 -13
- package/src/runs/shared/agent-contract.ts +38 -0
- package/src/runs/shared/child-protocol.ts +1 -1
- package/src/runs/shared/completion-guard.ts +36 -5
- package/src/runs/shared/context-mode.ts +44 -0
- package/src/runs/shared/dynamic-fanout.ts +4 -4
- package/src/runs/shared/long-running-guard.ts +4 -0
- package/src/runs/shared/nested-events.ts +27 -2
- package/src/runs/shared/parallel-handoff.ts +154 -0
- package/src/runs/shared/parallel-utils.ts +6 -0
- package/src/runs/shared/pi-args.ts +23 -14
- package/src/runs/shared/run-history.ts +90 -5
- package/src/runs/shared/structured-output.ts +112 -7
- package/src/runs/shared/subagent-control.ts +4 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +17 -18
- package/src/runs/shared/task-intent.ts +10 -5
- package/src/runs/shared/tool-availability.ts +3 -1
- package/src/runs/shared/tool-budget.ts +11 -5
- package/src/runs/shared/turn-budget.ts +2 -1
- package/src/runs/shared/worktree.ts +63 -14
- package/src/shared/accessible-dir.ts +25 -0
- package/src/shared/artifacts.ts +37 -7
- package/src/shared/atomic-json.ts +14 -42
- package/src/shared/child-transcript.ts +52 -0
- package/src/shared/file-system-retry.ts +47 -0
- package/src/shared/settings.ts +9 -1
- package/src/shared/types.ts +211 -22
- package/src/slash/delegation-adapters.ts +152 -5
- package/src/slash/delegation-json.ts +108 -0
- package/src/slash/delegation-request.ts +182 -36
- package/src/slash/prompt-template-bridge.ts +222 -37
- package/src/slash/selector.ts +147 -0
- package/src/slash/slash-commands.ts +14 -5
- package/src/slash/slash-live-state.ts +2 -2
- package/src/slash/subagents-admin.ts +42 -42
- package/src/tui/fleet-status.ts +362 -0
- package/src/tui/fleet-transcript.ts +472 -0
- package/src/tui/fleet.ts +318 -59
- package/src/tui/render.ts +25 -15
- package/src/watchdog/change-signature.ts +105 -12
- package/src/watchdog/review.ts +7 -2
- package/src/watchdog/runtime.ts +5 -3
- package/src/slash/subagents-editor.ts +0 -86
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { getLanguageFromPath, highlightCode, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Markdown, truncateToWidth, visibleWidth, wrapTextWithAnsi, type MarkdownTheme } from "@earendil-works/pi-tui";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_RECORDS = 240;
|
|
7
|
+
const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
const MAX_MESSAGE_CHARS = 64 * 1024;
|
|
9
|
+
const TOOL_PREVIEW_LINES = 7;
|
|
10
|
+
|
|
11
|
+
type Theme = ExtensionContext["ui"]["theme"];
|
|
12
|
+
|
|
13
|
+
export type FleetTranscriptEvent =
|
|
14
|
+
| { kind: "assistant"; text: string; model?: string; timestamp?: number }
|
|
15
|
+
| { kind: "user"; text: string; timestamp?: number }
|
|
16
|
+
| { kind: "tool"; toolCallId?: string; name: string; args?: string; argsPayload?: string; output?: string; outputTruncated?: boolean; status: "running" | "complete" | "error"; error?: string; startedAt?: number; endedAt?: number; timestamp?: number }
|
|
17
|
+
| { kind: "notice"; text: string; tone: "muted" | "warning" | "error"; timestamp?: number };
|
|
18
|
+
|
|
19
|
+
export interface FleetTranscript {
|
|
20
|
+
path: string;
|
|
21
|
+
events: FleetTranscriptEvent[];
|
|
22
|
+
truncated: boolean;
|
|
23
|
+
warning?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FleetTranscriptReadOptions {
|
|
27
|
+
trustedRoots: string[];
|
|
28
|
+
maxRecords?: number;
|
|
29
|
+
maxBytes?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface MutableToolEvent {
|
|
33
|
+
kind: "tool";
|
|
34
|
+
toolCallId?: string;
|
|
35
|
+
name: string;
|
|
36
|
+
args?: string;
|
|
37
|
+
argsPayload?: string;
|
|
38
|
+
output?: string;
|
|
39
|
+
outputTruncated?: boolean;
|
|
40
|
+
status: "running" | "complete" | "error";
|
|
41
|
+
error?: string;
|
|
42
|
+
startedAt?: number;
|
|
43
|
+
endedAt?: number;
|
|
44
|
+
timestamp?: number;
|
|
45
|
+
resultSeen?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
|
49
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
50
|
+
? value as Record<string, unknown>
|
|
51
|
+
: undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function stringValue(value: unknown): string | undefined {
|
|
55
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function numberValue(value: unknown): number | undefined {
|
|
59
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pathWithin(base: string, candidate: string): boolean {
|
|
63
|
+
const resolvedBase = path.resolve(base);
|
|
64
|
+
const resolvedCandidate = path.resolve(candidate);
|
|
65
|
+
return resolvedCandidate === resolvedBase || resolvedCandidate.startsWith(`${resolvedBase}${path.sep}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function errorMessage(error: unknown): string {
|
|
69
|
+
return error instanceof Error ? error.message : String(error);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isNotFoundError(error: unknown): boolean {
|
|
73
|
+
return Boolean(objectValue(error)?.code === "ENOENT");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateTranscriptPath(filePath: string, trustedRoots: string[]): { resolvedPath?: string; warning?: string } {
|
|
77
|
+
if (trustedRoots.length === 0) return { warning: `Transcript preview has no trusted root: ${filePath}` };
|
|
78
|
+
const resolvedPath = path.resolve(filePath);
|
|
79
|
+
if (!trustedRoots.some((root) => pathWithin(root, resolvedPath))) {
|
|
80
|
+
return { warning: `Transcript is outside trusted roots: ${filePath}` };
|
|
81
|
+
}
|
|
82
|
+
let stat: fs.Stats;
|
|
83
|
+
try {
|
|
84
|
+
stat = fs.lstatSync(resolvedPath);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (isNotFoundError(error)) return {};
|
|
87
|
+
return { warning: `Transcript could not be inspected: ${errorMessage(error)}` };
|
|
88
|
+
}
|
|
89
|
+
if (stat.isSymbolicLink()) return { warning: `Transcript preview refused a symlink: ${filePath}` };
|
|
90
|
+
if (!stat.isFile()) return { warning: `Transcript path is not a file: ${filePath}` };
|
|
91
|
+
try {
|
|
92
|
+
const realPath = fs.realpathSync(resolvedPath);
|
|
93
|
+
const realRoots = trustedRoots
|
|
94
|
+
.filter((root) => fs.existsSync(root))
|
|
95
|
+
.map((root) => fs.realpathSync(root));
|
|
96
|
+
if (!realRoots.some((root) => pathWithin(root, realPath))) {
|
|
97
|
+
return { warning: `Transcript resolves outside trusted roots: ${filePath}` };
|
|
98
|
+
}
|
|
99
|
+
return { resolvedPath: realPath };
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return { warning: `Transcript path could not be resolved: ${errorMessage(error)}` };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isCompleteRecord(line: string | undefined): boolean {
|
|
106
|
+
if (!line?.trim()) return false;
|
|
107
|
+
try {
|
|
108
|
+
return objectValue(JSON.parse(line)) !== undefined;
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readTailLines(filePath: string, maxBytes: number): { lines: string[]; truncated: boolean; warning?: string } {
|
|
115
|
+
let fd: number | undefined;
|
|
116
|
+
try {
|
|
117
|
+
const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
|
|
118
|
+
fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
|
|
119
|
+
const stat = fs.fstatSync(fd);
|
|
120
|
+
if (!stat.isFile()) return { lines: [], truncated: false, warning: `Transcript path is not a file: ${filePath}` };
|
|
121
|
+
if (stat.size === 0) return { lines: [], truncated: false };
|
|
122
|
+
const bytesToRead = Math.min(stat.size, maxBytes);
|
|
123
|
+
const start = stat.size - bytesToRead;
|
|
124
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
125
|
+
const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, start);
|
|
126
|
+
const content = buffer.subarray(0, bytesRead).toString("utf-8");
|
|
127
|
+
const endsWithNewline = content.endsWith("\n");
|
|
128
|
+
let lines = content.split(/\r?\n/);
|
|
129
|
+
if (start > 0 && lines.length > 0) lines = lines.slice(1);
|
|
130
|
+
if (lines.at(-1) === "") lines = lines.slice(0, -1);
|
|
131
|
+
else if (!endsWithNewline && !isCompleteRecord(lines.at(-1))) lines = lines.slice(0, -1);
|
|
132
|
+
return { lines, truncated: start > 0 };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return { lines: [], truncated: false, warning: `Transcript could not be read: ${errorMessage(error)}` };
|
|
135
|
+
} finally {
|
|
136
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function clipMessage(text: string): string {
|
|
141
|
+
if (text.length <= MAX_MESSAGE_CHARS) return text;
|
|
142
|
+
return `${text.slice(0, MAX_MESSAGE_CHARS)}\n\n… message truncated`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function findTool(
|
|
146
|
+
events: FleetTranscriptEvent[],
|
|
147
|
+
toolCallId: string | undefined,
|
|
148
|
+
name: string | undefined,
|
|
149
|
+
): MutableToolEvent | undefined {
|
|
150
|
+
if (toolCallId) {
|
|
151
|
+
for (let index = events.length - 1; index >= 0; index--) {
|
|
152
|
+
const event = events[index];
|
|
153
|
+
if (event?.kind === "tool" && event.toolCallId === toolCallId) return event as MutableToolEvent;
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
for (let index = events.length - 1; index >= 0; index--) {
|
|
158
|
+
const event = events[index];
|
|
159
|
+
if (event?.kind !== "tool") continue;
|
|
160
|
+
const tool = event as MutableToolEvent;
|
|
161
|
+
if ((!name || tool.name === name) && !tool.resultSeen) return tool;
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function appendTextEvent(
|
|
167
|
+
events: FleetTranscriptEvent[],
|
|
168
|
+
kind: "assistant" | "user",
|
|
169
|
+
text: string,
|
|
170
|
+
metadata: { model?: string; timestamp?: number },
|
|
171
|
+
): void {
|
|
172
|
+
const clipped = clipMessage(text.trim());
|
|
173
|
+
if (!clipped) return;
|
|
174
|
+
const previous = events.at(-1);
|
|
175
|
+
if (previous?.kind === kind && previous.text === clipped) return;
|
|
176
|
+
events.push({ kind, text: clipped, ...metadata });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseTranscriptLines(lines: string[], conversationStarted = false): { events: FleetTranscriptEvent[]; malformed: number; explicitTruncation: boolean } {
|
|
180
|
+
const events: FleetTranscriptEvent[] = [];
|
|
181
|
+
let malformed = 0;
|
|
182
|
+
let explicitTruncation = false;
|
|
183
|
+
let assistantSeen = conversationStarted;
|
|
184
|
+
|
|
185
|
+
for (const line of lines) {
|
|
186
|
+
if (!line.trim()) continue;
|
|
187
|
+
let record: Record<string, unknown> | undefined;
|
|
188
|
+
try {
|
|
189
|
+
record = objectValue(JSON.parse(line));
|
|
190
|
+
} catch {
|
|
191
|
+
malformed++;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (!record) {
|
|
195
|
+
malformed++;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const recordType = stringValue(record.recordType);
|
|
200
|
+
const timestamp = numberValue(record.ts);
|
|
201
|
+
if (recordType === "truncated") {
|
|
202
|
+
explicitTruncation = true;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (recordType === "tool_start") {
|
|
206
|
+
const name = stringValue(record.toolName) ?? "tool";
|
|
207
|
+
events.push({
|
|
208
|
+
kind: "tool",
|
|
209
|
+
...(stringValue(record.toolCallId) ? { toolCallId: stringValue(record.toolCallId) } : {}),
|
|
210
|
+
name,
|
|
211
|
+
...(stringValue(record.argsPreview) ? { args: stringValue(record.argsPreview) } : {}),
|
|
212
|
+
...(stringValue(record.argsPayload) ? { argsPayload: stringValue(record.argsPayload) } : {}),
|
|
213
|
+
status: "running",
|
|
214
|
+
...(timestamp !== undefined ? { timestamp, startedAt: timestamp } : {}),
|
|
215
|
+
});
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (recordType === "tool_end") {
|
|
219
|
+
const tool = findTool(events, stringValue(record.toolCallId), stringValue(record.toolName));
|
|
220
|
+
if (tool && !tool.resultSeen) tool.status = record.isError === true ? "error" : "complete";
|
|
221
|
+
if (tool && timestamp !== undefined && tool.endedAt === undefined) tool.endedAt = timestamp;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (recordType === "stderr") {
|
|
225
|
+
const text = stringValue(record.text);
|
|
226
|
+
if (text) events.push({ kind: "notice", text: clipMessage(text), tone: "error", ...(timestamp !== undefined ? { timestamp } : {}) });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (recordType !== "message") continue;
|
|
230
|
+
|
|
231
|
+
const message = objectValue(record.message);
|
|
232
|
+
const role = stringValue(record.role) ?? stringValue(message?.role);
|
|
233
|
+
const text = stringValue(record.text) ?? stringValue(message?.text) ?? stringValue(message?.content);
|
|
234
|
+
if (role === "toolResult" || role === "tool_result") {
|
|
235
|
+
const toolCallId = stringValue(record.toolCallId) ?? stringValue(message?.toolCallId);
|
|
236
|
+
const name = stringValue(record.toolName) ?? stringValue(message?.toolName) ?? "tool";
|
|
237
|
+
const failed = record.isError === true || message?.isError === true;
|
|
238
|
+
let tool = findTool(events, toolCallId, name);
|
|
239
|
+
if (!tool) {
|
|
240
|
+
tool = {
|
|
241
|
+
kind: "tool",
|
|
242
|
+
...(toolCallId ? { toolCallId } : {}),
|
|
243
|
+
name,
|
|
244
|
+
status: failed ? "error" : "complete",
|
|
245
|
+
...(timestamp !== undefined ? { timestamp } : {}),
|
|
246
|
+
};
|
|
247
|
+
events.push(tool);
|
|
248
|
+
}
|
|
249
|
+
if (!tool.resultSeen) {
|
|
250
|
+
tool.resultSeen = true;
|
|
251
|
+
tool.status = failed ? "error" : "complete";
|
|
252
|
+
if (timestamp !== undefined && tool.endedAt === undefined) tool.endedAt = timestamp;
|
|
253
|
+
if (text && (!failed || tool.output === undefined)) {
|
|
254
|
+
tool.output = clipMessage(text);
|
|
255
|
+
tool.outputTruncated = record.outputTruncated === true || text.includes("… payload truncated") || text.includes("[Showing lines");
|
|
256
|
+
}
|
|
257
|
+
if (failed && text) tool.error = clipMessage(text.split(/\r?\n/).find((candidate) => candidate.trim()) ?? text);
|
|
258
|
+
}
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (role === "assistant") {
|
|
262
|
+
assistantSeen = true;
|
|
263
|
+
if (text) appendTextEvent(events, "assistant", text, {
|
|
264
|
+
...(stringValue(record.model) ? { model: stringValue(record.model) } : {}),
|
|
265
|
+
...(timestamp !== undefined ? { timestamp } : {}),
|
|
266
|
+
});
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (role === "user" && assistantSeen && text) {
|
|
270
|
+
appendTextEvent(events, "user", text, timestamp !== undefined ? { timestamp } : {});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
for (const event of events) {
|
|
275
|
+
if (event.kind === "tool") delete (event as MutableToolEvent).resultSeen;
|
|
276
|
+
}
|
|
277
|
+
return { events, malformed, explicitTruncation };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function readFleetTranscript(filePath: string, options: FleetTranscriptReadOptions): FleetTranscript {
|
|
281
|
+
const validated = validateTranscriptPath(filePath, options.trustedRoots);
|
|
282
|
+
if (!validated.resolvedPath) {
|
|
283
|
+
return { path: filePath, events: [], truncated: false, ...(validated.warning ? { warning: validated.warning } : {}) };
|
|
284
|
+
}
|
|
285
|
+
const maxRecords = Math.max(1, options.maxRecords ?? DEFAULT_MAX_RECORDS);
|
|
286
|
+
const tail = readTailLines(validated.resolvedPath, Math.max(1024, options.maxBytes ?? DEFAULT_MAX_BYTES));
|
|
287
|
+
const recordsOmitted = tail.truncated || tail.lines.length > maxRecords;
|
|
288
|
+
const selectedLines = tail.lines.slice(-maxRecords);
|
|
289
|
+
const parsed = parseTranscriptLines(selectedLines, recordsOmitted);
|
|
290
|
+
const warnings = [
|
|
291
|
+
tail.warning,
|
|
292
|
+
parsed.malformed > 0 ? `Skipped ${parsed.malformed} malformed transcript record${parsed.malformed === 1 ? "" : "s"}.` : undefined,
|
|
293
|
+
].filter((value): value is string => Boolean(value));
|
|
294
|
+
return {
|
|
295
|
+
path: filePath,
|
|
296
|
+
events: parsed.events,
|
|
297
|
+
truncated: tail.truncated || tail.lines.length > maxRecords || parsed.explicitTruncation,
|
|
298
|
+
...(warnings.length ? { warning: warnings.join(" ") } : {}),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function statusGlyph(event: Extract<FleetTranscriptEvent, { kind: "tool" }>, theme: Theme): string {
|
|
303
|
+
if (event.status === "running") return theme.fg("warning", "●");
|
|
304
|
+
if (event.status === "error") return theme.fg("error", "✗");
|
|
305
|
+
return theme.fg("success", "✓");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function jsonScalar(value: unknown): string | undefined {
|
|
309
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
310
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function parseToolArgs(event: Extract<FleetTranscriptEvent, { kind: "tool" }>): Record<string, unknown> | undefined {
|
|
315
|
+
if (!event.argsPayload) return undefined;
|
|
316
|
+
try {
|
|
317
|
+
return objectValue(JSON.parse(event.argsPayload));
|
|
318
|
+
} catch {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function toolDuration(event: Extract<FleetTranscriptEvent, { kind: "tool" }>): string | undefined {
|
|
324
|
+
if (event.startedAt === undefined || event.endedAt === undefined) return undefined;
|
|
325
|
+
return `${((event.endedAt - event.startedAt) / 1000).toFixed(1)}s`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function renderExpandedTool(
|
|
329
|
+
event: Extract<FleetTranscriptEvent, { kind: "tool" }>,
|
|
330
|
+
width: number,
|
|
331
|
+
theme: Theme,
|
|
332
|
+
): string[] {
|
|
333
|
+
const lines: string[] = [];
|
|
334
|
+
const args = parseToolArgs(event);
|
|
335
|
+
const glyph = statusGlyph(event, theme);
|
|
336
|
+
const output = event.output ?? event.error;
|
|
337
|
+
const outputColor = event.status === "error" ? "error" : "toolOutput";
|
|
338
|
+
if (event.name === "bash") {
|
|
339
|
+
const command = jsonScalar(args?.command) ?? event.args ?? "(unknown command)";
|
|
340
|
+
lines.push(railLine(`${glyph} ${theme.fg("toolTitle", theme.bold(`$ ${command}`))}`, width, theme));
|
|
341
|
+
if (output) {
|
|
342
|
+
for (const outputLine of output.replace(/\s+$/, "").split(/\r?\n/)) {
|
|
343
|
+
for (const wrapped of renderWrapped(theme.fg(outputColor, outputLine), Math.max(1, width - 4))) {
|
|
344
|
+
lines.push(railLine(` ${wrapped}`, width, theme));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const duration = toolDuration(event);
|
|
349
|
+
if (duration) lines.push(railLine(theme.fg("dim", ` Took ${duration}`), width, theme));
|
|
350
|
+
return lines;
|
|
351
|
+
}
|
|
352
|
+
if (event.name === "read") {
|
|
353
|
+
const filePath = jsonScalar(args?.path ?? args?.file_path);
|
|
354
|
+
const language = filePath ? getLanguageFromPath(filePath) : undefined;
|
|
355
|
+
const rendered = !output
|
|
356
|
+
? []
|
|
357
|
+
: event.status === "error"
|
|
358
|
+
? output.split("\n").map((line) => theme.fg("error", line))
|
|
359
|
+
: language
|
|
360
|
+
? highlightCode(output, language)
|
|
361
|
+
: output.split("\n");
|
|
362
|
+
lines.push(railLine(`${glyph} ${theme.fg("toolTitle", theme.bold(`read ${filePath ?? event.args ?? ""}`))}`, width, theme));
|
|
363
|
+
for (const line of rendered) {
|
|
364
|
+
for (const wrapped of renderWrapped(line, Math.max(1, width - 4))) lines.push(railLine(` ${wrapped}`, width, theme));
|
|
365
|
+
}
|
|
366
|
+
return lines;
|
|
367
|
+
}
|
|
368
|
+
lines.push(railLine(`${glyph} ${theme.fg("toolTitle", theme.bold(event.name))}`, width, theme));
|
|
369
|
+
if (event.argsPayload) {
|
|
370
|
+
lines.push(railLine(theme.fg("dim", " args"), width, theme));
|
|
371
|
+
for (const argLine of event.argsPayload.split(/\r?\n/)) {
|
|
372
|
+
for (const wrapped of renderWrapped(theme.fg("muted", argLine), Math.max(1, width - 4))) lines.push(railLine(` ${wrapped}`, width, theme));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (output) {
|
|
376
|
+
lines.push(railLine(theme.fg(event.status === "error" ? "error" : "dim", event.status === "error" ? " error" : " output"), width, theme));
|
|
377
|
+
for (const outputLine of output.split(/\r?\n/)) {
|
|
378
|
+
for (const wrapped of renderWrapped(theme.fg(outputColor, outputLine), Math.max(1, width - 4))) lines.push(railLine(` ${wrapped}`, width, theme));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return lines;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function bounded(text: string, width: number): string {
|
|
385
|
+
return truncateToWidth(text, Math.max(0, width));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function railLine(content: string, width: number, theme: Theme): string {
|
|
389
|
+
return bounded(`${theme.fg("borderMuted", "│")} ${content}`, width);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function renderWrapped(text: string, width: number): string[] {
|
|
393
|
+
return wrapTextWithAnsi(text, Math.max(1, width));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function renderFleetTranscript(
|
|
397
|
+
transcript: FleetTranscript,
|
|
398
|
+
width: number,
|
|
399
|
+
theme: Theme,
|
|
400
|
+
markdownTheme: MarkdownTheme,
|
|
401
|
+
options: { expandedTools?: boolean } = {},
|
|
402
|
+
): string[] {
|
|
403
|
+
if (width <= 0) return [];
|
|
404
|
+
const lines: string[] = [];
|
|
405
|
+
if (transcript.truncated) lines.push(bounded(theme.fg("dim", "↑ Earlier activity omitted"), width));
|
|
406
|
+
if (transcript.warning) {
|
|
407
|
+
for (const line of renderWrapped(transcript.warning, Math.max(1, width - 2))) {
|
|
408
|
+
lines.push(bounded(`${theme.fg("warning", "!")} ${theme.fg("warning", line)}`, width));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for (const event of transcript.events) {
|
|
413
|
+
if (event.kind === "tool") {
|
|
414
|
+
if (options.expandedTools && (event.output || event.argsPayload || event.error)) {
|
|
415
|
+
lines.push(...renderExpandedTool(event, width, theme));
|
|
416
|
+
lines.push(railLine(theme.fg("dim", " x to collapse"), width, theme));
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
const title = theme.fg("toolTitle", theme.bold(event.name));
|
|
420
|
+
const args = event.args ? ` ${theme.fg("dim", event.args)}` : "";
|
|
421
|
+
const suffix = event.status === "running" ? theme.fg("warning", " running") : "";
|
|
422
|
+
lines.push(bounded(`${theme.fg("borderMuted", "├─")} ${statusGlyph(event, theme)} ${title}${args}${suffix}`, width));
|
|
423
|
+
if (event.output && event.status !== "error" && event.name === "bash") {
|
|
424
|
+
const outputLines = event.output.replace(/\s+$/, "").split(/\r?\n/);
|
|
425
|
+
const visible = outputLines.slice(-TOOL_PREVIEW_LINES);
|
|
426
|
+
const hidden = Math.max(0, outputLines.length - visible.length);
|
|
427
|
+
for (const outputLine of visible) {
|
|
428
|
+
for (const wrapped of renderWrapped(theme.fg("toolOutput", outputLine), Math.max(1, width - 4))) {
|
|
429
|
+
lines.push(railLine(` ${wrapped}`, width, theme));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (hidden > 0) lines.push(railLine(theme.fg("dim", ` … ${hidden} earlier lines · x to expand`), width, theme));
|
|
433
|
+
const duration = toolDuration(event);
|
|
434
|
+
lines.push(railLine(theme.fg("dim", ` Took${duration ? ` ${duration}` : ""}`), width, theme));
|
|
435
|
+
} else if (event.output && event.status !== "error") {
|
|
436
|
+
const summary = truncateToWidth(event.output.replace(/\s+/g, " ").trim(), Math.max(1, width - 18), "…");
|
|
437
|
+
if (summary) lines.push(railLine(theme.fg("dim", ` ${summary} · x to expand`), width, theme));
|
|
438
|
+
}
|
|
439
|
+
if (event.error) {
|
|
440
|
+
for (const errorLine of renderWrapped(event.error, Math.max(1, width - 4))) {
|
|
441
|
+
lines.push(railLine(theme.fg("error", ` ${errorLine}`), width, theme));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (event.kind === "notice") {
|
|
447
|
+
const color = event.tone === "error" ? "error" : event.tone === "warning" ? "warning" : "dim";
|
|
448
|
+
for (const noticeLine of renderWrapped(event.text, Math.max(1, width - 2))) {
|
|
449
|
+
lines.push(railLine(theme.fg(color, noticeLine), width, theme));
|
|
450
|
+
}
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const assistant = event.kind === "assistant";
|
|
455
|
+
const label = assistant ? "Assistant" : "Supervisor";
|
|
456
|
+
const marker = assistant ? theme.fg("accent", "◆") : theme.fg("warning", "◇");
|
|
457
|
+
const model = assistant && event.model ? theme.fg("dim", ` · ${event.model}`) : "";
|
|
458
|
+
lines.push(bounded(`${marker} ${theme.bold(label)}${model}`, width));
|
|
459
|
+
if (assistant) {
|
|
460
|
+
const rendered = new Markdown(event.text, 0, 0, markdownTheme).render(Math.max(1, width - 2));
|
|
461
|
+
for (const markdownLine of rendered) lines.push(railLine(markdownLine, width, theme));
|
|
462
|
+
} else {
|
|
463
|
+
for (const userLine of renderWrapped(event.text, Math.max(1, width - 2))) {
|
|
464
|
+
lines.push(railLine(userLine, width, theme));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
lines.push(theme.fg("borderMuted", "│"));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
while (lines.length > 0 && visibleWidth(lines.at(-1) ?? "") === 1) lines.pop();
|
|
471
|
+
return lines;
|
|
472
|
+
}
|