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
package/src/events.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import type { Clock } from "./clock";
4
+
5
+ export type ExtensionEventFields = Record<string, unknown>;
6
+
7
+ /** Append extension observability events to the session's shared events.jsonl. */
8
+ export class ExtensionEventLog {
9
+ constructor(
10
+ private readonly home: string,
11
+ private readonly sessionId: () => string,
12
+ private readonly now: () => number,
13
+ ) {}
14
+
15
+ write(type: string, fields: ExtensionEventFields = {}): void {
16
+ const sid = this.sessionId();
17
+ if (!sid) return;
18
+ try {
19
+ const file = join(this.home, "sessions", sid, "events.jsonl");
20
+ mkdirSync(dirname(file), { recursive: true });
21
+ appendFileSync(file, `${formatExtensionEventLine({ ...fields, ts: this.now(), src: "extension", type })}\n`, {
22
+ encoding: "utf8",
23
+ flag: "a",
24
+ });
25
+ } catch {
26
+ // Observability must never break extension behavior.
27
+ }
28
+ }
29
+ }
30
+
31
+ export function formatExtensionEventLine(event: ExtensionEventFields): string {
32
+ const bounded: ExtensionEventFields = { ...event };
33
+ let truncated = false;
34
+ for (const [key, value] of Object.entries(bounded)) {
35
+ if (typeof value === "string" && value.length > 700) {
36
+ bounded[key] = `${value.slice(0, 699)}…`;
37
+ truncated = true;
38
+ } else if (Array.isArray(value) && value.length > 32) {
39
+ bounded[key] = value.slice(0, 32);
40
+ truncated = true;
41
+ }
42
+ }
43
+ if (truncated) bounded.truncated = true;
44
+ let line = JSON.stringify(bounded);
45
+ while (Buffer.byteLength(line, "utf8") + 1 >= 4096) {
46
+ const candidate = Object.entries(bounded).find(([, value]) => typeof value === "string" && value.length > 64);
47
+ if (!candidate) {
48
+ line = JSON.stringify({ ts: event.ts, src: "extension", type: event.type, truncated: true });
49
+ break;
50
+ }
51
+ const [key, value] = candidate;
52
+ bounded[key] = `${(value as string).slice(0, Math.max(0, Math.floor((value as string).length / 2) - 1))}…`;
53
+ bounded.truncated = true;
54
+ line = JSON.stringify(bounded);
55
+ }
56
+ return line;
57
+ }
58
+
59
+ export function createExtensionEventLog(home: string, sessionId: () => string, clock: Clock): ExtensionEventLog {
60
+ return new ExtensionEventLog(home, sessionId, () => clock.now());
61
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * A task can exit in the same socket read as a wait reply of done:false.
3
+ * The exit event is dispatched before markNotifyOnExit runs, so the wake is
4
+ * dropped. Stash that exit and fire it when the mark arrives late.
5
+ */
6
+ import { realClock, type Clock } from "./clock";
7
+
8
+ export interface StashedExit<T> {
9
+ taskId: string;
10
+ event: T;
11
+ }
12
+
13
+ export class ExitNotifyGate<T> {
14
+ private readonly notify = new Set<string>();
15
+ private readonly recent = new Map<string, T>();
16
+ private readonly ttlMs: number;
17
+ private readonly clock: Clock;
18
+ private readonly stamped = new Map<string, number>();
19
+
20
+ constructor(opts: { ttlMs?: number; clock?: Clock } = {}) {
21
+ this.ttlMs = opts.ttlMs ?? 30_000;
22
+ this.clock = opts.clock ?? realClock;
23
+ }
24
+
25
+ private now(): number {
26
+ return this.clock.now();
27
+ }
28
+
29
+ /**
30
+ * Record that this task should wake the parent when it exits.
31
+ * If the exit already arrived, return it so the caller can notify now.
32
+ */
33
+ mark(taskId: string): T | undefined {
34
+ this.prune();
35
+ this.notify.add(taskId);
36
+ const prior = this.recent.get(taskId);
37
+ if (prior === undefined) return undefined;
38
+ this.recent.delete(taskId);
39
+ this.stamped.delete(taskId);
40
+ this.notify.delete(taskId);
41
+ return prior;
42
+ }
43
+
44
+ /**
45
+ * An exit event arrived. "notify" if it was already marked, "stash" if the
46
+ * mark may still be coming, "ignore" for monitors (they have their own path).
47
+ */
48
+ onExit(taskId: string, event: T, isMonitor: boolean): "notify" | "stash" | "ignore" {
49
+ if (isMonitor) return "ignore";
50
+ this.prune();
51
+ if (this.notify.has(taskId)) {
52
+ this.notify.delete(taskId);
53
+ return "notify";
54
+ }
55
+ this.recent.set(taskId, event);
56
+ this.stamped.set(taskId, this.now());
57
+ return "stash";
58
+ }
59
+
60
+ private prune(): void {
61
+ const cutoff = this.now() - this.ttlMs;
62
+ for (const [id, at] of this.stamped) {
63
+ if (at < cutoff) {
64
+ this.stamped.delete(id);
65
+ this.recent.delete(id);
66
+ }
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Keeps backgrounded commands' wakes alive without the model's help.
3
+ *
4
+ * The prompt tells the agent to end its turn after backgrounding a command.
5
+ * Its wake normally comes from task_exited; a lost event is recovered by
6
+ * syncWithManager, which otherwise runs only when someone looks (task_list,
7
+ * /tasks, task_stop) or on reconnect. After reconnect gives up nothing looks,
8
+ * so an agent that obeyed would never be woken. While any wake is pending,
9
+ * this ticks on its own.
10
+ */
11
+ import type { Clock, ClockTimer } from "./clock";
12
+
13
+ export const EXIT_WATCHDOG_INTERVAL_MS = 30_000;
14
+
15
+ export interface ExitWatchdogOptions {
16
+ clock: Clock;
17
+ intervalMs?: number;
18
+ hasPending: () => boolean;
19
+ /** Reconnect if needed and settle ended tasks (delivering their wakes). */
20
+ tick: () => Promise<unknown>;
21
+ }
22
+
23
+ export class ExitWatchdog {
24
+ private timer: ClockTimer;
25
+ private armed = false;
26
+ private inFlight = false;
27
+
28
+ constructor(private readonly opts: ExitWatchdogOptions) {}
29
+
30
+ /** Start ticking if not already; call whenever a task starts awaiting its wake. */
31
+ arm(): void {
32
+ if (this.armed) return;
33
+ this.armed = true;
34
+ this.timer = this.opts.clock.setInterval(
35
+ () => this.onTick(),
36
+ this.opts.intervalMs ?? EXIT_WATCHDOG_INTERVAL_MS,
37
+ );
38
+ }
39
+
40
+ dispose(): void {
41
+ if (!this.armed) return;
42
+ this.armed = false;
43
+ this.opts.clock.clearInterval(this.timer);
44
+ }
45
+
46
+ private onTick(): void {
47
+ if (!this.opts.hasPending()) {
48
+ this.dispose();
49
+ return;
50
+ }
51
+ if (this.inFlight) return;
52
+ this.inFlight = true;
53
+ void this.opts
54
+ .tick()
55
+ .catch(() => {})
56
+ .finally(() => {
57
+ this.inFlight = false;
58
+ });
59
+ }
60
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Read only the tail of a file (fd + position). Never slurps the whole file.
3
+ */
4
+ import { closeSync, fstatSync, openSync, readSync } from "node:fs";
5
+
6
+ export interface FileTail {
7
+ text: string;
8
+ size: number;
9
+ }
10
+
11
+ /** Last `maxBytes` bytes, cut on a UTF-8 boundary. Returns null if the file cannot be opened. */
12
+ export function readFileTail(path: string, maxBytes: number): FileTail | null {
13
+ const cap = Math.max(0, Math.floor(maxBytes));
14
+ let fd: number | undefined;
15
+ try {
16
+ fd = openSync(path, "r");
17
+ const size = fstatSync(fd).size;
18
+ if (size === 0 || cap === 0) return { text: "", size };
19
+ const start = Math.max(0, size - cap);
20
+ const len = size - start;
21
+ const buf = Buffer.alloc(len);
22
+ let off = 0;
23
+ while (off < len) {
24
+ const n = readSync(fd, buf, off, len - off, start + off);
25
+ if (n <= 0) break;
26
+ off += n;
27
+ }
28
+ const raw = buf.subarray(0, off);
29
+ const text = (start > 0 ? skipPartialUtf8(raw) : raw).toString("utf8");
30
+ return { text, size };
31
+ } catch {
32
+ return null;
33
+ } finally {
34
+ if (fd !== undefined) {
35
+ try {
36
+ closeSync(fd);
37
+ } catch {
38
+ // ignore
39
+ }
40
+ }
41
+ }
42
+ }
43
+
44
+ function skipPartialUtf8(buf: Buffer): Buffer {
45
+ let i = 0;
46
+ while (i < buf.length && (buf[i] & 0b1100_0000) === 0b1000_0000) i++;
47
+ return buf.subarray(i);
48
+ }
package/src/format.ts ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Pure formatting helpers (design doc Appendix A).
3
+ *
4
+ * These functions are the shared contract between implementation and tests.
5
+ * Signatures must match Appendix A exactly.
6
+ */
7
+ import { formatFamulusWake, shellWakeTitle, type FormattedWake, type WakeItem } from "./wake";
8
+
9
+ export { FAMULUS_WAKE_LEAD_IN } from "./wake";
10
+ export type { FormattedWake, WakeItem } from "./wake";
11
+
12
+ export interface TruncationInfo {
13
+ truncated: boolean;
14
+ totalLines: number;
15
+ totalBytes: number;
16
+ }
17
+
18
+ function byteLength(text: string): number {
19
+ return Buffer.byteLength(text, "utf8");
20
+ }
21
+
22
+ /**
23
+ * Keep the tail of `text`, bounded by both maxLines and maxBytes
24
+ * (defaults match the built-in bash tool: 2000 lines / 50KB).
25
+ * Never splits a line, except when the final line alone exceeds maxBytes,
26
+ * in which case its byte-tail is kept (UTF-8 lossy).
27
+ */
28
+ export function truncateTail(
29
+ text: string,
30
+ maxLines: number = 2000,
31
+ maxBytes: number = 51200,
32
+ ): { text: string } & TruncationInfo {
33
+ const totalBytes = byteLength(text);
34
+ const lines = text.split("\n");
35
+ const totalLines = lines.length;
36
+ if (totalLines <= maxLines && totalBytes <= maxBytes) {
37
+ return { text, truncated: false, totalLines, totalBytes };
38
+ }
39
+ const kept: string[] = [];
40
+ let bytes = 0;
41
+ for (let i = lines.length - 1; i >= 0; i--) {
42
+ const lineBytes = byteLength(lines[i]) + (kept.length > 0 ? 1 : 0); // +1 for the joining newline
43
+ if (kept.length >= maxLines || bytes + lineBytes > maxBytes) break;
44
+ kept.unshift(lines[i]);
45
+ bytes += lineBytes;
46
+ }
47
+ if (kept.length === 0 && lines.length > 0) {
48
+ // The final line alone exceeds maxBytes: keep its byte tail, aligned to a
49
+ // UTF-8 code point boundary so no replacement character is surfaced.
50
+ kept.push(byteTail(lines[lines.length - 1], maxBytes));
51
+ }
52
+ return { text: kept.join("\n"), truncated: true, totalLines, totalBytes };
53
+ }
54
+
55
+ /** Last `maxBytes` bytes of `text`, cut at a UTF-8 boundary (never mid-character). */
56
+ function byteTail(text: string, maxBytes: number): string {
57
+ const buf = Buffer.from(text, "utf8");
58
+ if (buf.length <= maxBytes) return text;
59
+ let start = buf.length - maxBytes;
60
+ // Skip continuation bytes (0b10xxxxxx) to land on a character boundary.
61
+ while (start < buf.length && (buf[start] & 0b1100_0000) === 0b1000_0000) start++;
62
+ return buf.subarray(start).toString("utf8");
63
+ }
64
+
65
+ export interface TaskExitInfo {
66
+ taskId: string;
67
+ kind: string;
68
+ command: string;
69
+ status: "completed" | "failed" | "killed" | "orphaned";
70
+ exitCode: number | null;
71
+ durationMs: number;
72
+ outputPath: string;
73
+ /** Pre-truncated by the caller to 4000 chars. */
74
+ preview: string;
75
+ /** Signal name from the manager, when the exit was a signal. */
76
+ signal?: string;
77
+ }
78
+
79
+ /** Shorten a command for one-line display inside notifications. */
80
+ function displayCommand(command: string): string {
81
+ return shellWakeTitle(command);
82
+ }
83
+
84
+ function statusPhrase(info: TaskExitInfo): string {
85
+ const code = info.exitCode === null ? null : `exit code ${info.exitCode}`;
86
+ switch (info.status) {
87
+ case "completed":
88
+ return code ? `completed (${code})` : "completed";
89
+ case "failed":
90
+ return code ? `failed (${code})` : "failed";
91
+ case "killed":
92
+ return "was killed";
93
+ case "orphaned":
94
+ return "ended when pi-famulus crashed";
95
+ }
96
+ }
97
+
98
+ const TASK_COMMAND_CHARS = 2000;
99
+
100
+ function capCommand(command: string): string {
101
+ return command.length > TASK_COMMAND_CHARS ? `${command.slice(0, TASK_COMMAND_CHARS)}…` : command;
102
+ }
103
+
104
+ function taskSummary(info: TaskExitInfo): string {
105
+ return `Background command "${displayCommand(info.command)}" ${statusPhrase(info)}`;
106
+ }
107
+
108
+ /**
109
+ * One <pi-famulus-wake kind="task"> for a batch of exits (§4.5).
110
+ * `leadIn` is the ablation seam: "" leaves a parseable envelope.
111
+ */
112
+ export function formatTaskNotification(
113
+ events: TaskExitInfo[],
114
+ stillRunning: WakeItem[] = [],
115
+ leadIn?: string,
116
+ ): FormattedWake {
117
+ return formatFamulusWake(
118
+ {
119
+ kind: "task",
120
+ stillRunning,
121
+ tasks: events.map((info) => ({
122
+ id: info.taskId,
123
+ taskKind: info.kind,
124
+ status: info.status,
125
+ summary: taskSummary(info),
126
+ command: capCommand(info.command),
127
+ outputPath: info.outputPath,
128
+ preview: info.preview,
129
+ durationMs: info.durationMs,
130
+ exitCode: info.exitCode,
131
+ ...(info.signal ? { signal: info.signal } : {}),
132
+ })),
133
+ },
134
+ leadIn,
135
+ );
136
+ }
137
+
138
+ /** Tool-result text returned when a foreground command is moved to the background (§4.2). */
139
+ export function formatBackgroundNotice(
140
+ taskId: string,
141
+ command: string,
142
+ outputPath: string,
143
+ ): string {
144
+ // Model-facing. The transcript row is drawn by the bash tool's renderResult.
145
+ return [
146
+ `Command "${displayCommand(command)}" moved to background (task_id: ${taskId}). Output: ${outputPath}.`,
147
+ 'You will be notified when it completes, even if other commands are still running. Do not poll or sleep: reply to the user now with no tool call, and continue from the <pi-famulus-wake kind="task"> when it arrives.',
148
+ ].join("\n");
149
+ }
150
+
151
+ /** Transcript row for a backgrounded command (UI only; never sent to the model). */
152
+ export function backgroundRowText(
153
+ taskId: string,
154
+ item: { status: string; exitCode?: number | null; startedAt: number; endedAt?: number } | undefined,
155
+ now: number,
156
+ ): { glyph: string; color: string; text: string } {
157
+ if (!item || item.status === "running" || item.status === "pending") {
158
+ return { glyph: "⏵", color: "accent", text: `${taskId} running in background · /tasks` };
159
+ }
160
+ const secs = ((item.endedAt ?? now) - item.startedAt) / 1000;
161
+ const dur = secs < 60 ? `${secs.toFixed(1)}s` : `${Math.floor(secs / 60)}m${Math.round(secs % 60)}s`;
162
+ const exit = item.exitCode === null || item.exitCode === undefined ? "" : ` · exit ${item.exitCode}`;
163
+ const ok = item.status === "completed" && (item.exitCode === 0 || item.exitCode === null || item.exitCode === undefined);
164
+ return ok
165
+ ? { glyph: "✓", color: "success", text: `${taskId} finished${exit} · ${dur}` }
166
+ : { glyph: "✗", color: "error", text: `${taskId} ${item.status}${exit} · ${dur} · /tasks` };
167
+ }
168
+
169
+ /** Injected payload for a batch of monitor output lines (§4.4 / §4.5). */
170
+ export function formatMonitorEvent(
171
+ description: string,
172
+ taskId: string,
173
+ batchText: string,
174
+ status?: string,
175
+ extras: { eventCount?: number; droppedLines?: number } = {},
176
+ ): FormattedWake {
177
+ return formatFamulusWake({
178
+ kind: "monitor",
179
+ id: taskId,
180
+ description,
181
+ ...(status ? { status } : {}),
182
+ event: batchText,
183
+ ...(extras.eventCount !== undefined ? { eventCount: extras.eventCount } : {}),
184
+ ...(extras.droppedLines !== undefined ? { droppedLines: extras.droppedLines } : {}),
185
+ });
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // M3: subagent run completion notification (design doc §4.6)
190
+ // ---------------------------------------------------------------------------
191
+
192
+ export interface SubagentChildInfo {
193
+ childId: string;
194
+ name: string;
195
+ status: "pending" | "running" | "completed" | "failed" | "interrupted";
196
+ text: string;
197
+ error?: string;
198
+ /** Task prompt that was sent to this child. */
199
+ prompt?: string;
200
+ }
201
+
202
+ export interface SubagentNotificationInfo {
203
+ runId: string;
204
+ status: "completed" | "partial" | "failed" | "interrupted";
205
+ durationMs: number;
206
+ children: SubagentChildInfo[];
207
+ }
208
+
209
+ /** Per-child result text is capped to its tail inside notifications (§4.6). */
210
+ const SUBAGENT_NOTIFICATION_CHILD_CHARS = 2000;
211
+
212
+ function capTail(text: string): string {
213
+ return text.length > SUBAGENT_NOTIFICATION_CHILD_CHARS
214
+ ? text.slice(-SUBAGENT_NOTIFICATION_CHILD_CHARS)
215
+ : text;
216
+ }
217
+
218
+ /** Keep the start of a task prompt; the assignment is at the beginning. */
219
+ function capPrompt(text: string): string {
220
+ return text.length > SUBAGENT_NOTIFICATION_CHILD_CHARS
221
+ ? `${text.slice(0, SUBAGENT_NOTIFICATION_CHILD_CHARS)}…`
222
+ : text;
223
+ }
224
+
225
+ export interface SubagentHandoverInfo {
226
+ runId: string;
227
+ childId: string;
228
+ name: string;
229
+ status: "completed" | "failed" | "interrupted";
230
+ prompt: string;
231
+ text: string;
232
+ error?: string;
233
+ /** Children that are still pending or running. Title is the agent name. */
234
+ stillRunning: WakeItem[];
235
+ }
236
+
237
+ /** Finished run: one <child> per child inside <pi-famulus-wake kind="subagent-done">. */
238
+ export function formatSubagentNotification(info: SubagentNotificationInfo): FormattedWake {
239
+ const completed = info.children.filter((c) => c.status === "completed").length;
240
+ const summary = `${completed}/${info.children.length} subagents completed in ${Math.round(info.durationMs)}ms`;
241
+ return formatFamulusWake({
242
+ kind: "subagent-done",
243
+ runId: info.runId,
244
+ status: info.status,
245
+ durationMs: info.durationMs,
246
+ summary,
247
+ children: info.children.map((child) => ({
248
+ childId: child.childId,
249
+ name: child.name,
250
+ status: child.status,
251
+ prompt: capPrompt(child.prompt ?? ""),
252
+ result: capTail(child.text),
253
+ ...(child.error ? { error: child.error } : {}),
254
+ })),
255
+ });
256
+ }
257
+
258
+ /**
259
+ * One child finished while others in the same run are still going.
260
+ * The parent must see the original prompt and the result, then keep working.
261
+ */
262
+ export function formatSubagentHandover(info: SubagentHandoverInfo): FormattedWake {
263
+ const summary = `${info.name} ${info.status}; ${info.stillRunning.length} still running`;
264
+ return formatFamulusWake({
265
+ kind: "subagent-handover",
266
+ runId: info.runId,
267
+ childId: info.childId,
268
+ name: info.name,
269
+ status: info.status,
270
+ stillRunning: info.stillRunning,
271
+ summary,
272
+ prompt: capPrompt(info.prompt),
273
+ result: capTail(info.text),
274
+ ...(info.error ? { error: info.error } : {}),
275
+ });
276
+ }