brl-subagent 2.3.4
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/LICENSE +21 -0
- package/README.md +257 -0
- package/package.json +49 -0
- package/presets/code-reviewer.md +37 -0
- package/presets/data-analyst.md +33 -0
- package/presets/debugger.md +36 -0
- package/presets/dev-agent.md +30 -0
- package/presets/rapid-prototyper.md +25 -0
- package/presets/refactorer.md +34 -0
- package/presets/security-auditor.md +39 -0
- package/presets/tech-writer.md +33 -0
- package/presets/test-engineer.md +34 -0
- package/src/concurrency.ts +186 -0
- package/src/diff.ts +102 -0
- package/src/event-bus.ts +86 -0
- package/src/git.ts +187 -0
- package/src/history.ts +177 -0
- package/src/index.ts +3824 -0
- package/src/logging.ts +135 -0
- package/src/messaging.ts +164 -0
- package/src/metrics.ts +221 -0
- package/src/model-availability.ts +73 -0
- package/src/notify-completion.ts +207 -0
- package/src/params.ts +225 -0
- package/src/preflight.ts +150 -0
- package/src/presets.ts +425 -0
- package/src/prompt.ts +169 -0
- package/src/reports.ts +325 -0
- package/src/router.ts +133 -0
- package/src/runner.ts +877 -0
- package/src/sanitize.ts +365 -0
- package/src/scheduler.ts +187 -0
- package/src/session-manager.ts +1065 -0
- package/src/state.ts +590 -0
- package/src/templates.ts +469 -0
- package/src/transcript-tail.ts +327 -0
- package/src/transcript.ts +119 -0
- package/src/tui-format.ts +51 -0
- package/src/tui.ts +3103 -0
- package/src/types.ts +803 -0
- package/src/unit-run.ts +269 -0
- package/src/update.ts +109 -0
- package/src/validate.ts +302 -0
- package/templates/analyze-data.md +9 -0
- package/templates/code-review.md +10 -0
- package/templates/debug-issue.md +9 -0
- package/templates/implement-feature.md +9 -0
- package/templates/prototype.md +9 -0
- package/templates/refactor.md +9 -0
- package/templates/security-audit.md +10 -0
- package/templates/write-docs.md +9 -0
- package/templates/write-tests.md +10 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brl-subagent — Pure transcript-tail rendering for the drill-in overlay
|
|
3
|
+
*
|
|
4
|
+
* Line planning for the full-screen agent transcript view (showAgentDetail):
|
|
5
|
+
* message filtering, tail selection, per-block line caps, budget fitting, and
|
|
6
|
+
* per-line width truncation. Kept free of pi-tui imports so it is
|
|
7
|
+
* unit-testable without a TUI harness.
|
|
8
|
+
*
|
|
9
|
+
* Messages are matched STRUCTURALLY (no pi-ai import): a message is renderable
|
|
10
|
+
* when its `role` is one of user/assistant/toolResult; content blocks are
|
|
11
|
+
* matched by `type` (text/thinking/toolCall).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Structural message types (subset of the session transcript shapes)
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export interface TranscriptTextBlock {
|
|
19
|
+
type: "text";
|
|
20
|
+
text?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TranscriptThinkingBlock {
|
|
24
|
+
type: "thinking";
|
|
25
|
+
thinking?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TranscriptToolCallBlock {
|
|
29
|
+
type: "toolCall";
|
|
30
|
+
name?: string;
|
|
31
|
+
arguments?: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type TranscriptContentBlock =
|
|
35
|
+
| TranscriptTextBlock
|
|
36
|
+
| TranscriptThinkingBlock
|
|
37
|
+
| TranscriptToolCallBlock;
|
|
38
|
+
|
|
39
|
+
export interface TranscriptMessage {
|
|
40
|
+
role?: string;
|
|
41
|
+
content?: string | TranscriptContentBlock[] | null;
|
|
42
|
+
toolName?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Tail selection
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/** True for messages the drill-in view can render (user/assistant/toolResult). */
|
|
50
|
+
export function isRenderableTranscriptMessage(
|
|
51
|
+
msg: unknown,
|
|
52
|
+
): msg is TranscriptMessage {
|
|
53
|
+
if (!msg || typeof msg !== "object") return false;
|
|
54
|
+
const role = (msg as { role?: unknown }).role;
|
|
55
|
+
return role === "user" || role === "assistant" || role === "toolResult";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The transcript tail: the last `maxMessages` RENDERABLE messages, in order.
|
|
60
|
+
* Non-renderable messages (custom types like bash-execution updates) are
|
|
61
|
+
* skipped first so the tail is full of meaningful transcript content.
|
|
62
|
+
*/
|
|
63
|
+
export function buildTranscriptTail(
|
|
64
|
+
messages: readonly unknown[],
|
|
65
|
+
maxMessages: number,
|
|
66
|
+
): TranscriptMessage[] {
|
|
67
|
+
if (maxMessages <= 0) return [];
|
|
68
|
+
return messages.filter(isRenderableTranscriptMessage).slice(-maxMessages);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Per-block truncation
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Truncate `text` to its last `maxLines` lines. When truncated, a marker line
|
|
77
|
+
* (default "… (truncated)") is PREPENDED so the cut point is visible above
|
|
78
|
+
* the shown tail. Untruncated text is returned unchanged (marker-free).
|
|
79
|
+
*/
|
|
80
|
+
export function truncateTail(
|
|
81
|
+
text: string,
|
|
82
|
+
maxLines: number,
|
|
83
|
+
marker = "… (truncated)",
|
|
84
|
+
): string {
|
|
85
|
+
const lines = text.split("\n");
|
|
86
|
+
if (lines.length <= maxLines) return text;
|
|
87
|
+
return `${marker}\n${lines.slice(-maxLines).join("\n")}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Cut a single plain line to `width` columns, trailing ellipsis on the cut. */
|
|
91
|
+
export function truncateLineWidth(line: string, width: number | undefined): string {
|
|
92
|
+
if (!width || width <= 0 || line.length <= width) return line;
|
|
93
|
+
return line.slice(0, Math.max(0, width - 1)) + "…";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Line planning
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/** Line styles the drill-in view maps to theme colors. */
|
|
101
|
+
export type TranscriptLineStyle =
|
|
102
|
+
| "user"
|
|
103
|
+
| "thinking"
|
|
104
|
+
| "text"
|
|
105
|
+
| "toolCall"
|
|
106
|
+
| "toolResult"
|
|
107
|
+
| "note"
|
|
108
|
+
| "streaming";
|
|
109
|
+
|
|
110
|
+
/** One planned transcript line: plain text + style for coloring. */
|
|
111
|
+
export interface TranscriptRenderLine {
|
|
112
|
+
text: string;
|
|
113
|
+
style: TranscriptLineStyle;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface TranscriptRenderOptions {
|
|
117
|
+
/** Tail size in messages (default 6). */
|
|
118
|
+
maxMessages?: number;
|
|
119
|
+
/** Max thinking lines per block (default 30). */
|
|
120
|
+
maxThinkingLines?: number;
|
|
121
|
+
/** Max text lines per block (default 20). */
|
|
122
|
+
maxTextLines?: number;
|
|
123
|
+
/** Max user-echo lines (default 6). */
|
|
124
|
+
maxUserLines?: number;
|
|
125
|
+
/** Max tool-call args preview chars (default 80). */
|
|
126
|
+
toolCallArgsPreview?: number;
|
|
127
|
+
/** Total line budget; oldest lines are dropped with an omission note. */
|
|
128
|
+
maxLines?: number;
|
|
129
|
+
/** Per-line width cap; longer lines are cut with a trailing ellipsis. */
|
|
130
|
+
maxWidth?: number;
|
|
131
|
+
/** True while the agent is streaming — appends a spinner line. */
|
|
132
|
+
streaming?: boolean;
|
|
133
|
+
/** Live spinner frame to use for the streaming line. */
|
|
134
|
+
spinner?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const DEFAULT_OPTIONS = {
|
|
138
|
+
maxMessages: 6,
|
|
139
|
+
maxThinkingLines: 30,
|
|
140
|
+
maxTextLines: 20,
|
|
141
|
+
maxUserLines: 6,
|
|
142
|
+
toolCallArgsPreview: 80,
|
|
143
|
+
} as const;
|
|
144
|
+
|
|
145
|
+
/** Continuation indent under the block icon (aligned after "💬 " etc.). */
|
|
146
|
+
const CONTINUATION_INDENT = " ";
|
|
147
|
+
|
|
148
|
+
/** Extract the displayable user text: raw string or the first text block. */
|
|
149
|
+
function userText(content: TranscriptMessage["content"]): string {
|
|
150
|
+
if (typeof content === "string") return content;
|
|
151
|
+
if (Array.isArray(content)) {
|
|
152
|
+
const block = content.find(
|
|
153
|
+
(b): b is TranscriptTextBlock =>
|
|
154
|
+
b.type === "text" && typeof b.text === "string",
|
|
155
|
+
);
|
|
156
|
+
return block?.text ?? "";
|
|
157
|
+
}
|
|
158
|
+
return "";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Plan one block of plain text: icon on the first line, indented continuation. */
|
|
162
|
+
function blockLines(
|
|
163
|
+
text: string,
|
|
164
|
+
icon: string,
|
|
165
|
+
maxLines: number,
|
|
166
|
+
marker: string,
|
|
167
|
+
style: TranscriptLineStyle,
|
|
168
|
+
maxWidth: number | undefined,
|
|
169
|
+
): TranscriptRenderLine[] {
|
|
170
|
+
const tail = truncateTail(text, maxLines, marker);
|
|
171
|
+
return tail.split("\n").map((line, i) => ({
|
|
172
|
+
text: truncateLineWidth(
|
|
173
|
+
(i === 0 ? `${icon} ` : CONTINUATION_INDENT) + line,
|
|
174
|
+
maxWidth,
|
|
175
|
+
),
|
|
176
|
+
style,
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Plan one tool-call line: 🛠 → name: <args preview>. */
|
|
181
|
+
function toolCallLines(
|
|
182
|
+
block: TranscriptToolCallBlock,
|
|
183
|
+
previewLen: number,
|
|
184
|
+
maxWidth: number | undefined,
|
|
185
|
+
): TranscriptRenderLine[] {
|
|
186
|
+
const name = block.name ?? "tool";
|
|
187
|
+
let argsPreview = "";
|
|
188
|
+
try {
|
|
189
|
+
argsPreview = JSON.stringify(block.arguments ?? {});
|
|
190
|
+
} catch {
|
|
191
|
+
argsPreview = "";
|
|
192
|
+
}
|
|
193
|
+
if (argsPreview.length > previewLen) {
|
|
194
|
+
argsPreview = argsPreview.slice(0, previewLen) + "…";
|
|
195
|
+
}
|
|
196
|
+
const label = argsPreview ? `${name}: ${argsPreview}` : name;
|
|
197
|
+
return [
|
|
198
|
+
{ text: truncateLineWidth(`🛠 → ${label}`, maxWidth), style: "toolCall" },
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Plan all lines for a single message, in chronological order. */
|
|
203
|
+
function messageLines(
|
|
204
|
+
msg: TranscriptMessage,
|
|
205
|
+
opts: {
|
|
206
|
+
maxThinkingLines: number;
|
|
207
|
+
maxTextLines: number;
|
|
208
|
+
maxUserLines: number;
|
|
209
|
+
toolCallArgsPreview: number;
|
|
210
|
+
maxWidth?: number;
|
|
211
|
+
},
|
|
212
|
+
): TranscriptRenderLine[] {
|
|
213
|
+
if (msg.role === "user") {
|
|
214
|
+
const text = userText(msg.content);
|
|
215
|
+
if (!text) return [];
|
|
216
|
+
return blockLines(
|
|
217
|
+
text,
|
|
218
|
+
"💬",
|
|
219
|
+
opts.maxUserLines,
|
|
220
|
+
"… (message truncated)",
|
|
221
|
+
"user",
|
|
222
|
+
opts.maxWidth,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (msg.role === "assistant") {
|
|
226
|
+
const blocks = Array.isArray(msg.content) ? msg.content : [];
|
|
227
|
+
const out: TranscriptRenderLine[] = [];
|
|
228
|
+
for (const block of blocks) {
|
|
229
|
+
if (!block || typeof block !== "object") continue;
|
|
230
|
+
if (block.type === "thinking" && typeof block.thinking === "string" && block.thinking) {
|
|
231
|
+
out.push(
|
|
232
|
+
...blockLines(
|
|
233
|
+
block.thinking,
|
|
234
|
+
"🧠",
|
|
235
|
+
opts.maxThinkingLines,
|
|
236
|
+
"… (thinking truncated)",
|
|
237
|
+
"thinking",
|
|
238
|
+
opts.maxWidth,
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
} else if (block.type === "text" && typeof block.text === "string" && block.text) {
|
|
242
|
+
out.push(
|
|
243
|
+
...blockLines(
|
|
244
|
+
block.text,
|
|
245
|
+
"📝",
|
|
246
|
+
opts.maxTextLines,
|
|
247
|
+
"… (text truncated)",
|
|
248
|
+
"text",
|
|
249
|
+
opts.maxWidth,
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
} else if (block.type === "toolCall") {
|
|
253
|
+
out.push(...toolCallLines(block, opts.toolCallArgsPreview, opts.maxWidth));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
if (msg.role === "toolResult") {
|
|
259
|
+
if (!msg.toolName) return [];
|
|
260
|
+
return [
|
|
261
|
+
{
|
|
262
|
+
text: truncateLineWidth(`← ${msg.toolName} done`, opts.maxWidth),
|
|
263
|
+
style: "toolResult",
|
|
264
|
+
},
|
|
265
|
+
];
|
|
266
|
+
}
|
|
267
|
+
return [];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Budget fitting
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Keep the NEWEST `maxLines` lines. When content is cut, a dim omission note
|
|
276
|
+
* is prepended so the oldest visible line does not read as the transcript
|
|
277
|
+
* start.
|
|
278
|
+
*/
|
|
279
|
+
export function fitLinesToBudget(
|
|
280
|
+
lines: readonly TranscriptRenderLine[],
|
|
281
|
+
maxLines: number,
|
|
282
|
+
): TranscriptRenderLine[] {
|
|
283
|
+
if (maxLines <= 0) return [];
|
|
284
|
+
if (lines.length <= maxLines) return [...lines];
|
|
285
|
+
const kept = lines.slice(lines.length - (maxLines - 1));
|
|
286
|
+
return [{ text: "… (earlier messages omitted)", style: "note" }, ...kept];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Full pipeline
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Plan the drill-in transcript from a message array: tail selection, per-block
|
|
295
|
+
* line caps, budget fit (oldest dropped first), per-line width truncation.
|
|
296
|
+
* The output lines are plain text — the caller maps `style` to theme colors.
|
|
297
|
+
*/
|
|
298
|
+
export function renderTranscriptMessages(
|
|
299
|
+
messages: readonly unknown[],
|
|
300
|
+
options: TranscriptRenderOptions = {},
|
|
301
|
+
): TranscriptRenderLine[] {
|
|
302
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
303
|
+
const tail = buildTranscriptTail(messages, opts.maxMessages);
|
|
304
|
+
|
|
305
|
+
const lines: TranscriptRenderLine[] = [];
|
|
306
|
+
for (const msg of tail) {
|
|
307
|
+
lines.push(
|
|
308
|
+
...messageLines(msg, {
|
|
309
|
+
maxThinkingLines: opts.maxThinkingLines,
|
|
310
|
+
maxTextLines: opts.maxTextLines,
|
|
311
|
+
maxUserLines: opts.maxUserLines,
|
|
312
|
+
toolCallArgsPreview: opts.toolCallArgsPreview,
|
|
313
|
+
maxWidth: opts.maxWidth,
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (opts.streaming && opts.spinner) {
|
|
318
|
+
lines.push({
|
|
319
|
+
text: truncateLineWidth(`${opts.spinner} streaming…`, opts.maxWidth),
|
|
320
|
+
style: "streaming",
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (opts.maxLines !== undefined && opts.maxLines > 0 && lines.length > opts.maxLines) {
|
|
324
|
+
return fitLinesToBudget(lines, opts.maxLines);
|
|
325
|
+
}
|
|
326
|
+
return lines;
|
|
327
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { mkdirSync, appendFileSync, readFileSync, existsSync } from 'fs';
|
|
3
|
+
import type { TranscriptEntry, TranscriptEntryType } from './types';
|
|
4
|
+
import { assertSafeAgentId } from './sanitize';
|
|
5
|
+
|
|
6
|
+
// Output directory — overridable for tests (issue #52): unit tests must NOT
|
|
7
|
+
// write into the real repo .pi/ dir; the setter is test-only.
|
|
8
|
+
let OUTPUT_DIR = '.pi/output';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* TEST-ONLY: point the transcript output at an alternate directory.
|
|
12
|
+
* The tests call this in beforeEach to isolate from the real repo .pi/.
|
|
13
|
+
*/
|
|
14
|
+
export function __setOutputDir(dir: string): void {
|
|
15
|
+
OUTPUT_DIR = dir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Ensure output directory exists
|
|
20
|
+
*/
|
|
21
|
+
function ensureOutputDir(): void {
|
|
22
|
+
// F6 (issue #29): transcripts contain the full subagent conversation — the
|
|
23
|
+
// dir must be owner-only (0o700) so other local users cannot list the files.
|
|
24
|
+
mkdirSync(OUTPUT_DIR, { recursive: true, mode: 0o700 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get transcript file path for an agent
|
|
29
|
+
*/
|
|
30
|
+
export function getTranscriptPath(agentId: string): string {
|
|
31
|
+
// F24: single chokepoint for every transcript file path. All internal callers
|
|
32
|
+
// (startTranscript/appendEntry/getTranscript/completeTranscript) pass
|
|
33
|
+
// generateUUID() ids, but get_agent_result feeds LLM-controlled ids in via
|
|
34
|
+
// getTranscript. path.join does not sanitize "../" or absolute segments, so
|
|
35
|
+
// throw on anything that is not a UUID before it reaches the filesystem.
|
|
36
|
+
assertSafeAgentId(agentId);
|
|
37
|
+
return join(OUTPUT_DIR, `agent-${agentId}.jsonl`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Start a new transcript for an agent
|
|
42
|
+
* Creates the file and writes a system entry
|
|
43
|
+
*/
|
|
44
|
+
export function startTranscript(agentId: string, task: string): string {
|
|
45
|
+
ensureOutputDir();
|
|
46
|
+
const path = getTranscriptPath(agentId);
|
|
47
|
+
|
|
48
|
+
const entry: TranscriptEntry = {
|
|
49
|
+
type: 'system',
|
|
50
|
+
timestamp: Date.now(),
|
|
51
|
+
content: `Transcript started for agent ${agentId}`,
|
|
52
|
+
metadata: { task },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// F6 (issue #29): transcripts hold the full conversation — append
|
|
56
|
+
// owner-only (0o600) on file CREATE (mode is not retroactive).
|
|
57
|
+
appendFileSync(path, JSON.stringify(entry) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
58
|
+
return path;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Append an entry to an agent's transcript
|
|
63
|
+
*/
|
|
64
|
+
export function appendEntry(
|
|
65
|
+
agentId: string,
|
|
66
|
+
type: TranscriptEntryType,
|
|
67
|
+
content: string,
|
|
68
|
+
metadata?: Record<string, unknown>
|
|
69
|
+
): void {
|
|
70
|
+
const path = getTranscriptPath(agentId);
|
|
71
|
+
if (!existsSync(path)) {
|
|
72
|
+
throw new Error(`Transcript not found for agent ${agentId}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const entry: TranscriptEntry = {
|
|
76
|
+
type,
|
|
77
|
+
timestamp: Date.now(),
|
|
78
|
+
content,
|
|
79
|
+
metadata,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// F6 (issue #29): transcripts hold the full conversation — append
|
|
83
|
+
// owner-only (0o600) on file CREATE (mode is not retroactive).
|
|
84
|
+
appendFileSync(path, JSON.stringify(entry) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read a transcript for an agent
|
|
89
|
+
*/
|
|
90
|
+
export function getTranscript(agentId: string): TranscriptEntry[] {
|
|
91
|
+
const path = getTranscriptPath(agentId);
|
|
92
|
+
if (!existsSync(path)) return [];
|
|
93
|
+
|
|
94
|
+
const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean);
|
|
95
|
+
return lines.map(line => {
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(line) as TranscriptEntry;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}).filter((entry): entry is TranscriptEntry => entry !== null);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function completeTranscript(agentId: string, status: string): void {
|
|
105
|
+
// Issue #53: completeTranscript is settle-path bookkeeping (called from
|
|
106
|
+
// promise callbacks in session-manager/index) and must NEVER throw. The
|
|
107
|
+
// transcript file can legitimately be missing — a spawn that died before
|
|
108
|
+
// startTranscript ran, or a file deleted during cleanup. A throw here
|
|
109
|
+
// escapes the promise callback and becomes an uncaughtException that kills
|
|
110
|
+
// the whole pi process. appendEntry KEEPS its throwing behavior for
|
|
111
|
+
// startTranscript/appendEntry callers (F24: the file must exist before
|
|
112
|
+
// appending — that invariant is load-bearing for caller validation).
|
|
113
|
+
const path = getTranscriptPath(agentId);
|
|
114
|
+
if (!existsSync(path)) {
|
|
115
|
+
console.warn(`[brl-subagent] completeTranscript: transcript missing for agent ${agentId} — skipping completion entry`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
appendEntry(agentId, 'system', `Transcript completed: ${status}`);
|
|
119
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brl-subagent — Pure TUI formatting helpers
|
|
3
|
+
*
|
|
4
|
+
* Row-render logic shared by the live monitor and dashboard "Active
|
|
5
|
+
* Subagents" panel. Kept free of pi-tui imports so it is unit-testable
|
|
6
|
+
* without a TUI harness.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { formatTokens } from "./types";
|
|
10
|
+
|
|
11
|
+
/** Braille spinner frames, indexed by Math.floor(now / 150) % 10. */
|
|
12
|
+
export const LIVE_SPINNER_FRAMES: readonly string[] = [
|
|
13
|
+
"\u280B",
|
|
14
|
+
"\u2819",
|
|
15
|
+
"\u2839",
|
|
16
|
+
"\u2838",
|
|
17
|
+
"\u283C",
|
|
18
|
+
"\u2834",
|
|
19
|
+
"\u2826",
|
|
20
|
+
"\u2827",
|
|
21
|
+
"\u2807",
|
|
22
|
+
"\u280F",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Format an elapsed duration (ms) as a compact string, e.g. "5s", "1m 5s".
|
|
27
|
+
* Unified format: minutes and seconds are separated by a space.
|
|
28
|
+
*/
|
|
29
|
+
export function formatElapsed(ms: number): string {
|
|
30
|
+
const elapsed = Math.round(ms / 1000);
|
|
31
|
+
return elapsed < 60 ? `${elapsed}s` : `${Math.floor(elapsed / 60)}m ${elapsed % 60}s`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Live row display name: explicit label wins, else task truncated to 40 chars. */
|
|
35
|
+
export function liveRowName(label: string | undefined, task: string): string {
|
|
36
|
+
return label || task.slice(0, 40);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Spinner frame at a given timestamp (ms since epoch). */
|
|
40
|
+
export function liveSpinner(now: number): string {
|
|
41
|
+
return LIVE_SPINNER_FRAMES[Math.floor(now / 150) % 10];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Dim section of a live row: short id + token counts + elapsed. */
|
|
45
|
+
export function formatLiveRowDim(
|
|
46
|
+
id: string,
|
|
47
|
+
usage: { input: number; output: number },
|
|
48
|
+
elapsedStr: string,
|
|
49
|
+
): string {
|
|
50
|
+
return ` [${id.slice(0, 8)}] \u2191${formatTokens(usage.input)} \u2193${formatTokens(usage.output)} ${elapsedStr}`;
|
|
51
|
+
}
|