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.
- package/LICENSE +21 -0
- package/README.md +36 -0
- package/bin/pi-famulus.js +29 -0
- package/package.json +49 -0
- package/src/agents/builtins.ts +41 -0
- package/src/agents/definition.ts +221 -0
- package/src/agents/loader.ts +119 -0
- package/src/bash-override.ts +372 -0
- package/src/behavior-guidelines.ts +42 -0
- package/src/clock.ts +169 -0
- package/src/comms/comms.ts +199 -0
- package/src/comms/mailbox.ts +156 -0
- package/src/comms/registry-host.ts +73 -0
- package/src/comms/reply-command.ts +52 -0
- package/src/comms/routing.ts +23 -0
- package/src/comms/tools.ts +339 -0
- package/src/comms/types.ts +68 -0
- package/src/config.ts +266 -0
- package/src/events.ts +61 -0
- package/src/exit-notify-gate.ts +69 -0
- package/src/exit-watchdog.ts +60 -0
- package/src/file-tail.ts +48 -0
- package/src/format.ts +276 -0
- package/src/index.ts +700 -0
- package/src/manager-client.ts +839 -0
- package/src/monitor-batching.ts +191 -0
- package/src/monitor.ts +517 -0
- package/src/native-manager.d.ts +9 -0
- package/src/native-manager.js +39 -0
- package/src/notify.ts +231 -0
- package/src/shell-exec.ts +145 -0
- package/src/subagent/agent-records.ts +168 -0
- package/src/subagent/child-bash.ts +183 -0
- package/src/subagent/conversation.ts +76 -0
- package/src/subagent/fleet-widget.ts +176 -0
- package/src/subagent/model-spec.ts +108 -0
- package/src/subagent/pi-runtime.ts +295 -0
- package/src/subagent/pool.ts +190 -0
- package/src/subagent/registry.ts +575 -0
- package/src/subagent/runner.ts +709 -0
- package/src/subagent/tool.ts +741 -0
- package/src/subagent/transcript.ts +71 -0
- package/src/subagent/types.ts +125 -0
- package/src/task-exit-notify.ts +21 -0
- package/src/task-tools.ts +330 -0
- package/src/tui/message-renderers.ts +183 -0
- package/src/tui/pi-tui-load.ts +186 -0
- package/src/tui/scroll-detail-view.ts +190 -0
- package/src/tui/task-output-paths.ts +46 -0
- package/src/tui/tasks-command.ts +515 -0
- package/src/tui/tool-component.ts +29 -0
- package/src/wake.ts +219 -0
- package/src/work-index.ts +182 -0
package/src/notify.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotifyCenter (design doc §4.5): the single injection point for all
|
|
3
|
+
* asynchronous events into the pi session.
|
|
4
|
+
*
|
|
5
|
+
* - idle -> pi.sendMessage(msg, { triggerTurn: true })
|
|
6
|
+
* - busy -> pi.sendMessage(msg, { deliverAs: "steer" })
|
|
7
|
+
* - passive -> held until the agent settles, then
|
|
8
|
+
* pi.sendMessage(msg, { triggerTurn: false }): appended, starting no turn.
|
|
9
|
+
* Sent at once while the model was writing, pi appended it at that turn's
|
|
10
|
+
* end, while a wake steered in at the same moment waits for the next turn's
|
|
11
|
+
* start: a monitor's exit landed before its own event (eval batches 2–4).
|
|
12
|
+
* - task exit notifications are coalesced over a 200ms window into one
|
|
13
|
+
* <pi-famulus-wake kind="task"> payload, and the same task/event pair is only
|
|
14
|
+
* ever delivered once.
|
|
15
|
+
*/
|
|
16
|
+
import { formatMonitorEvent, formatTaskNotification, type TaskExitInfo } from "./format";
|
|
17
|
+
import { FAMULUS_WAKE_CUSTOM_TYPE, type WakeItem } from "./wake";
|
|
18
|
+
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
export interface NotifyMessage {
|
|
22
|
+
customType: string;
|
|
23
|
+
content: string;
|
|
24
|
+
details?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NotifyCenterDeps {
|
|
28
|
+
sendMessage: (
|
|
29
|
+
message: { customType: string; content: string; display: boolean; details?: unknown },
|
|
30
|
+
options: { triggerTurn?: boolean; deliverAs?: "steer" },
|
|
31
|
+
) => void;
|
|
32
|
+
isIdle: () => boolean;
|
|
33
|
+
/** Batching window for task exit notifications (ms). Default 200. */
|
|
34
|
+
batchMs?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Background tasks still awaiting their own exit wake.
|
|
37
|
+
* Read at flush time so siblings that exit in the same window are not listed.
|
|
38
|
+
*/
|
|
39
|
+
listStillRunning?: () => WakeItem[];
|
|
40
|
+
clock?: Clock;
|
|
41
|
+
logEvent?: (type: string, fields?: Record<string, unknown>) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
export class NotifyCenter {
|
|
46
|
+
private readonly deps: NotifyCenterDeps;
|
|
47
|
+
private readonly batchMs: number;
|
|
48
|
+
private readonly clock: Clock;
|
|
49
|
+
private pendingExits: TaskExitInfo[] = [];
|
|
50
|
+
private readonly pendingMonitors = new Map<
|
|
51
|
+
string,
|
|
52
|
+
{ description: string; eventCount: number; lastEvent: string; droppedLines: number; quiet: boolean }
|
|
53
|
+
>();
|
|
54
|
+
private readonly seen = new Set<string>();
|
|
55
|
+
private timer: ClockTimer | null = null;
|
|
56
|
+
private disposed = false;
|
|
57
|
+
/** A wake started or steered a run that has not settled yet. */
|
|
58
|
+
private runPending = false;
|
|
59
|
+
private held: NotifyMessage[] = [];
|
|
60
|
+
|
|
61
|
+
constructor(deps: NotifyCenterDeps) {
|
|
62
|
+
this.deps = deps;
|
|
63
|
+
this.batchMs = deps.batchMs ?? 200;
|
|
64
|
+
this.clock = deps.clock ?? realClock;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Queue a task exit notification. Deduplicated by task id; merged with
|
|
69
|
+
* other exits inside the batching window.
|
|
70
|
+
*/
|
|
71
|
+
notifyTaskExit(info: TaskExitInfo): void {
|
|
72
|
+
if (this.disposed) return;
|
|
73
|
+
const key = `exit:${info.taskId}`;
|
|
74
|
+
if (this.seen.has(key)) {
|
|
75
|
+
this.deps.logEvent?.("wake.dedupe", { id: info.taskId });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.seen.add(key);
|
|
79
|
+
this.pendingExits.push(info);
|
|
80
|
+
this.scheduleFlush();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Send a notification immediately, routed by idle/busy state. A monitor's
|
|
85
|
+
* own notice (exit, timeout, stop) first delivers that monitor's coalesced
|
|
86
|
+
* events: they happened before it, and the model must not hear "exited"
|
|
87
|
+
* ahead of the lines the command printed.
|
|
88
|
+
*/
|
|
89
|
+
notify(message: NotifyMessage, opts: { passive?: boolean; quietEvents?: boolean } = {}): void {
|
|
90
|
+
if (this.disposed) return;
|
|
91
|
+
const wake = message.details as { kind?: string; id?: string } | undefined;
|
|
92
|
+
if (wake?.kind === "monitor" && typeof wake.id === "string") this.flushMonitor(wake.id, opts.quietEvents);
|
|
93
|
+
if (!opts.passive) {
|
|
94
|
+
this.deliver(message);
|
|
95
|
+
} else if (this.runPending || !this.deps.isIdle()) {
|
|
96
|
+
this.held.push(message);
|
|
97
|
+
} else {
|
|
98
|
+
this.deliver(message, true);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The agent settled: the run any earlier wake started is over. Flush
|
|
104
|
+
* coalesced monitor output, then, unless that started another run, the
|
|
105
|
+
* held passive notices.
|
|
106
|
+
*/
|
|
107
|
+
settled(): void {
|
|
108
|
+
if (this.disposed) return;
|
|
109
|
+
this.runPending = false;
|
|
110
|
+
this.flushMonitorEvents();
|
|
111
|
+
if (this.runPending) return;
|
|
112
|
+
for (const message of this.held.splice(0)) this.deliver(message, true);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Deliver monitor output immediately when idle, otherwise coalesce per
|
|
117
|
+
* monitor. `quiet`: output of a monitor the model stopped; it is only
|
|
118
|
+
* coalesced, and its summary is delivered without a turn (with the exit
|
|
119
|
+
* notice, or at settle), pending events of that monitor included.
|
|
120
|
+
*/
|
|
121
|
+
notifyMonitorEvent(description: string, taskId: string, event: string, droppedLines = 0, opts: { quiet?: boolean } = {}): void {
|
|
122
|
+
if (this.disposed) return;
|
|
123
|
+
const pending = this.pendingMonitors.get(taskId);
|
|
124
|
+
if (this.deps.isIdle() && !pending && !opts.quiet) {
|
|
125
|
+
const wake = formatMonitorEvent(description, taskId, event, undefined, { droppedLines });
|
|
126
|
+
this.deliver({ customType: wake.customType, content: wake.content, details: wake.details });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
this.pendingMonitors.set(taskId, {
|
|
130
|
+
description,
|
|
131
|
+
eventCount: (pending?.eventCount ?? 0) + 1,
|
|
132
|
+
lastEvent: event,
|
|
133
|
+
droppedLines: (pending?.droppedLines ?? 0) + droppedLines,
|
|
134
|
+
quiet: (pending?.quiet ?? false) || opts.quiet === true,
|
|
135
|
+
});
|
|
136
|
+
if (this.deps.isIdle() && !opts.quiet) this.flushMonitorEvents();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Flush coalesced monitor output when the parent agent settles. */
|
|
140
|
+
flushMonitorEvents(): void {
|
|
141
|
+
if (this.disposed || !this.deps.isIdle() || this.pendingMonitors.size === 0) return;
|
|
142
|
+
for (const taskId of [...this.pendingMonitors.keys()]) this.flushMonitor(taskId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Deliver one monitor's coalesced events, if any, in the current mode. */
|
|
146
|
+
private flushMonitor(taskId: string, quiet = false): void {
|
|
147
|
+
const item = this.pendingMonitors.get(taskId);
|
|
148
|
+
if (!item) return;
|
|
149
|
+
this.pendingMonitors.delete(taskId);
|
|
150
|
+
const summary = item.eventCount > 1
|
|
151
|
+
? `${item.eventCount} events · last: ${item.lastEvent}`
|
|
152
|
+
: item.lastEvent;
|
|
153
|
+
const wake = formatMonitorEvent(item.description, taskId, summary, undefined, {
|
|
154
|
+
eventCount: item.eventCount,
|
|
155
|
+
droppedLines: item.droppedLines,
|
|
156
|
+
});
|
|
157
|
+
const message = { customType: wake.customType, content: wake.content, details: wake.details };
|
|
158
|
+
if (quiet || item.quiet) this.notify(message, { passive: true });
|
|
159
|
+
else this.deliver(message);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Flush any pending task exit notifications now. */
|
|
163
|
+
flush(): void {
|
|
164
|
+
this.clearTimer();
|
|
165
|
+
this.flushExits();
|
|
166
|
+
this.flushMonitorEvents();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
dispose(): void {
|
|
170
|
+
this.disposed = true;
|
|
171
|
+
this.clearTimer();
|
|
172
|
+
this.pendingExits = [];
|
|
173
|
+
this.pendingMonitors.clear();
|
|
174
|
+
this.held = [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private scheduleFlush(): void {
|
|
178
|
+
if (this.timer !== null) return;
|
|
179
|
+
this.timer = this.clock.setTimeout(() => {
|
|
180
|
+
this.timer = null;
|
|
181
|
+
this.flushExits();
|
|
182
|
+
}, this.batchMs);
|
|
183
|
+
this.clock.unref?.(this.timer);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private clearTimer(): void {
|
|
187
|
+
if (this.timer !== null) {
|
|
188
|
+
this.clock.clearTimeout(this.timer);
|
|
189
|
+
this.timer = null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private flushExits(): void {
|
|
194
|
+
if (this.disposed || this.pendingExits.length === 0) return;
|
|
195
|
+
const events = this.pendingExits;
|
|
196
|
+
this.pendingExits = [];
|
|
197
|
+
const wake = formatTaskNotification(events, this.deps.listStillRunning?.() ?? []);
|
|
198
|
+
this.deliver({
|
|
199
|
+
customType: FAMULUS_WAKE_CUSTOM_TYPE,
|
|
200
|
+
content: wake.content,
|
|
201
|
+
details: wake.details,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private deliver(message: NotifyMessage, passive = false): void {
|
|
206
|
+
const msg = { ...message, display: true };
|
|
207
|
+
const details = message.details as { kind?: string; id?: string; taskId?: string; tasks?: { id: string }[]; children?: { childId: string }[]; childId?: string; from?: string; eventCount?: number } | undefined;
|
|
208
|
+
if (details?.kind) {
|
|
209
|
+
const ids = details.kind === "task"
|
|
210
|
+
? (details.tasks ?? []).map((task) => task.id)
|
|
211
|
+
: details.kind === "subagent-done"
|
|
212
|
+
? (details.children ?? []).map((child) => child.childId)
|
|
213
|
+
: [details.id ?? details.taskId ?? details.childId ?? details.from].filter((id): id is string => Boolean(id));
|
|
214
|
+
this.deps.logEvent?.("wake.emit", {
|
|
215
|
+
kind: details.kind,
|
|
216
|
+
ids,
|
|
217
|
+
batch: ids.length > 1 || (details.kind === "monitor" && (details.eventCount ?? 0) > 1),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const mode = passive ? "passive" : this.deps.isIdle() ? "trigger" : "steer";
|
|
221
|
+
this.deps.logEvent?.("wake.deliver", { kind: details?.kind ?? "unknown", mode });
|
|
222
|
+
if (mode !== "passive") this.runPending = true;
|
|
223
|
+
if (mode === "passive") {
|
|
224
|
+
this.deps.sendMessage(msg, { triggerTurn: false });
|
|
225
|
+
} else if (mode === "trigger") {
|
|
226
|
+
this.deps.sendMessage(msg, { triggerTurn: true });
|
|
227
|
+
} else {
|
|
228
|
+
this.deps.sendMessage(msg, { deliverAs: "steer" });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared bash helpers used by the parent override and the child-session bash.
|
|
3
|
+
* Previously copied in both files.
|
|
4
|
+
*/
|
|
5
|
+
import type { BashToolDetails } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { truncateTail } from "./format";
|
|
7
|
+
import type { ManagerClient } from "./manager-client";
|
|
8
|
+
|
|
9
|
+
export const SHELL_MAX_LINES = 2000;
|
|
10
|
+
export const SHELL_MAX_BYTES = 51200;
|
|
11
|
+
export const SHELL_OUTPUT_WINDOW_BYTES = 512 * 1024;
|
|
12
|
+
|
|
13
|
+
export const BARE_SLEEP_PATTERNS: RegExp[] = [
|
|
14
|
+
/^\s*sleep\s+\d/,
|
|
15
|
+
/^\s*while\s+true\b/,
|
|
16
|
+
/^\s*while\s+sleep\b/,
|
|
17
|
+
/^\s*until\s+/,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export function bareSleepError(command: string, guidance: string): string | null {
|
|
21
|
+
if (!BARE_SLEEP_PATTERNS.some((re) => re.test(command))) return null;
|
|
22
|
+
return `Refusing to run a bare sleep/idle-loop command: ${JSON.stringify(command)}. ${guidance}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function withAbort<T>(
|
|
26
|
+
promise: Promise<T>,
|
|
27
|
+
signal: AbortSignal | undefined,
|
|
28
|
+
onAbort: () => void,
|
|
29
|
+
): Promise<T> {
|
|
30
|
+
if (!signal) return promise;
|
|
31
|
+
if (signal.aborted) {
|
|
32
|
+
onAbort();
|
|
33
|
+
return Promise.reject(new Error("aborted"));
|
|
34
|
+
}
|
|
35
|
+
return new Promise<T>((resolve, reject) => {
|
|
36
|
+
const handler = () => {
|
|
37
|
+
onAbort();
|
|
38
|
+
reject(new Error("aborted"));
|
|
39
|
+
};
|
|
40
|
+
signal.addEventListener("abort", handler, { once: true });
|
|
41
|
+
promise.then(
|
|
42
|
+
(value) => {
|
|
43
|
+
signal.removeEventListener("abort", handler);
|
|
44
|
+
resolve(value);
|
|
45
|
+
},
|
|
46
|
+
(err) => {
|
|
47
|
+
signal.removeEventListener("abort", handler);
|
|
48
|
+
reject(err);
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CollectedOutput {
|
|
55
|
+
text: string;
|
|
56
|
+
totalSize: number;
|
|
57
|
+
windowed: boolean;
|
|
58
|
+
/** The task's status as the manager reported it with the output. */
|
|
59
|
+
status: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function collectOutput(client: ManagerClient, taskId: string): Promise<CollectedOutput> {
|
|
63
|
+
const probe = await client.output(taskId, 0, 1);
|
|
64
|
+
const totalSize = probe.total_size;
|
|
65
|
+
const start = Math.max(0, totalSize - SHELL_OUTPUT_WINDOW_BYTES);
|
|
66
|
+
let cursor = start;
|
|
67
|
+
let text = "";
|
|
68
|
+
for (;;) {
|
|
69
|
+
const res = await client.output(taskId, cursor, SHELL_OUTPUT_WINDOW_BYTES);
|
|
70
|
+
text += res.chunk;
|
|
71
|
+
if (res.next_cursor <= cursor || res.next_cursor >= res.total_size) break;
|
|
72
|
+
cursor = res.next_cursor;
|
|
73
|
+
}
|
|
74
|
+
return { text, totalSize, windowed: start > 0, status: probe.status };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatSize(bytes: number): string {
|
|
78
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
79
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`;
|
|
80
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface FormattedShellOutput {
|
|
84
|
+
text: string;
|
|
85
|
+
details: BashToolDetails | undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function formatFinishedOutput(raw: CollectedOutput, outputPath: string): FormattedShellOutput {
|
|
89
|
+
const t = truncateTail(raw.text, SHELL_MAX_LINES, SHELL_MAX_BYTES);
|
|
90
|
+
const truncated = t.truncated || raw.windowed;
|
|
91
|
+
let text = t.text || "(no output)";
|
|
92
|
+
if (!truncated) return { text, details: undefined };
|
|
93
|
+
|
|
94
|
+
const outputLines = t.text.length === 0 ? 0 : t.text.split("\n").length;
|
|
95
|
+
const outputBytes = Buffer.byteLength(t.text, "utf8");
|
|
96
|
+
const truncatedBy = t.totalLines > SHELL_MAX_LINES ? "lines" : "bytes";
|
|
97
|
+
const details: BashToolDetails = {
|
|
98
|
+
truncation: {
|
|
99
|
+
content: t.text,
|
|
100
|
+
truncated: true,
|
|
101
|
+
truncatedBy,
|
|
102
|
+
totalLines: t.totalLines,
|
|
103
|
+
totalBytes: raw.totalSize,
|
|
104
|
+
outputLines,
|
|
105
|
+
outputBytes,
|
|
106
|
+
lastLinePartial: false,
|
|
107
|
+
firstLineExceedsLimit: false,
|
|
108
|
+
maxLines: SHELL_MAX_LINES,
|
|
109
|
+
maxBytes: SHELL_MAX_BYTES,
|
|
110
|
+
},
|
|
111
|
+
fullOutputPath: outputPath,
|
|
112
|
+
};
|
|
113
|
+
const startLine = t.totalLines - outputLines + 1;
|
|
114
|
+
const endLine = t.totalLines;
|
|
115
|
+
if (truncatedBy === "lines") {
|
|
116
|
+
text += `\n\n[Showing lines ${startLine}-${endLine} of ${t.totalLines}. Full output: ${outputPath}]`;
|
|
117
|
+
} else {
|
|
118
|
+
text += `\n\n[Showing lines ${startLine}-${endLine} of ${t.totalLines} (${formatSize(SHELL_MAX_BYTES)} limit). Full output: ${outputPath}]`;
|
|
119
|
+
}
|
|
120
|
+
return { text, details };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function appendStatus(text: string, status: string): string {
|
|
124
|
+
return text ? `${text}\n\n${status}` : status;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Status line for a finished task with no exit code: it was killed (its
|
|
129
|
+
* timeout, a stop, a crash). Reported as success, a model took a 1s timeout
|
|
130
|
+
* kill for a command still running (eval batch 4).
|
|
131
|
+
*/
|
|
132
|
+
export async function killedStatus(client: ManagerClient, taskId: string, timeoutSeconds: number | undefined): Promise<string> {
|
|
133
|
+
const record = (await client.list(true).catch(() => [])).find((t) => t.task_id === taskId);
|
|
134
|
+
if (record?.end_reason === "timeout" && timeoutSeconds !== undefined) return timedOutStatus(timeoutSeconds);
|
|
135
|
+
return killedBy(record?.signal);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The one wording of a timeout kill, on every bash path. */
|
|
139
|
+
export function timedOutStatus(timeoutSeconds: number): string {
|
|
140
|
+
return `Command timed out after ${timeoutSeconds} seconds`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function killedBy(signal: string | null | undefined): string {
|
|
144
|
+
return `Command was killed${signal ? ` (${signal})` : ""}`;
|
|
145
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk agent child records so `pi-famulus ls` can see in-process subagents
|
|
3
|
+
* (design doc §4.3: task_list merges manager tasks with subagent runs).
|
|
4
|
+
*
|
|
5
|
+
* Layout: <home>/sessions/<session_id>/agents/<child_id>.json
|
|
6
|
+
*/
|
|
7
|
+
import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
export interface AgentChildRecord {
|
|
11
|
+
v: 1;
|
|
12
|
+
kind: "agent";
|
|
13
|
+
child_id: string;
|
|
14
|
+
run_id: string;
|
|
15
|
+
session_id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
agent: string;
|
|
18
|
+
/** Resolved model id when known (param override or agent definition). */
|
|
19
|
+
model?: string;
|
|
20
|
+
status: "pending" | "running" | "completed" | "failed" | "interrupted";
|
|
21
|
+
/** Total generations run (1 + resumes + stall retries); omitted when 1. */
|
|
22
|
+
attempts?: number;
|
|
23
|
+
/** Stall detections in the final user turn. */
|
|
24
|
+
stalls?: number;
|
|
25
|
+
started_at: number;
|
|
26
|
+
ended_at?: number;
|
|
27
|
+
error?: string;
|
|
28
|
+
/** Why the child ended (terminal statuses only). */
|
|
29
|
+
end_reason?: AgentEndReason;
|
|
30
|
+
/** Task prompt without the agent preamble, capped. */
|
|
31
|
+
prompt_head?: string;
|
|
32
|
+
/** Tail of the final result text, capped. */
|
|
33
|
+
result_tail?: string;
|
|
34
|
+
/** Tool results seen in the transcript so far. */
|
|
35
|
+
tool_calls?: number;
|
|
36
|
+
/** Absolute path of the live `<child_id>.jsonl` transcript. */
|
|
37
|
+
transcript?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type AgentEndReason =
|
|
41
|
+
| "completed"
|
|
42
|
+
| "failed"
|
|
43
|
+
| "model-error"
|
|
44
|
+
| "stalled"
|
|
45
|
+
| "timeout"
|
|
46
|
+
| "interrupted"
|
|
47
|
+
| "disposed";
|
|
48
|
+
|
|
49
|
+
/** Map a terminal child status + result to the end_reason recorded on disk. */
|
|
50
|
+
export function agentEndReason(
|
|
51
|
+
status: AgentChildRecord["status"],
|
|
52
|
+
result: { error?: string; endReason?: "model-error" } | undefined,
|
|
53
|
+
): AgentEndReason | undefined {
|
|
54
|
+
if (status === "completed") return "completed";
|
|
55
|
+
if (status === "failed") {
|
|
56
|
+
if (result?.endReason === "model-error") return "model-error";
|
|
57
|
+
return result?.error === "stalled" ? "stalled" : "failed";
|
|
58
|
+
}
|
|
59
|
+
if (status === "interrupted") {
|
|
60
|
+
if (result?.error === "timeout") return "timeout";
|
|
61
|
+
if (result?.error === "disposed") return "disposed";
|
|
62
|
+
return "interrupted";
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const HEAD_TAIL_CHARS = 2000;
|
|
68
|
+
|
|
69
|
+
export function headOf(text: string): string {
|
|
70
|
+
return text.length <= HEAD_TAIL_CHARS ? text : `${text.slice(0, HEAD_TAIL_CHARS)}…`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function tailOf(text: string): string {
|
|
74
|
+
return text.length <= HEAD_TAIL_CHARS ? text : `…${text.slice(-HEAD_TAIL_CHARS)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function agentRecordsDir(home: string, sessionId: string): string {
|
|
78
|
+
return join(home, "sessions", sessionId, "agents");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function agentRecordPath(home: string, sessionId: string, childId: string): string {
|
|
82
|
+
return join(agentRecordsDir(home, sessionId), `${childId}.json`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Write one child record (mkdir + overwrite). */
|
|
86
|
+
export function writeAgentChildRecord(home: string, record: AgentChildRecord): void {
|
|
87
|
+
const dir = agentRecordsDir(home, record.session_id);
|
|
88
|
+
mkdirSync(dir, { recursive: true });
|
|
89
|
+
writeFileSync(agentRecordPath(home, record.session_id, record.child_id), JSON.stringify(record));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function removeAgentChildRecord(home: string, sessionId: string, childId: string): void {
|
|
93
|
+
try {
|
|
94
|
+
unlinkSync(agentRecordPath(home, sessionId, childId));
|
|
95
|
+
} catch {
|
|
96
|
+
// already gone
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseRecord(raw: string): AgentChildRecord | null {
|
|
101
|
+
try {
|
|
102
|
+
const v = JSON.parse(raw) as Partial<AgentChildRecord>;
|
|
103
|
+
if (v.v !== 1 || v.kind !== "agent") return null;
|
|
104
|
+
if (typeof v.child_id !== "string" || typeof v.session_id !== "string") return null;
|
|
105
|
+
if (typeof v.name !== "string" || typeof v.agent !== "string") return null;
|
|
106
|
+
if (typeof v.status !== "string" || typeof v.started_at !== "number") return null;
|
|
107
|
+
return v as AgentChildRecord;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Load agent child records from disk (all sessions under home). */
|
|
114
|
+
export function loadAgentChildRecords(
|
|
115
|
+
home: string,
|
|
116
|
+
opts: { sessionId?: string; includeTerminal?: boolean; connectedSessionIds?: ReadonlySet<string> } = {},
|
|
117
|
+
): AgentChildRecord[] {
|
|
118
|
+
const sessionsRoot = join(home, "sessions");
|
|
119
|
+
let sessionIds: string[];
|
|
120
|
+
try {
|
|
121
|
+
sessionIds = opts.sessionId
|
|
122
|
+
? [opts.sessionId]
|
|
123
|
+
: readdirSync(sessionsRoot, { withFileTypes: true })
|
|
124
|
+
.filter((d) => d.isDirectory())
|
|
125
|
+
.map((d) => d.name);
|
|
126
|
+
} catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
const out: AgentChildRecord[] = [];
|
|
130
|
+
for (const sid of sessionIds) {
|
|
131
|
+
const dir = agentRecordsDir(home, sid);
|
|
132
|
+
let files: string[];
|
|
133
|
+
try {
|
|
134
|
+
files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
135
|
+
} catch {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
for (const file of files) {
|
|
139
|
+
try {
|
|
140
|
+
const rec = parseRecord(readFileSync(join(dir, file), "utf8"));
|
|
141
|
+
if (!rec) continue;
|
|
142
|
+
const disconnected =
|
|
143
|
+
opts.connectedSessionIds !== undefined && !opts.connectedSessionIds.has(rec.session_id);
|
|
144
|
+
const shown: AgentChildRecord =
|
|
145
|
+
disconnected && (rec.status === "pending" || rec.status === "running")
|
|
146
|
+
? { ...rec, status: "interrupted" }
|
|
147
|
+
: rec;
|
|
148
|
+
const terminal =
|
|
149
|
+
shown.status === "completed" || shown.status === "failed" || shown.status === "interrupted";
|
|
150
|
+
if (!opts.includeTerminal && terminal) continue;
|
|
151
|
+
out.push(shown);
|
|
152
|
+
} catch {
|
|
153
|
+
// skip bad files
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function isAgentStatusActive(status: AgentChildRecord["status"]): boolean {
|
|
161
|
+
return status === "pending" || status === "running";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Display command column for CLI / task_list. */
|
|
165
|
+
export function formatAgentCommand(rec: AgentChildRecord): string {
|
|
166
|
+
const model = rec.model ? ` ${rec.model}` : "";
|
|
167
|
+
return `agent:${rec.name} (${rec.agent})${model}`;
|
|
168
|
+
}
|