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/wake.ts ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Unified wake envelope (design doc §4.5).
3
+ *
4
+ * One customType, one lead-in, one <pi-famulus-wake> element. details.kind is the
5
+ * discriminator the renderer and the eval adapter both read.
6
+ */
7
+
8
+ export const FAMULUS_WAKE_CUSTOM_TYPE = "pi-famulus-wake";
9
+
10
+ export const FAMULUS_WAKE_LEAD_IN =
11
+ "System wake — not a new user message. Handle this <pi-famulus-wake> before other work.";
12
+
13
+ export interface WakeItem {
14
+ id: string;
15
+ title: string;
16
+ }
17
+
18
+ export interface TaskWake {
19
+ id: string;
20
+ taskKind: string;
21
+ status: "completed" | "failed" | "killed" | "orphaned";
22
+ summary: string;
23
+ command: string;
24
+ outputPath: string;
25
+ preview: string;
26
+ durationMs: number;
27
+ exitCode: number | null;
28
+ /** Signal name ("SIGTERM" | "SIGKILL"). Omitted when the exit had no signal. */
29
+ signal?: string;
30
+ }
31
+
32
+ export interface SubagentDoneChild {
33
+ childId: string;
34
+ name: string;
35
+ status: "pending" | "running" | "completed" | "failed" | "interrupted";
36
+ prompt: string;
37
+ result: string;
38
+ error?: string;
39
+ }
40
+
41
+ export type FamulusWake =
42
+ | { kind: "task"; stillRunning: WakeItem[]; tasks: TaskWake[] }
43
+ | {
44
+ kind: "monitor";
45
+ id: string;
46
+ description: string;
47
+ status?: string;
48
+ event: string;
49
+ eventCount?: number;
50
+ droppedLines?: number;
51
+ }
52
+ | {
53
+ kind: "subagent-handover";
54
+ runId: string;
55
+ childId: string;
56
+ name: string;
57
+ status: "completed" | "failed" | "interrupted";
58
+ stillRunning: WakeItem[];
59
+ summary: string;
60
+ prompt: string;
61
+ result: string;
62
+ error?: string;
63
+ }
64
+ | {
65
+ kind: "subagent-done";
66
+ runId: string;
67
+ status: "completed" | "partial" | "failed" | "interrupted";
68
+ durationMs: number;
69
+ summary: string;
70
+ children: SubagentDoneChild[];
71
+ }
72
+ | { kind: "supervisor-request"; from: string; name: string; message: string }
73
+ | { kind: "supervisor-update"; from: string; name: string; message: string };
74
+
75
+ export interface FormattedWake {
76
+ customType: typeof FAMULUS_WAKE_CUSTOM_TYPE;
77
+ content: string;
78
+ details: FamulusWake;
79
+ }
80
+
81
+ export function escapeXml(text: string): string {
82
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
83
+ }
84
+
85
+ export function escapeXmlAttr(text: string): string {
86
+ return escapeXml(text).replace(/"/g, "&quot;");
87
+ }
88
+
89
+ /** Shell still-running title: one line, capped at 80 characters. */
90
+ export function shellWakeTitle(command: string): string {
91
+ const one = command.replace(/\s+/g, " ").trim();
92
+ if (one.length <= 80) return one;
93
+ return `${one.slice(0, 79)}…`;
94
+ }
95
+
96
+ export function formatFamulusWake(details: FamulusWake, leadIn: string = FAMULUS_WAKE_LEAD_IN): FormattedWake {
97
+ const xml = renderWake(details);
98
+ const content = leadIn ? `${leadIn}\n\n${xml}` : xml;
99
+ return { customType: FAMULUS_WAKE_CUSTOM_TYPE, content, details };
100
+ }
101
+
102
+ function renderWake(details: FamulusWake): string {
103
+ switch (details.kind) {
104
+ case "task":
105
+ return renderTask(details);
106
+ case "monitor":
107
+ return renderMonitor(details);
108
+ case "subagent-handover":
109
+ return renderHandover(details);
110
+ case "subagent-done":
111
+ return renderDone(details);
112
+ case "supervisor-request":
113
+ return renderRequest(details);
114
+ case "supervisor-update":
115
+ return renderUpdate(details);
116
+ }
117
+ }
118
+
119
+ function stillRunningXml(items: WakeItem[]): string {
120
+ if (items.length === 0) return "";
121
+ const lines = items.map((item) => ` <item id="${escapeXmlAttr(item.id)}">${escapeXml(item.title)}</item>`);
122
+ return [" <still-running>", ...lines, " </still-running>"].join("\n");
123
+ }
124
+
125
+ function renderTask(details: Extract<FamulusWake, { kind: "task" }>): string {
126
+ const parts = ['<pi-famulus-wake kind="task">'];
127
+ const still = stillRunningXml(details.stillRunning);
128
+ if (still) parts.push(still);
129
+ for (const task of details.tasks) {
130
+ const attrs = [
131
+ `id="${escapeXmlAttr(task.id)}"`,
132
+ `kind="${escapeXmlAttr(task.taskKind)}"`,
133
+ `status="${escapeXmlAttr(task.status)}"`,
134
+ `duration-ms="${Math.round(task.durationMs)}"`,
135
+ ];
136
+ if (task.exitCode !== null) attrs.push(`exit-code="${task.exitCode}"`);
137
+ if (task.signal) attrs.push(`signal="${escapeXmlAttr(task.signal)}"`);
138
+ parts.push(` <task ${attrs.join(" ")}>`);
139
+ parts.push(` <summary>${escapeXml(task.summary)}</summary>`);
140
+ parts.push(` <command>${escapeXml(task.command)}</command>`);
141
+ parts.push(` <output-file>${escapeXml(task.outputPath)}</output-file>`);
142
+ parts.push(` <preview>${escapeXml(task.preview)}</preview>`);
143
+ parts.push(" </task>");
144
+ }
145
+ parts.push("</pi-famulus-wake>");
146
+ return parts.join("\n");
147
+ }
148
+
149
+ function renderMonitor(details: Extract<FamulusWake, { kind: "monitor" }>): string {
150
+ const attrs = [
151
+ 'kind="monitor"',
152
+ `id="${escapeXmlAttr(details.id)}"`,
153
+ `description="${escapeXmlAttr(details.description)}"`,
154
+ ];
155
+ if (details.status) attrs.push(`status="${escapeXmlAttr(details.status)}"`);
156
+ if (details.eventCount !== undefined && details.eventCount > 1) attrs.push(`event-count="${details.eventCount}"`);
157
+ if (details.droppedLines !== undefined && details.droppedLines > 0) {
158
+ attrs.push(`dropped-lines="${details.droppedLines}"`);
159
+ }
160
+ return [`<pi-famulus-wake ${attrs.join(" ")}>`, ` <event>${escapeXml(details.event)}</event>`, "</pi-famulus-wake>"].join("\n");
161
+ }
162
+
163
+ function renderHandover(details: Extract<FamulusWake, { kind: "subagent-handover" }>): string {
164
+ const attrs = [
165
+ 'kind="subagent-handover"',
166
+ `run-id="${escapeXmlAttr(details.runId)}"`,
167
+ `child-id="${escapeXmlAttr(details.childId)}"`,
168
+ `name="${escapeXmlAttr(details.name)}"`,
169
+ `status="${escapeXmlAttr(details.status)}"`,
170
+ ];
171
+ const parts = [`<pi-famulus-wake ${attrs.join(" ")}>`];
172
+ const still = stillRunningXml(details.stillRunning);
173
+ if (still) parts.push(still);
174
+ parts.push(` <summary>${escapeXml(details.summary)}</summary>`);
175
+ parts.push(` <prompt>${escapeXml(details.prompt)}</prompt>`);
176
+ if (details.error) parts.push(` <error>${escapeXml(details.error)}</error>`);
177
+ parts.push(` <result>${escapeXml(details.result)}</result>`);
178
+ parts.push("</pi-famulus-wake>");
179
+ return parts.join("\n");
180
+ }
181
+
182
+ function renderDone(details: Extract<FamulusWake, { kind: "subagent-done" }>): string {
183
+ const attrs = [
184
+ 'kind="subagent-done"',
185
+ `run-id="${escapeXmlAttr(details.runId)}"`,
186
+ `status="${escapeXmlAttr(details.status)}"`,
187
+ `duration-ms="${Math.round(details.durationMs)}"`,
188
+ ];
189
+ const parts = [`<pi-famulus-wake ${attrs.join(" ")}>`, ` <summary>${escapeXml(details.summary)}</summary>`];
190
+ for (const child of details.children) {
191
+ parts.push(
192
+ ` <child id="${escapeXmlAttr(child.childId)}" name="${escapeXmlAttr(child.name)}" status="${escapeXmlAttr(child.status)}">`,
193
+ );
194
+ parts.push(` <prompt>${escapeXml(child.prompt)}</prompt>`);
195
+ if (child.error) parts.push(` <error>${escapeXml(child.error)}</error>`);
196
+ parts.push(` <result>${escapeXml(child.result)}</result>`);
197
+ parts.push(" </child>");
198
+ }
199
+ parts.push("</pi-famulus-wake>");
200
+ return parts.join("\n");
201
+ }
202
+
203
+ function renderRequest(details: Extract<FamulusWake, { kind: "supervisor-request" }>): string {
204
+ const recipe = `agent_message { action: "reply", to: "${details.from}", message: "<your decision>" }`;
205
+ return [
206
+ `<pi-famulus-wake kind="supervisor-request" from="${escapeXmlAttr(details.from)}" name="${escapeXmlAttr(details.name)}">`,
207
+ ` <message>${escapeXml(details.message)}</message>`,
208
+ ` <reply-with>${escapeXml(recipe)}</reply-with>`,
209
+ "</pi-famulus-wake>",
210
+ ].join("\n");
211
+ }
212
+
213
+ function renderUpdate(details: Extract<FamulusWake, { kind: "supervisor-update" }>): string {
214
+ return [
215
+ `<pi-famulus-wake kind="supervisor-update" from="${escapeXmlAttr(details.from)}" name="${escapeXmlAttr(details.name)}">`,
216
+ ` <message>${escapeXml(details.message)}</message>`,
217
+ "</pi-famulus-wake>",
218
+ ].join("\n");
219
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * One index of background work for the fleet line, /tasks, and task_list.
3
+ *
4
+ * Finished items stay for 10 minutes (cap 50). Sync-waited shells are never
5
+ * inserted — only backgrounded shells count as workers.
6
+ */
7
+ import { realClock, type Clock } from "./clock";
8
+
9
+ export type WorkKind = "shell" | "monitor" | "agent";
10
+
11
+ export const WORK_RETAIN_MS = 10 * 60 * 1000;
12
+ export const WORK_FINISHED_CAP = 50;
13
+
14
+ export interface WorkItem {
15
+ id: string;
16
+ kind: WorkKind;
17
+ status: string;
18
+ title: string;
19
+ command?: string;
20
+ cwd?: string;
21
+ startedAt: number;
22
+ endedAt?: number;
23
+ exitCode?: number | null;
24
+ signal?: string;
25
+ endReason?: string;
26
+ outputPath?: string;
27
+ stderrPath?: string;
28
+ /** Backgrounded parent shell. Ignored for monitor/agent. */
29
+ countsAsWorker: boolean;
30
+ runId?: string;
31
+ name?: string;
32
+ agent?: string;
33
+ model?: string;
34
+ /** User-authored prompt, excluding injected agent/model preamble. */
35
+ prompt?: string;
36
+ /** Agent-authored preamble, shown separately from the user task prompt. */
37
+ preamble?: string;
38
+ /** Last assistant text, for task_output on ch_ ids. */
39
+ text?: string;
40
+ /** Failure reason for failed agents (shown in /tasks and task_list). */
41
+ error?: string;
42
+ }
43
+
44
+ export interface WorkCounts {
45
+ workers: number;
46
+ subagents: number;
47
+ monitors: number;
48
+ }
49
+
50
+ export interface WorkIndexOptions {
51
+ retainMs?: number;
52
+ finishedCap?: number;
53
+ clock?: Clock;
54
+ }
55
+
56
+ function isActiveStatus(status: string): boolean {
57
+ return status === "pending" || status === "running";
58
+ }
59
+
60
+ export class WorkIndex {
61
+ private readonly items = new Map<string, WorkItem>();
62
+ private readonly listeners = new Set<() => void>();
63
+ private readonly retainMs: number;
64
+ private readonly finishedCap: number;
65
+ private readonly clock: Clock;
66
+
67
+ constructor(opts: WorkIndexOptions = {}) {
68
+ this.retainMs = opts.retainMs ?? WORK_RETAIN_MS;
69
+ this.finishedCap = opts.finishedCap ?? WORK_FINISHED_CAP;
70
+ this.clock = opts.clock ?? realClock;
71
+ }
72
+
73
+ private now(): number {
74
+ return this.clock.now();
75
+ }
76
+
77
+ onChange(cb: () => void): () => void {
78
+ this.listeners.add(cb);
79
+ return () => this.listeners.delete(cb);
80
+ }
81
+
82
+ get(id: string): WorkItem | undefined {
83
+ return this.items.get(id);
84
+ }
85
+
86
+ upsert(item: WorkItem): void {
87
+ this.items.set(item.id, item);
88
+ this.prune();
89
+ this.emit();
90
+ }
91
+
92
+ patch(id: string, patch: Partial<WorkItem>): WorkItem | undefined {
93
+ const cur = this.items.get(id);
94
+ if (!cur) return undefined;
95
+ const next = { ...cur, ...patch, id: cur.id, kind: cur.kind };
96
+ this.items.set(id, next);
97
+ this.prune();
98
+ this.emit();
99
+ return next;
100
+ }
101
+
102
+ /**
103
+ * Active items (oldest first) plus finished items still inside the retain
104
+ * window (newest first), capped.
105
+ */
106
+ list(now = this.now()): WorkItem[] {
107
+ this.prune(now);
108
+ const active: WorkItem[] = [];
109
+ const finished: WorkItem[] = [];
110
+ for (const item of this.items.values()) {
111
+ if (isActiveStatus(item.status)) active.push(item);
112
+ else finished.push(item);
113
+ }
114
+ active.sort((a, b) => a.startedAt - b.startedAt);
115
+ finished.sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt));
116
+ return [...active, ...finished.slice(0, this.finishedCap)];
117
+ }
118
+
119
+ /**
120
+ * Shell/monitor rows still shown as live although the manager reports the
121
+ * task ended: their exit event was lost (reconnect, a race, an older
122
+ * manager). The caller settles them; nothing else ever would.
123
+ */
124
+ staleLive<T extends { task_id: string; status: string }>(tasks: readonly T[]): T[] {
125
+ return tasks.filter((task) => {
126
+ if (task.status === "running") return false;
127
+ const row = this.items.get(task.task_id);
128
+ return row !== undefined && row.kind !== "agent" && isActiveStatus(row.status);
129
+ });
130
+ }
131
+
132
+ /** Running background shells, subagents, and monitors. No total. */
133
+ counts(now = this.now()): WorkCounts {
134
+ let workers = 0;
135
+ let subagents = 0;
136
+ let monitors = 0;
137
+ for (const item of this.list(now)) {
138
+ if (!isActiveStatus(item.status)) continue;
139
+ if (item.kind === "shell" && item.countsAsWorker && item.status === "running") workers++;
140
+ else if (item.kind === "agent") subagents++;
141
+ else if (item.kind === "monitor" && item.status === "running") monitors++;
142
+ }
143
+ return { workers, subagents, monitors };
144
+ }
145
+
146
+ private prune(now = this.now()): void {
147
+ const finished: WorkItem[] = [];
148
+ for (const item of this.items.values()) {
149
+ if (isActiveStatus(item.status)) continue;
150
+ const ended = item.endedAt ?? item.startedAt;
151
+ if (now - ended > this.retainMs) {
152
+ this.items.delete(item.id);
153
+ continue;
154
+ }
155
+ finished.push(item);
156
+ }
157
+ if (finished.length <= this.finishedCap) return;
158
+ finished.sort((a, b) => (a.endedAt ?? a.startedAt) - (b.endedAt ?? b.startedAt));
159
+ for (const item of finished.slice(0, finished.length - this.finishedCap)) {
160
+ this.items.delete(item.id);
161
+ }
162
+ }
163
+
164
+ private emit(): void {
165
+ for (const cb of [...this.listeners]) {
166
+ try {
167
+ cb();
168
+ } catch {
169
+ // ignore listener errors
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ export function formatAge(startedAt: number, endedAt: number | undefined, now: number): string {
176
+ const end = endedAt ?? now;
177
+ const ms = Math.max(0, end - startedAt);
178
+ if (ms < 1000) return `${ms}ms`;
179
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
180
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`;
181
+ return `${Math.floor(ms / 3_600_000)}h${Math.floor((ms % 3_600_000) / 60_000)}m`;
182
+ }