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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -0
  3. package/package.json +49 -0
  4. package/presets/code-reviewer.md +37 -0
  5. package/presets/data-analyst.md +33 -0
  6. package/presets/debugger.md +36 -0
  7. package/presets/dev-agent.md +30 -0
  8. package/presets/rapid-prototyper.md +25 -0
  9. package/presets/refactorer.md +34 -0
  10. package/presets/security-auditor.md +39 -0
  11. package/presets/tech-writer.md +33 -0
  12. package/presets/test-engineer.md +34 -0
  13. package/src/concurrency.ts +186 -0
  14. package/src/diff.ts +102 -0
  15. package/src/event-bus.ts +86 -0
  16. package/src/git.ts +187 -0
  17. package/src/history.ts +177 -0
  18. package/src/index.ts +3824 -0
  19. package/src/logging.ts +135 -0
  20. package/src/messaging.ts +164 -0
  21. package/src/metrics.ts +221 -0
  22. package/src/model-availability.ts +73 -0
  23. package/src/notify-completion.ts +207 -0
  24. package/src/params.ts +225 -0
  25. package/src/preflight.ts +150 -0
  26. package/src/presets.ts +425 -0
  27. package/src/prompt.ts +169 -0
  28. package/src/reports.ts +325 -0
  29. package/src/router.ts +133 -0
  30. package/src/runner.ts +877 -0
  31. package/src/sanitize.ts +365 -0
  32. package/src/scheduler.ts +187 -0
  33. package/src/session-manager.ts +1065 -0
  34. package/src/state.ts +590 -0
  35. package/src/templates.ts +469 -0
  36. package/src/transcript-tail.ts +327 -0
  37. package/src/transcript.ts +119 -0
  38. package/src/tui-format.ts +51 -0
  39. package/src/tui.ts +3103 -0
  40. package/src/types.ts +803 -0
  41. package/src/unit-run.ts +269 -0
  42. package/src/update.ts +109 -0
  43. package/src/validate.ts +302 -0
  44. package/templates/analyze-data.md +9 -0
  45. package/templates/code-review.md +10 -0
  46. package/templates/debug-issue.md +9 -0
  47. package/templates/implement-feature.md +9 -0
  48. package/templates/prototype.md +9 -0
  49. package/templates/refactor.md +9 -0
  50. package/templates/security-audit.md +10 -0
  51. package/templates/write-docs.md +9 -0
  52. 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
+ }