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/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
+ }