pi-famulus 0.1.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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/bin/pi-famulus.js +29 -0
  4. package/package.json +49 -0
  5. package/src/agents/builtins.ts +41 -0
  6. package/src/agents/definition.ts +221 -0
  7. package/src/agents/loader.ts +119 -0
  8. package/src/bash-override.ts +372 -0
  9. package/src/behavior-guidelines.ts +42 -0
  10. package/src/clock.ts +169 -0
  11. package/src/comms/comms.ts +199 -0
  12. package/src/comms/mailbox.ts +156 -0
  13. package/src/comms/registry-host.ts +73 -0
  14. package/src/comms/reply-command.ts +52 -0
  15. package/src/comms/routing.ts +23 -0
  16. package/src/comms/tools.ts +339 -0
  17. package/src/comms/types.ts +68 -0
  18. package/src/config.ts +266 -0
  19. package/src/events.ts +61 -0
  20. package/src/exit-notify-gate.ts +69 -0
  21. package/src/exit-watchdog.ts +60 -0
  22. package/src/file-tail.ts +48 -0
  23. package/src/format.ts +276 -0
  24. package/src/index.ts +700 -0
  25. package/src/manager-client.ts +839 -0
  26. package/src/monitor-batching.ts +191 -0
  27. package/src/monitor.ts +517 -0
  28. package/src/native-manager.d.ts +9 -0
  29. package/src/native-manager.js +39 -0
  30. package/src/notify.ts +231 -0
  31. package/src/shell-exec.ts +145 -0
  32. package/src/subagent/agent-records.ts +168 -0
  33. package/src/subagent/child-bash.ts +183 -0
  34. package/src/subagent/conversation.ts +76 -0
  35. package/src/subagent/fleet-widget.ts +176 -0
  36. package/src/subagent/model-spec.ts +108 -0
  37. package/src/subagent/pi-runtime.ts +295 -0
  38. package/src/subagent/pool.ts +190 -0
  39. package/src/subagent/registry.ts +575 -0
  40. package/src/subagent/runner.ts +709 -0
  41. package/src/subagent/tool.ts +741 -0
  42. package/src/subagent/transcript.ts +71 -0
  43. package/src/subagent/types.ts +125 -0
  44. package/src/task-exit-notify.ts +21 -0
  45. package/src/task-tools.ts +330 -0
  46. package/src/tui/message-renderers.ts +183 -0
  47. package/src/tui/pi-tui-load.ts +186 -0
  48. package/src/tui/scroll-detail-view.ts +190 -0
  49. package/src/tui/task-output-paths.ts +46 -0
  50. package/src/tui/tasks-command.ts +515 -0
  51. package/src/tui/tool-component.ts +29 -0
  52. package/src/wake.ts +219 -0
  53. package/src/work-index.ts +182 -0
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Custom message renderers for Famulus notifications (Claude-style compact pills).
3
+ *
4
+ * Box is `(paddingX, paddingY, bgFn)` — the second argument is vertical padding,
5
+ * not a child gap. `outputPad` is the horizontal pad (0 or 1), matching pi's
6
+ * custom-message boxes which use `new Box(1, 1, bg)`.
7
+ */
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { FAMULUS_WAKE_CUSTOM_TYPE, FAMULUS_WAKE_LEAD_IN, type FamulusWake, type TaskWake } from "../wake";
10
+ import { fitLines, loadPiTui } from "./pi-tui-load";
11
+ import { statusGlyph } from "./tool-component";
12
+
13
+ type Theme = {
14
+ fg(color: string, text: string): string;
15
+ bg(color: string, text: string): string;
16
+ };
17
+
18
+ type PillComponent = {
19
+ render(width: number): string[];
20
+ invalidate(): void;
21
+ };
22
+
23
+ const STATUS_ORDER = [
24
+ "completed",
25
+ "failed",
26
+ "interrupted",
27
+ "killed",
28
+ "orphaned",
29
+ "pending",
30
+ "running",
31
+ "partial",
32
+ "timeout",
33
+ "stopped",
34
+ ];
35
+
36
+ function countStatuses(statuses: string[]): string {
37
+ const counts = new Map<string, number>();
38
+ for (const status of statuses) counts.set(status, (counts.get(status) ?? 0) + 1);
39
+ const parts = STATUS_ORDER.filter((status) => counts.has(status)).map(
40
+ (status) => `${counts.get(status)} ${status}`,
41
+ );
42
+ return parts.join(" · ");
43
+ }
44
+
45
+ function badExit(status: string | undefined, exitCode?: number | null): boolean {
46
+ if (exitCode !== undefined && exitCode !== null && exitCode !== 0) return true;
47
+ return status === "failed" || status === "killed" || status === "orphaned" || status === "interrupted";
48
+ }
49
+
50
+ function formatDuration(ms: number): string {
51
+ if (ms < 1000) return `${ms}ms`;
52
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
53
+ return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`;
54
+ }
55
+
56
+ function taskExit(task: TaskWake): string {
57
+ if (task.exitCode !== null) return `exit ${task.exitCode}`;
58
+ return task.signal ?? task.status;
59
+ }
60
+
61
+ function taskHead(details: Extract<FamulusWake, { kind: "task" }>): string {
62
+ const taskInfo = details.tasks.length === 1
63
+ ? `${formatDuration(details.tasks[0].durationMs)} · ${taskExit(details.tasks[0])}`
64
+ : `${details.tasks.length} tasks · ${countStatuses(details.tasks.map((task) => task.status))}`;
65
+ const still = details.stillRunning.length > 0 ? ` · ${details.stillRunning.length} still running` : "";
66
+ const summary = details.tasks.length === 1 ? details.tasks[0].summary : "Background work";
67
+ return `${summary} · ${taskInfo}${still}`;
68
+ }
69
+
70
+ function collapsedText(details: FamulusWake, theme: Theme): string {
71
+ switch (details.kind) {
72
+ case "task": {
73
+ const bad = details.tasks.some((task) => badExit(task.status, task.exitCode));
74
+ const { color, glyph } = statusGlyph(bad ? "failed" : "completed");
75
+ return `${theme.fg(color, glyph)} ${theme.fg("muted", "task")} ${taskHead(details)}`;
76
+ }
77
+ case "monitor": {
78
+ const preview = details.event.split("\n").find((line) => line.trim().length > 0)?.trim() ?? "(event)";
79
+ const { color, glyph } = details.status
80
+ ? statusGlyph(details.status)
81
+ : { color: "accent", glyph: "›" };
82
+ const statusBit = details.status ? ` ${theme.fg("dim", `· ${details.status}`)}` : "";
83
+ const countBit = details.eventCount && details.eventCount > 1
84
+ ? ` ${theme.fg("dim", `· ${details.eventCount} events`)}`
85
+ : "";
86
+ const droppedBit = details.droppedLines
87
+ ? ` ${theme.fg("warning", `· ${details.droppedLines} lines dropped`)}`
88
+ : "";
89
+ return `${theme.fg(color, glyph)} ${theme.fg("muted", "monitor")} ${theme.fg("accent", `"${details.description}"`)}${statusBit}${countBit}${droppedBit}\n${theme.fg("dim", preview)}`;
90
+ }
91
+ case "subagent-handover": {
92
+ const { color, glyph } = statusGlyph(details.status);
93
+ const snippet = details.result.replace(/\s+/g, " ").trim().slice(0, 72);
94
+ return `${theme.fg(color, glyph)} ${theme.fg("muted", "handover")} ${details.name} ${details.status}${snippet ? ` · ${snippet}` : ""}`;
95
+ }
96
+ case "subagent-done": {
97
+ const bad = details.children.some((child) => badExit(child.status));
98
+ const { color, glyph } = statusGlyph(bad ? "failed" : details.status);
99
+ return `${theme.fg(color, glyph)} ${theme.fg("muted", "subagent")} ${countStatuses(details.children.map((child) => child.status))}`;
100
+ }
101
+ case "supervisor-request":
102
+ return [
103
+ `${theme.fg("warning", "?")} ${theme.fg("muted", `decision for ${details.name}`)} ${details.message.slice(0, 100)}`,
104
+ theme.fg("dim", `/reply ${details.from} <decision>`),
105
+ ].join("\n");
106
+ case "supervisor-update":
107
+ return `${theme.fg("muted", "↑")} ${theme.fg("muted", "supervisor update")} ${details.message.slice(0, 100)}`;
108
+ }
109
+ }
110
+
111
+ export function expandedWakeText(details: FamulusWake | undefined, content: string): string {
112
+ if (!details) {
113
+ return content
114
+ .replace(FAMULUS_WAKE_LEAD_IN, "")
115
+ .replace(/<[^>]+>/g, " ")
116
+ .replace(/&lt;/g, "<")
117
+ .replace(/&gt;/g, ">")
118
+ .replace(/&amp;/g, "&")
119
+ .trim();
120
+ }
121
+ switch (details.kind) {
122
+ case "task":
123
+ return [
124
+ `Tasks (${details.tasks.length})`,
125
+ ...details.tasks.map((task) => [
126
+ `${task.id} · ${task.taskKind} · ${task.status} · ${formatDuration(task.durationMs)} · ${taskExit(task)}`,
127
+ `$ ${task.command}`,
128
+ `Output: ${task.outputPath}`,
129
+ ...(task.preview ? [`Preview: ${task.preview}`] : []),
130
+ ].join("\n")),
131
+ ...(details.stillRunning.length ? [`Still running (${details.stillRunning.length}): ${details.stillRunning.map((item) => `${item.id} ${item.title}`).join(", ")}`] : []),
132
+ ].join("\n\n");
133
+ case "monitor":
134
+ return [`Monitor: ${details.description}`, `Task: ${details.id}`, ...(details.status ? [`Status: ${details.status}`] : []), `Event: ${details.event}`, ...(details.eventCount ? [`Events: ${details.eventCount}`] : []), ...(details.droppedLines ? [`Dropped lines: ${details.droppedLines}`] : [])].join("\n");
135
+ case "subagent-handover":
136
+ return [`Subagent handover: ${details.name} (${details.status})`, `Run: ${details.runId}`, `Child: ${details.childId}`, `Task prompt: ${details.prompt}`, `Result: ${details.result}`, ...(details.error ? [`Error: ${details.error}`] : []), ...(details.stillRunning.length ? [`Still running: ${details.stillRunning.map((item) => `${item.id} ${item.title}`).join(", ")}`] : [])].join("\n\n");
137
+ case "subagent-done":
138
+ return [`Subagent run: ${details.runId} (${details.status})`, `Duration: ${formatDuration(details.durationMs)}`, ...details.children.map((child) => [`${child.name} (${child.childId}) · ${child.status}`, `Task prompt: ${child.prompt}`, `Result: ${child.result}`, ...(child.error ? [`Error: ${child.error}`] : [])].join("\n"))].join("\n\n");
139
+ case "supervisor-request":
140
+ return [`Decision requested by ${details.name} (${details.from})`, `Request: ${details.message}`, `Reply with /reply ${details.from} <decision>`].join("\n");
141
+ case "supervisor-update":
142
+ return [`Update from ${details.name} (${details.from})`, details.message].join("\n");
143
+ }
144
+ }
145
+
146
+ function wakeDetails(message: { details?: unknown }): FamulusWake | undefined {
147
+ const details = message.details as FamulusWake | undefined;
148
+ if (!details || typeof details !== "object" || !("kind" in details)) return undefined;
149
+ return details;
150
+ }
151
+
152
+ function makeComponent(pad: number, theme: Theme, text: string): PillComponent {
153
+ const tui = loadPiTui();
154
+ if (tui) {
155
+ // paddingX = outputPad, paddingY = 1 (blank line above/below), then bg.
156
+ const box = new tui.Box(pad, 1, (s) => theme.bg("customMessageBg", s));
157
+ box.addChild(new tui.Text(text, 0, 0));
158
+ const rendered = box as unknown as { render(width: number): string[]; invalidate(): void };
159
+ if (typeof rendered.invalidate !== "function") {
160
+ rendered.invalidate = () => {};
161
+ }
162
+ return rendered;
163
+ }
164
+ return {
165
+ render(width: number) {
166
+ const inner = Math.max(1, width - pad * 2);
167
+ const padStr = " ".repeat(Math.max(0, pad));
168
+ return fitLines(text, inner).map((line) => padStr + line);
169
+ },
170
+ invalidate() {},
171
+ };
172
+ }
173
+
174
+ export function registerFamulusMessageRenderers(pi: ExtensionAPI): void {
175
+ pi.registerMessageRenderer(FAMULUS_WAKE_CUSTOM_TYPE, (message, { expanded, outputPad }, theme) => {
176
+ const details = wakeDetails(message);
177
+ const content = typeof message.content === "string" ? message.content : "";
178
+ const head = details ? collapsedText(details, theme) : theme.fg("muted", "wake");
179
+ const body = expanded && content ? `\n${theme.fg("dim", expandedWakeText(details, content))}` : "";
180
+ return makeComponent(outputPad, theme, head + body) as never;
181
+ });
182
+ }
183
+
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Resolve `@earendil-works/pi-tui` from the host pi install.
3
+ * Typed structurally so this package does not depend on pi-tui's declarations.
4
+ */
5
+ import * as bundledPiTui from "@earendil-works/pi-tui";
6
+
7
+ export interface PiTuiText {
8
+ setText(text: string): void;
9
+ invalidate(): void;
10
+ }
11
+
12
+ export interface PiTuiScroll {
13
+ viewportHeight: number;
14
+ scrollBy(lines: number): number;
15
+ scrollToEnd(): void;
16
+ invalidate(): void;
17
+ }
18
+
19
+ export interface PiTuiComponent {
20
+ render(width: number): string[];
21
+ invalidate(): void;
22
+ }
23
+
24
+ export interface PiTuiModule {
25
+ Text: new (text?: string, paddingX?: number, paddingY?: number) => PiTuiText & PiTuiComponent;
26
+ ScrollView: new (
27
+ component: PiTuiComponent,
28
+ options?: {
29
+ primary?: boolean;
30
+ scrollbar?: "hidden" | "auto" | "always";
31
+ follow?: "none" | "end";
32
+ overscroll?: "chain" | "contain";
33
+ },
34
+ ) => PiTuiScroll & PiTuiComponent;
35
+ VStack: new (
36
+ children?: Array<
37
+ | PiTuiComponent
38
+ | {
39
+ component: PiTuiComponent;
40
+ basis?: number | "auto";
41
+ grow?: number;
42
+ shrink?: number;
43
+ minSize?: number;
44
+ }
45
+ >,
46
+ options?: { gap?: number },
47
+ ) => PiTuiComponent;
48
+ matchesKey(data: string, key: string): boolean;
49
+ Box: new (
50
+ paddingX?: number,
51
+ paddingY?: number,
52
+ bgFn?: (text: string) => string,
53
+ ) => { addChild(component: unknown): void };
54
+ }
55
+
56
+ export interface PiTuiWidth {
57
+ visibleWidth(text: string): number;
58
+ truncateToWidth(text: string, maxWidth: number, ellipsis?: string): string;
59
+ wrapTextWithAnsi(text: string, width: number): string[];
60
+ }
61
+
62
+ let fallbackWarningLogged = false;
63
+
64
+ function warnFallbackOnce(): void {
65
+ if (fallbackWarningLogged) return;
66
+ fallbackWarningLogged = true;
67
+ console.warn(
68
+ "pi-famulus: pi-tui is unavailable; using the reduced text fallback. " +
69
+ "Load this extension through pi to enable interactive task views and full-width rendering.",
70
+ );
71
+ }
72
+
73
+ export function loadPiTui(): PiTuiModule | null {
74
+ if (forcedTui !== undefined) {
75
+ if (forcedTui === null) warnFallbackOnce();
76
+ return forcedTui;
77
+ }
78
+ // pi's extension loader aliases this static specifier to its bundled TUI in
79
+ // both jiti and compiled-binary modes. Resolving it relative to argv[1] is
80
+ // incorrect when pi is launched through a package-manager shim.
81
+ return bundledPiTui as unknown as PiTuiModule;
82
+ }
83
+
84
+ let widthFns: PiTuiWidth | null | undefined;
85
+ let forcedTui: PiTuiModule | null | undefined;
86
+
87
+ /** Test hook. `null` forces the no-pi-tui fallback; `undefined` restores normal resolution. */
88
+ export function setPiTuiForTests(mod: PiTuiModule | null | undefined): void {
89
+ forcedTui = mod;
90
+ widthFns = undefined;
91
+ }
92
+
93
+ function widthApi(): PiTuiWidth | null {
94
+ if (widthFns !== undefined) return widthFns;
95
+ const tui = (forcedTui !== undefined ? forcedTui : loadPiTui()) as (PiTuiModule & Partial<PiTuiWidth>) | null;
96
+ if (tui && typeof tui.visibleWidth === "function" && typeof tui.truncateToWidth === "function" && typeof tui.wrapTextWithAnsi === "function") {
97
+ widthFns = {
98
+ visibleWidth: tui.visibleWidth.bind(tui),
99
+ truncateToWidth: tui.truncateToWidth.bind(tui),
100
+ wrapTextWithAnsi: tui.wrapTextWithAnsi.bind(tui),
101
+ };
102
+ return widthFns;
103
+ }
104
+ if (!tui) warnFallbackOnce();
105
+ widthFns = null;
106
+ return null;
107
+ }
108
+
109
+ /** Visible columns, ANSI stripped. Falls back to a CJK-aware counter if pi-tui is absent. */
110
+ export function visibleWidth(text: string): number {
111
+ const api = widthApi();
112
+ if (api) return api.visibleWidth(text);
113
+ return fallbackVisibleWidth(text);
114
+ }
115
+
116
+ export function truncateToWidth(text: string, maxWidth: number, ellipsis = "…"): string {
117
+ const api = widthApi();
118
+ if (api) return api.truncateToWidth(text, maxWidth, ellipsis);
119
+ return fallbackTruncate(text, maxWidth, ellipsis);
120
+ }
121
+
122
+ export function wrapTextWithAnsi(text: string, width: number): string[] {
123
+ const api = widthApi();
124
+ if (api) return api.wrapTextWithAnsi(text, Math.max(1, width));
125
+ return fallbackWrap(text, Math.max(1, width));
126
+ }
127
+
128
+ /** Wrap, then hard-truncate any line that is still too wide (ANSI-safe). */
129
+ export function fitLines(text: string, width: number): string[] {
130
+ const limit = Math.max(1, width);
131
+ const wrapped = wrapTextWithAnsi(text, limit);
132
+ return wrapped.map((line) => (visibleWidth(line) > limit ? truncateToWidth(line, limit, "…") : line));
133
+ }
134
+
135
+ function stripAnsi(text: string): string {
136
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
137
+ }
138
+
139
+ function fallbackVisibleWidth(text: string): number {
140
+ let n = 0;
141
+ for (const ch of stripAnsi(text)) {
142
+ const cp = ch.codePointAt(0) ?? 0;
143
+ n += cp > 0xff ? 2 : 1;
144
+ }
145
+ return n;
146
+ }
147
+
148
+ function fallbackTruncate(text: string, maxWidth: number, ellipsis: string): string {
149
+ if (maxWidth <= 0) return "";
150
+ if (fallbackVisibleWidth(text) <= maxWidth) return text;
151
+ const ell = fallbackVisibleWidth(ellipsis) >= maxWidth ? "" : ellipsis;
152
+ const budget = maxWidth - fallbackVisibleWidth(ell);
153
+ let out = "";
154
+ let n = 0;
155
+ for (const ch of stripAnsi(text)) {
156
+ const w = (ch.codePointAt(0) ?? 0) > 0xff ? 2 : 1;
157
+ if (n + w > budget) break;
158
+ out += ch;
159
+ n += w;
160
+ }
161
+ return out + ell;
162
+ }
163
+
164
+ function fallbackWrap(text: string, width: number): string[] {
165
+ const out: string[] = [];
166
+ for (const raw of text.split("\n")) {
167
+ if (fallbackVisibleWidth(raw) <= width) {
168
+ out.push(raw);
169
+ continue;
170
+ }
171
+ let cur = "";
172
+ let n = 0;
173
+ for (const ch of raw) {
174
+ const w = (ch.codePointAt(0) ?? 0) > 0xff ? 2 : 1;
175
+ if (n + w > width && cur) {
176
+ out.push(cur);
177
+ cur = "";
178
+ n = 0;
179
+ }
180
+ cur += ch;
181
+ n += w;
182
+ }
183
+ out.push(cur);
184
+ }
185
+ return out.length > 0 ? out : [""];
186
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Full-screen detail panel for /tasks.
3
+ *
4
+ * pi's `ui.custom()` without `overlay` replaces the editor, and ScrollView only
5
+ * clips when the chat layout engine owns it. This panel is a full-screen overlay
6
+ * that slices its own lines, so wheel / page keys scroll and terminal selection
7
+ * can copy the visible text.
8
+ */
9
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
10
+ import { fitLines, loadPiTui, truncateToWidth } from "./pi-tui-load";
11
+ import { realClock, type Clock, type ClockTimer } from "../clock";
12
+
13
+ export type TaskDetailTab = "output" | "stderr" | "info" | "conversation" | "result";
14
+
15
+ const TAB_ORDER: TaskDetailTab[] = ["output", "stderr", "info", "conversation", "result"];
16
+ const TAB_LABEL: Record<TaskDetailTab, string> = {
17
+ output: "output",
18
+ stderr: "stderr",
19
+ info: "info",
20
+ conversation: "conversation",
21
+ result: "result",
22
+ };
23
+
24
+ export interface ScrollDetailOptions {
25
+ title: string;
26
+ /** Named panes; shortcuts 1/2/3 and Tab cycle through the provided panes. */
27
+ tabs?: Partial<Record<TaskDetailTab, () => string>>;
28
+ /** Single-pane content (subagent conversation). */
29
+ content?: () => string;
30
+ /** Poll for live updates while the task is running. */
31
+ pollMs?: number;
32
+ followEnd?: boolean;
33
+ clock?: Clock;
34
+ }
35
+
36
+ /** Wrap on visible width. ANSI is preserved; CJK is not split by JS length. */
37
+ export function wrapLines(text: string, width: number): string[] {
38
+ return fitLines(text, Math.max(1, width));
39
+ }
40
+
41
+ export function visibleDetailTabs(tabs: ScrollDetailOptions["tabs"]): TaskDetailTab[] {
42
+ const order = tabs?.conversation ? ["conversation", "result", "info"] as const : TAB_ORDER;
43
+ return order.filter((key) => Boolean(tabs?.[key]));
44
+ }
45
+
46
+ export function fitTitle(title: string, width: number): string {
47
+ return truncateToWidth(title, Math.max(1, width), "…");
48
+ }
49
+
50
+ export async function showScrollDetail(ui: ExtensionUIContext, options: ScrollDetailOptions): Promise<void> {
51
+ const piTui = loadPiTui();
52
+ if (!piTui) throw new Error("pi-tui is not available");
53
+ const { matchesKey } = piTui;
54
+ await ui.custom<void>(
55
+ (tui, theme, _kb, done) => {
56
+ const tabKeys = () => visibleDetailTabs(options.tabs);
57
+ let tab: TaskDetailTab = tabKeys()[0] ?? "output";
58
+ let follow = options.followEnd !== false;
59
+ let scroll = 0;
60
+ let stuckToEnd = follow;
61
+ let timer: ClockTimer | null = null;
62
+ const clock = options.clock ?? realClock;
63
+ let disposed = false;
64
+ let lastLineCount = 0;
65
+
66
+ function source(): string {
67
+ return options.tabs?.[tab]?.() ?? options.content?.() ?? "";
68
+ }
69
+
70
+ function bodyHeight(width: number): { lines: string[]; height: number } {
71
+ const rows = Math.max(8, tui.terminal?.rows ?? 24);
72
+ const chrome = (options.tabs ? 2 : 1) + 1;
73
+ const height = Math.max(1, rows - chrome - 1);
74
+ return { lines: wrapLines(source(), Math.max(1, width - 2)), height };
75
+ }
76
+
77
+ function refresh(): void {
78
+ if (!disposed) tui.requestRender();
79
+ }
80
+
81
+ if (options.pollMs && options.pollMs > 0) {
82
+ timer = clock.setInterval(refresh, options.pollMs);
83
+ clock.unref?.(timer);
84
+ }
85
+
86
+ return {
87
+ render(width: number) {
88
+ const { lines, height } = bodyHeight(width);
89
+ const maxScroll = Math.max(0, lines.length - height);
90
+ if (lines.length !== lastLineCount) {
91
+ if (stuckToEnd) scroll = maxScroll;
92
+ lastLineCount = lines.length;
93
+ }
94
+ scroll = Math.max(0, Math.min(scroll, maxScroll));
95
+ stuckToEnd = follow && scroll >= maxScroll;
96
+
97
+ const dim = (s: string) => theme.fg("dim", s);
98
+ const accent = (s: string) => theme.fg("accent", s);
99
+ const head = accent(fitTitle(options.title, Math.max(1, width - 2)));
100
+ const keys = tabKeys();
101
+ const tabLine = keys.length > 0
102
+ ? `${keys.map((key, index) => tab === key ? accent(`▸ ${index + 1} ${TAB_LABEL[key]}`) : dim(` ${index + 1} ${TAB_LABEL[key]}`)).join(" ")} ${dim("· Tab cycle · f follow")}`
103
+ : "";
104
+ const visible = lines.slice(scroll, scroll + height);
105
+ while (visible.length < height) visible.push("");
106
+ const place =
107
+ lines.length <= height ? "" : ` ${scroll + 1}–${Math.min(lines.length, scroll + height)}/${lines.length}`;
108
+ const foot = dim(`↑↓ PgUp PgDn scroll · f follow ${follow ? "on" : "off"} · Esc close${place}`);
109
+ return [head, ...(tabLine ? [tabLine] : []), ...visible, foot];
110
+ },
111
+ invalidate() {},
112
+ handleInput(data: string) {
113
+ const width = Math.max(20, tui.terminal?.columns ?? 80);
114
+ const { lines, height } = bodyHeight(width);
115
+ const maxScroll = Math.max(0, lines.length - height);
116
+ const move = (next: number) => {
117
+ scroll = Math.max(0, Math.min(maxScroll, next));
118
+ stuckToEnd = follow && scroll >= maxScroll;
119
+ tui.requestRender();
120
+ };
121
+ if (matchesKey(data, "escape") || data === "q") {
122
+ done();
123
+ return;
124
+ }
125
+ const keys = tabKeys();
126
+ const shortcut = /^[123]$/.test(data) ? Number(data) - 1 : -1;
127
+ if (keys.length > 0 && (matchesKey(data, "tab") || data === "\t")) {
128
+ const current = keys.indexOf(tab);
129
+ tab = keys[(current + 1) % keys.length];
130
+ scroll = 0;
131
+ stuckToEnd = follow;
132
+ tui.requestRender();
133
+ return;
134
+ }
135
+ if (shortcut >= 0 && shortcut < keys.length) {
136
+ tab = keys[shortcut];
137
+ scroll = 0;
138
+ stuckToEnd = follow;
139
+ tui.requestRender();
140
+ return;
141
+ }
142
+ if (data === "f" || data === "F") {
143
+ follow = !follow;
144
+ if (follow) scroll = Math.max(0, bodyHeight(width).lines.length - bodyHeight(width).height);
145
+ stuckToEnd = follow;
146
+ tui.requestRender();
147
+ return;
148
+ }
149
+ if (matchesKey(data, "up")) move(scroll - 1);
150
+ else if (matchesKey(data, "down")) move(scroll + 1);
151
+ else if (matchesKey(data, "pageUp")) move(scroll - Math.max(1, height - 1));
152
+ else if (matchesKey(data, "pageDown")) move(scroll + Math.max(1, height - 1));
153
+ else if (matchesKey(data, "home")) move(0);
154
+ else if (matchesKey(data, "end")) move(maxScroll);
155
+ },
156
+ handleMouse(event: { type: string; wheelDelta?: number }) {
157
+ if (event.type !== "wheel" || !event.wheelDelta) return undefined;
158
+ const width = Math.max(20, tui.terminal?.columns ?? 80);
159
+ const { lines, height } = bodyHeight(width);
160
+ const maxScroll = Math.max(0, lines.length - height);
161
+ scroll = Math.max(0, Math.min(maxScroll, scroll + event.wheelDelta));
162
+ stuckToEnd = follow && scroll >= maxScroll;
163
+ return { handled: true };
164
+ },
165
+ dispose() {
166
+ disposed = true;
167
+ if (timer !== null) clock.clearInterval(timer);
168
+ },
169
+ };
170
+ },
171
+ {
172
+ overlay: true,
173
+ overlayOptions: {
174
+ anchor: "bottom-left",
175
+ width: "100%",
176
+ maxHeight: "100%",
177
+ margin: 0,
178
+ },
179
+ },
180
+ );
181
+ }
182
+
183
+ /** Fallback when pi-tui cannot load (should not happen in interactive mode). */
184
+ export function notifyPlainFallback(
185
+ notify: (message: string, type?: "info" | "warning" | "error") => void,
186
+ title: string,
187
+ text: string,
188
+ ): void {
189
+ notify(`${title}\n${text.slice(0, 4000)}`, "info");
190
+ }
@@ -0,0 +1,46 @@
1
+ import { statSync } from "node:fs";
2
+ import { readFileTail } from "../file-tail";
3
+
4
+ /** Sibling stderr file next to `<id>.output` (matches pi-famulus). */
5
+ export function stderrPathFor(outputPath: string): string {
6
+ if (!outputPath) return "";
7
+ const dot = outputPath.lastIndexOf(".");
8
+ if (dot > 0) return `${outputPath.slice(0, dot)}.stderr`;
9
+ return `${outputPath}.stderr`;
10
+ }
11
+
12
+ const tailCache = new Map<string, { size: number; mtimeMs: number; text: string }>();
13
+
14
+ export function readTaskFileTail(path: string, maxBytes = 512_000): string {
15
+ if (!path) return "(no output path)";
16
+ const tail = readFileTail(path, maxBytes);
17
+ if (!tail) return "(empty)";
18
+ if (tail.size === 0) return "(empty)";
19
+ if (tail.size <= maxBytes) return tail.text;
20
+ return `… (${tail.size} bytes total, showing last ${maxBytes})\n${tail.text}`;
21
+ }
22
+
23
+ /**
24
+ * Tail read cached by size+mtime. Callers should only ask for the tab they are showing.
25
+ */
26
+ export function readTaskFileTailCached(path: string, maxBytes = 512_000): string {
27
+ if (!path) return "(no output path)";
28
+ let size = 0;
29
+ let mtimeMs = 0;
30
+ try {
31
+ const st = statSync(path);
32
+ size = st.size;
33
+ mtimeMs = st.mtimeMs;
34
+ } catch {
35
+ return readTaskFileTail(path, maxBytes);
36
+ }
37
+ const hit = tailCache.get(path);
38
+ if (hit && hit.size === size && hit.mtimeMs === mtimeMs) return hit.text;
39
+ const text = readTaskFileTail(path, maxBytes);
40
+ tailCache.set(path, { size, mtimeMs, text });
41
+ return text;
42
+ }
43
+
44
+ export function clearTaskFileTailCache(): void {
45
+ tailCache.clear();
46
+ }