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/index.ts
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-famulus extension entry point (design doc §4).
|
|
3
|
+
*
|
|
4
|
+
* M1: bash override (auto-backgrounding) + task_* tools + manager client.
|
|
5
|
+
* M2: NotifyCenter + monitor tool.
|
|
6
|
+
* M3: subagent tool (InProcessRunner + tasks/chain + budget-to-async) + fleet widget.
|
|
7
|
+
*/
|
|
8
|
+
import { applyBehaviorGuidelines } from "./behavior-guidelines";
|
|
9
|
+
import { realClock } from "./clock";
|
|
10
|
+
import { ExitNotifyGate } from "./exit-notify-gate";
|
|
11
|
+
import { ExitWatchdog } from "./exit-watchdog";
|
|
12
|
+
import { createExtensionEventLog } from "./events";
|
|
13
|
+
import { readFileTail } from "./file-tail";
|
|
14
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { resolveAgent as resolveAgentDef } from "./agents/definition";
|
|
19
|
+
import { createAgentLoader, type AgentLoader } from "./agents/loader";
|
|
20
|
+
import { createBashOverride } from "./bash-override";
|
|
21
|
+
import { createComms, type CommsWithOrigin } from "./comms/comms";
|
|
22
|
+
import { createRegistryCommsHost } from "./comms/registry-host";
|
|
23
|
+
import { createAgentMessageTool, createContactSupervisorTool } from "./comms/tools";
|
|
24
|
+
import { registerReplyCommand } from "./comms/reply-command";
|
|
25
|
+
import { describeManagerSearch, getFamulusHome, loadConfig, resolveManagerPath, resolveSubagentConfig } from "./config";
|
|
26
|
+
import type { TaskExitInfo } from "./format";
|
|
27
|
+
import { ManagerClient, type ManagerEvent, type TaskRecord } from "./manager-client";
|
|
28
|
+
import { createMonitorTool, exitEventFromRecord, MonitorRegistry } from "./monitor";
|
|
29
|
+
import { NotifyCenter } from "./notify";
|
|
30
|
+
import { createChildBashTool } from "./subagent/child-bash";
|
|
31
|
+
import {
|
|
32
|
+
agentEndReason,
|
|
33
|
+
headOf,
|
|
34
|
+
tailOf,
|
|
35
|
+
writeAgentChildRecord,
|
|
36
|
+
type AgentChildRecord,
|
|
37
|
+
} from "./subagent/agent-records";
|
|
38
|
+
import { TranscriptWriter } from "./subagent/transcript";
|
|
39
|
+
import { FleetWidget } from "./subagent/fleet-widget";
|
|
40
|
+
import { WorkIndex, type WorkItem } from "./work-index";
|
|
41
|
+
import { createPiSessionFn, modelCandidates } from "./subagent/pi-runtime";
|
|
42
|
+
import { SubagentRegistry } from "./subagent/registry";
|
|
43
|
+
import { InProcessRunner } from "./subagent/runner";
|
|
44
|
+
import { createSubagentTool } from "./subagent/tool";
|
|
45
|
+
import { createTaskListTool, createTaskOutputTool, createTaskStopTool } from "./task-tools";
|
|
46
|
+
import { registerFamulusMessageRenderers } from "./tui/message-renderers";
|
|
47
|
+
import { registerTasksCommand } from "./tui/tasks-command";
|
|
48
|
+
import { stderrPathFor } from "./tui/task-output-paths";
|
|
49
|
+
import { shellWakeTitle } from "./wake";
|
|
50
|
+
|
|
51
|
+
/** Read only the tail of a task output file for the notification preview (≤ maxChars). */
|
|
52
|
+
export function readPreview(outputPath: string | undefined, maxChars: number): string {
|
|
53
|
+
if (!outputPath) return "";
|
|
54
|
+
const tail = readFileTail(outputPath, Math.max(maxChars, maxChars * 4));
|
|
55
|
+
if (!tail || !tail.text) return "";
|
|
56
|
+
return tail.text.length <= maxChars ? tail.text : tail.text.slice(-maxChars);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toExitStatus(event: ManagerEvent): TaskExitInfo["status"] {
|
|
60
|
+
// The manager died without shutting down; its runner took the task down.
|
|
61
|
+
if (event.end_reason === "manager-crash") return "orphaned";
|
|
62
|
+
if (event.signal) return "killed";
|
|
63
|
+
if (event.exit_code === 0) return "completed";
|
|
64
|
+
return "failed";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default function (pi: ExtensionAPI): void {
|
|
68
|
+
const home = getFamulusHome();
|
|
69
|
+
const config = loadConfig(home);
|
|
70
|
+
const managerPath = resolveManagerPath(config, home);
|
|
71
|
+
const clock = realClock;
|
|
72
|
+
|
|
73
|
+
// Claude-style transcript pills for notifications (TUI only; no-ops elsewhere).
|
|
74
|
+
registerFamulusMessageRenderers(pi);
|
|
75
|
+
|
|
76
|
+
let ctx: ExtensionContext | null = null;
|
|
77
|
+
let client: ManagerClient | null = null;
|
|
78
|
+
let notifyCenter: NotifyCenter | null = null;
|
|
79
|
+
let monitorRegistry: MonitorRegistry | null = null;
|
|
80
|
+
let subagentRegistry: SubagentRegistry | null = null;
|
|
81
|
+
let fleetWidget: FleetWidget | null = null;
|
|
82
|
+
let agentLoader: AgentLoader | null = null;
|
|
83
|
+
/** task_id -> metadata, for notifications and the original manager task start time. */
|
|
84
|
+
const taskMeta = new Map<string, { kind: string; command: string; cwd?: string; startedAt?: number }>();
|
|
85
|
+
/**
|
|
86
|
+
* task_ids whose task_exited should wake the parent via <pi-famulus-wake kind="task">.
|
|
87
|
+
* Parent bash only adds ids when it actually backgrounded the command.
|
|
88
|
+
* Child-bash (sync wait) must not — otherwise every subagent shell completion
|
|
89
|
+
* is mis-labeled as a parent "Background command" wake (§4.2 / §4.6).
|
|
90
|
+
*/
|
|
91
|
+
const notifyOnExit = new Set<string>();
|
|
92
|
+
const exitGate = new ExitNotifyGate<ManagerEvent>({ clock });
|
|
93
|
+
const workIndex = new WorkIndex({ clock });
|
|
94
|
+
const patchExited = (taskId: string, event: ManagerEvent): void => {
|
|
95
|
+
workIndex.patch(taskId, {
|
|
96
|
+
status: toExitStatus(event),
|
|
97
|
+
endedAt: clock.now(),
|
|
98
|
+
exitCode: event.exit_code ?? null,
|
|
99
|
+
...(event.signal ? { signal: event.signal } : {}),
|
|
100
|
+
...(event.end_reason ? { endReason: event.end_reason } : {}),
|
|
101
|
+
...(event.output_path
|
|
102
|
+
? { outputPath: event.output_path, stderrPath: stderrPathFor(event.output_path) }
|
|
103
|
+
: {}),
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Is the daemon process alive? The pid file can be stale (pid reuse), so
|
|
108
|
+
* this errs towards "alive": a false positive only delays the settle, a
|
|
109
|
+
* false negative would wake the agent while its command still runs.
|
|
110
|
+
*/
|
|
111
|
+
const isDaemonAlive = async (): Promise<boolean> => {
|
|
112
|
+
try {
|
|
113
|
+
const pf = JSON.parse(await readFile(join(home, "manager.pid"), "utf8")) as { pid?: unknown };
|
|
114
|
+
if (typeof pf.pid !== "number" || pf.pid <= 0) return false;
|
|
115
|
+
try {
|
|
116
|
+
process.kill(pf.pid, 0);
|
|
117
|
+
return true;
|
|
118
|
+
} catch (e) {
|
|
119
|
+
return (e as NodeJS.ErrnoException).code === "EPERM"; // alive, not ours
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
return false; // no pid file / unparseable: no daemon
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* This session's task records, straight from disk: the fallback for when
|
|
127
|
+
* the manager is unavailable. A record still "running" whose daemon is
|
|
128
|
+
* dead is what the next daemon start marks orphaned (manager-crash), so
|
|
129
|
+
* it settles here; a "running" record with a live daemon is left alone.
|
|
130
|
+
*/
|
|
131
|
+
const loadDiskTasks = async (): Promise<TaskRecord[] | null> => {
|
|
132
|
+
const sid = ctx?.sessionManager.getSessionId();
|
|
133
|
+
if (!sid) return null;
|
|
134
|
+
const dir = join(home, "sessions", sid, "tasks");
|
|
135
|
+
let files: string[];
|
|
136
|
+
try {
|
|
137
|
+
files = await readdir(dir);
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const daemonAlive = await isDaemonAlive();
|
|
142
|
+
const out: TaskRecord[] = [];
|
|
143
|
+
for (const f of files) {
|
|
144
|
+
if (!f.endsWith(".json")) continue;
|
|
145
|
+
try {
|
|
146
|
+
const rec = JSON.parse(await readFile(join(dir, f), "utf8")) as TaskRecord;
|
|
147
|
+
// The persisted output_size lags the output file; the file is the truth.
|
|
148
|
+
try {
|
|
149
|
+
rec.output_size = Math.max(rec.output_size, (await stat(rec.output_path)).size);
|
|
150
|
+
} catch { /* output file gone */ }
|
|
151
|
+
if (rec.status === "running" && !daemonAlive) {
|
|
152
|
+
rec.status = "orphaned";
|
|
153
|
+
rec.end_reason = "manager-crash";
|
|
154
|
+
rec.ended_at ??= clock.now();
|
|
155
|
+
}
|
|
156
|
+
out.push(rec);
|
|
157
|
+
} catch { /* not a record */ }
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Settle everything the manager reports as ended that we still show live:
|
|
163
|
+
* monitors in the registry and shell/monitor rows in the index. Runs when
|
|
164
|
+
* the user or model looks (task_list, /tasks), after task_stop, after a
|
|
165
|
+
* monitor timeout, and after a reconnect. A backgrounded command the model
|
|
166
|
+
* is waiting on gets its exit wake here: after a manager crash no
|
|
167
|
+
* task_exited ever comes (the task ended orphaned), and without the wake
|
|
168
|
+
* the model would wait forever.
|
|
169
|
+
*/
|
|
170
|
+
const syncWithManager = async (): Promise<void> => {
|
|
171
|
+
const c = client;
|
|
172
|
+
let tasks: TaskRecord[];
|
|
173
|
+
if (c && c.isAvailable()) {
|
|
174
|
+
try {
|
|
175
|
+
tasks = await c.list(true);
|
|
176
|
+
} catch {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
// Unavailable (it crashed and every respawn attempt failed): settle
|
|
181
|
+
// from the records on disk instead of letting the model wait forever.
|
|
182
|
+
const disk = await loadDiskTasks();
|
|
183
|
+
if (!disk) return;
|
|
184
|
+
tasks = disk;
|
|
185
|
+
}
|
|
186
|
+
monitorRegistry?.reconcile(tasks);
|
|
187
|
+
for (const task of workIndex.staleLive(tasks)) {
|
|
188
|
+
const event = exitEventFromRecord(task);
|
|
189
|
+
// Route through the gate: if the real task_exited is processed after
|
|
190
|
+
// this (a stop-triggered exit written after the list response, a
|
|
191
|
+
// reconnect replay), the consumed mark stashes it instead of waking
|
|
192
|
+
// the agent a second time.
|
|
193
|
+
if (notifyOnExit.has(task.task_id) && exitGate.onExit(task.task_id, event, false) === "notify") {
|
|
194
|
+
deliverExit(task.task_id, event);
|
|
195
|
+
} else {
|
|
196
|
+
patchExited(task.task_id, event);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
// Settle on our own while a wake is pending: the agent was told to end its
|
|
201
|
+
// turn, so nothing else may look before the wake is due.
|
|
202
|
+
const exitWatchdog = new ExitWatchdog({
|
|
203
|
+
clock,
|
|
204
|
+
hasPending: () => notifyOnExit.size > 0,
|
|
205
|
+
tick: async () => {
|
|
206
|
+
await client?.ensureAvailable().catch(() => false);
|
|
207
|
+
await syncWithManager();
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
const eventLog = createExtensionEventLog(home, () => ctx?.sessionManager.getSessionId() ?? "", clock);
|
|
211
|
+
const logEvent = (type: string, fields?: Record<string, unknown>) => eventLog.write(type, fields);
|
|
212
|
+
|
|
213
|
+
const trackTask = (taskId: string, meta: { kind: string; command: string; cwd?: string }) => {
|
|
214
|
+
taskMeta.set(taskId, { ...meta, startedAt: taskMeta.get(taskId)?.startedAt });
|
|
215
|
+
};
|
|
216
|
+
const upsertBackgroundTask = (taskId: string, startedAt: number) => {
|
|
217
|
+
const meta = taskMeta.get(taskId);
|
|
218
|
+
workIndex.upsert({
|
|
219
|
+
id: taskId,
|
|
220
|
+
kind: "shell",
|
|
221
|
+
status: "running",
|
|
222
|
+
title: meta?.command?.replace(/\s+/g, " ").trim() || taskId,
|
|
223
|
+
command: meta?.command,
|
|
224
|
+
cwd: meta?.cwd,
|
|
225
|
+
startedAt,
|
|
226
|
+
countsAsWorker: true,
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
const deliverExit = (taskId: string, event: ManagerEvent) => {
|
|
230
|
+
notifyOnExit.delete(taskId);
|
|
231
|
+
const meta = taskMeta.get(taskId);
|
|
232
|
+
taskMeta.delete(taskId);
|
|
233
|
+
workIndex.patch(taskId, {
|
|
234
|
+
status: toExitStatus(event),
|
|
235
|
+
endedAt: clock.now(),
|
|
236
|
+
exitCode: event.exit_code ?? null,
|
|
237
|
+
...(event.signal ? { signal: event.signal } : {}),
|
|
238
|
+
...(event.end_reason ? { endReason: event.end_reason } : {}),
|
|
239
|
+
...(event.output_path
|
|
240
|
+
? { outputPath: event.output_path, stderrPath: stderrPathFor(event.output_path) }
|
|
241
|
+
: {}),
|
|
242
|
+
});
|
|
243
|
+
notifyCenter?.notifyTaskExit({
|
|
244
|
+
taskId,
|
|
245
|
+
kind: meta?.kind ?? event.kind ?? "shell",
|
|
246
|
+
command: meta?.command ?? event.command ?? "",
|
|
247
|
+
status: toExitStatus(event),
|
|
248
|
+
exitCode: event.exit_code ?? null,
|
|
249
|
+
...(typeof event.signal === "string" && event.signal ? { signal: event.signal } : {}),
|
|
250
|
+
durationMs: event.duration_ms ?? 0,
|
|
251
|
+
outputPath: event.output_path ?? "",
|
|
252
|
+
preview: readPreview(event.output_path, 4000),
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const markNotifyOnExit = (taskId: string) => {
|
|
257
|
+
notifyOnExit.add(taskId);
|
|
258
|
+
exitWatchdog.arm();
|
|
259
|
+
void client?.markBackground(taskId).catch(() => {});
|
|
260
|
+
const prior = exitGate.mark(taskId);
|
|
261
|
+
if (prior) {
|
|
262
|
+
deliverExit(taskId, prior);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const startedAt = taskMeta.get(taskId)?.startedAt;
|
|
266
|
+
if (startedAt !== undefined) {
|
|
267
|
+
upsertBackgroundTask(taskId, startedAt);
|
|
268
|
+
} else {
|
|
269
|
+
// If task_started was missed (e.g. reconnect), recover the daemon's start
|
|
270
|
+
// time rather than making this task appear younger than it is.
|
|
271
|
+
void client?.list(true).then((tasks) => {
|
|
272
|
+
const task = tasks.find((candidate) => candidate.task_id === taskId);
|
|
273
|
+
const recoveredStart = taskMeta.get(taskId)?.startedAt ?? task?.started_at;
|
|
274
|
+
if (recoveredStart !== undefined && notifyOnExit.has(taskId)) {
|
|
275
|
+
const current = taskMeta.get(taskId) ?? { kind: "shell", command: task?.command ?? "" };
|
|
276
|
+
taskMeta.set(taskId, { ...current, startedAt: recoveredStart });
|
|
277
|
+
upsertBackgroundTask(taskId, recoveredStart);
|
|
278
|
+
}
|
|
279
|
+
}).catch(() => {});
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const sessionEnv = (c: ExtensionContext): Record<string, string> => ({
|
|
284
|
+
PI_SESSION_ID: c.sessionManager.getSessionId(),
|
|
285
|
+
PI_SESSION_FILE: c.sessionManager.getSessionFile() ?? "",
|
|
286
|
+
PI_PROVIDER: c.model?.provider ?? "",
|
|
287
|
+
PI_MODEL: c.model?.id ?? "",
|
|
288
|
+
PI_REASONING_LEVEL: c.thinkingLevel ?? "",
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const deps = {
|
|
292
|
+
getClient: () => client,
|
|
293
|
+
config,
|
|
294
|
+
home,
|
|
295
|
+
sessionId: () => ctx?.sessionManager.getSessionId() ?? "",
|
|
296
|
+
sessionEnv,
|
|
297
|
+
trackTask,
|
|
298
|
+
markNotifyOnExit,
|
|
299
|
+
clock,
|
|
300
|
+
getRegistry: () => subagentRegistry,
|
|
301
|
+
getIndex: () => workIndex,
|
|
302
|
+
syncWithManager: () => syncWithManager(),
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
monitorRegistry = new MonitorRegistry({
|
|
306
|
+
getClient: () => client,
|
|
307
|
+
sessionEnv,
|
|
308
|
+
getNotifyCenter: () => notifyCenter,
|
|
309
|
+
trackTask,
|
|
310
|
+
clock,
|
|
311
|
+
logEvent,
|
|
312
|
+
toast: (message, type) => {
|
|
313
|
+
if (ctx?.hasUI) ctx.ui.notify(message, type);
|
|
314
|
+
},
|
|
315
|
+
onExited: (taskId, event) => patchExited(taskId, event),
|
|
316
|
+
afterStop: () => void syncWithManager(),
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
pi.registerTool(createBashOverride(deps));
|
|
320
|
+
pi.registerTool(createTaskListTool({ ...deps, getIndex: () => workIndex }));
|
|
321
|
+
pi.registerTool(createTaskOutputTool({ ...deps, getIndex: () => workIndex }));
|
|
322
|
+
pi.registerTool(
|
|
323
|
+
createTaskStopTool({
|
|
324
|
+
...deps,
|
|
325
|
+
getIndex: () => workIndex,
|
|
326
|
+
noteStopRequested: (id) => monitorRegistry?.noteStopRequested(id),
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
pi.registerTool(createMonitorTool(monitorRegistry));
|
|
330
|
+
registerTasksCommand(pi, {
|
|
331
|
+
getRegistry: () => subagentRegistry,
|
|
332
|
+
getIndex: () => workIndex,
|
|
333
|
+
getClient: () => client,
|
|
334
|
+
home,
|
|
335
|
+
sessionId: () => ctx?.sessionManager.getSessionId() ?? "",
|
|
336
|
+
clock,
|
|
337
|
+
syncWithManager: deps.syncWithManager,
|
|
338
|
+
});
|
|
339
|
+
monitorRegistry.onChange(() => {
|
|
340
|
+
for (const mon of monitorRegistry.listActive()) {
|
|
341
|
+
const existing = workIndex.get(mon.taskId);
|
|
342
|
+
// Never revive a row that already ended (an exit can beat registration).
|
|
343
|
+
if (existing && existing.status !== "running" && existing.status !== "pending") continue;
|
|
344
|
+
workIndex.upsert({
|
|
345
|
+
id: mon.taskId,
|
|
346
|
+
kind: "monitor",
|
|
347
|
+
status: "running",
|
|
348
|
+
title: mon.description,
|
|
349
|
+
command: taskMeta.get(mon.taskId)?.command,
|
|
350
|
+
cwd: taskMeta.get(mon.taskId)?.cwd,
|
|
351
|
+
startedAt: existing?.startedAt ?? mon.startedAt,
|
|
352
|
+
outputPath: existing?.outputPath,
|
|
353
|
+
stderrPath: existing?.stderrPath,
|
|
354
|
+
countsAsWorker: false,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// M3: subagent tool. The registry/runner are (re)built on every session_start;
|
|
360
|
+
// the tool resolves them lazily through getters.
|
|
361
|
+
const subagentConfig = resolveSubagentConfig(config);
|
|
362
|
+
// M4: comms host bridges the registry and the NotifyCenter. A single Comms
|
|
363
|
+
// instance lives at extension scope (mailbox is per-run namespaced; pending
|
|
364
|
+
// waiters are resolved via dispose() on session_shutdown).
|
|
365
|
+
const commsHost = createRegistryCommsHost({
|
|
366
|
+
getRegistry: () => subagentRegistry,
|
|
367
|
+
getNotifyCenter: () => notifyCenter,
|
|
368
|
+
});
|
|
369
|
+
const comms: CommsWithOrigin = createComms(commsHost, {
|
|
370
|
+
decisionTimeoutMs: subagentConfig.decisionTimeoutMs,
|
|
371
|
+
clock,
|
|
372
|
+
logEvent,
|
|
373
|
+
});
|
|
374
|
+
registerReplyCommand(pi, comms);
|
|
375
|
+
pi.registerTool(
|
|
376
|
+
createSubagentTool({
|
|
377
|
+
getRegistry: () => subagentRegistry,
|
|
378
|
+
getNotifyCenter: () => notifyCenter,
|
|
379
|
+
getIndex: () => workIndex,
|
|
380
|
+
budgetMs: () => subagentConfig.budgetMs,
|
|
381
|
+
defaultTimeoutMs: subagentConfig.timeoutMs,
|
|
382
|
+
defaultConcurrency: subagentConfig.concurrency,
|
|
383
|
+
clock,
|
|
384
|
+
resolveAgent: (name) => {
|
|
385
|
+
const loader = agentLoader;
|
|
386
|
+
if (!loader) return resolveAgentDef([], name); // builtins-only fallback
|
|
387
|
+
return resolveAgentDef(loader.reload().definitions, name);
|
|
388
|
+
},
|
|
389
|
+
listModels: () => {
|
|
390
|
+
const current = ctx?.model;
|
|
391
|
+
return modelCandidates({
|
|
392
|
+
getModelRegistry: () => ctx?.modelRegistry ?? null,
|
|
393
|
+
getScopedModels: () => ctx?.scopedModels ?? [],
|
|
394
|
+
}).map((c) => ({
|
|
395
|
+
...c,
|
|
396
|
+
current: current !== undefined && c.provider === current.provider && c.id === current.id,
|
|
397
|
+
}));
|
|
398
|
+
},
|
|
399
|
+
}),
|
|
400
|
+
);
|
|
401
|
+
// M4: parent-side agent_message tool (child-side variant is injected into
|
|
402
|
+
// each child session via customTools, see below).
|
|
403
|
+
pi.registerTool(createAgentMessageTool(comms, { kind: "parent" }, commsHost, clock));
|
|
404
|
+
|
|
405
|
+
pi.on("session_start", async (_event, startCtx) => {
|
|
406
|
+
ctx = startCtx;
|
|
407
|
+
const base = (
|
|
408
|
+
startCtx as { getSystemPromptOptions?: () => { sections?: Record<string, string> } }
|
|
409
|
+
).getSystemPromptOptions?.();
|
|
410
|
+
if (base) applyBehaviorGuidelines(base);
|
|
411
|
+
notifyCenter?.dispose();
|
|
412
|
+
notifyCenter = new NotifyCenter({
|
|
413
|
+
sendMessage: (msg, opts) => pi.sendMessage(msg, opts),
|
|
414
|
+
isIdle: () => ctx?.isIdle() ?? true,
|
|
415
|
+
clock,
|
|
416
|
+
logEvent,
|
|
417
|
+
listStillRunning: () =>
|
|
418
|
+
[...notifyOnExit].map((id) => {
|
|
419
|
+
const command = taskMeta.get(id)?.command;
|
|
420
|
+
const title = command ? shellWakeTitle(command) || id : id;
|
|
421
|
+
return { id, title };
|
|
422
|
+
}),
|
|
423
|
+
});
|
|
424
|
+
fleetWidget?.dispose();
|
|
425
|
+
fleetWidget = null;
|
|
426
|
+
subagentRegistry?.disposeAll();
|
|
427
|
+
subagentRegistry = null;
|
|
428
|
+
|
|
429
|
+
client = new ManagerClient({
|
|
430
|
+
home,
|
|
431
|
+
sessionId: startCtx.sessionManager.getSessionId(),
|
|
432
|
+
managerPath,
|
|
433
|
+
cwd: startCtx.cwd,
|
|
434
|
+
clock,
|
|
435
|
+
log: () => {}, // keep quiet; degradation is surfaced via tools
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
client.onEvent((event) => {
|
|
439
|
+
if (event.event === "task_started" && event.task_id) {
|
|
440
|
+
const prior = taskMeta.get(event.task_id);
|
|
441
|
+
taskMeta.set(event.task_id, {
|
|
442
|
+
kind: prior?.kind ?? event.kind ?? "shell",
|
|
443
|
+
command: prior?.command ?? event.command ?? "",
|
|
444
|
+
...(typeof event.ts === "number"
|
|
445
|
+
? { startedAt: event.ts }
|
|
446
|
+
: prior?.startedAt !== undefined
|
|
447
|
+
? { startedAt: prior.startedAt }
|
|
448
|
+
: {}),
|
|
449
|
+
});
|
|
450
|
+
if (notifyOnExit.has(event.task_id) && typeof event.ts === "number") {
|
|
451
|
+
upsertBackgroundTask(event.task_id, event.ts);
|
|
452
|
+
}
|
|
453
|
+
if (event.kind === "monitor" && !workIndex.get(event.task_id)) {
|
|
454
|
+
workIndex.upsert({
|
|
455
|
+
id: event.task_id,
|
|
456
|
+
kind: "monitor",
|
|
457
|
+
status: "running",
|
|
458
|
+
title: event.command || event.task_id,
|
|
459
|
+
command: event.command,
|
|
460
|
+
cwd: taskMeta.get(event.task_id)?.cwd,
|
|
461
|
+
startedAt: clock.now(),
|
|
462
|
+
countsAsWorker: false,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (event.event === "output" && event.task_id && typeof event.chunk === "string") {
|
|
468
|
+
monitorRegistry?.handleOutput(event.task_id, event.chunk, event.next_cursor);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (event.event === "task_exited" && event.task_id) {
|
|
472
|
+
// A known monitor closes (and patches the index via onExited); an
|
|
473
|
+
// unknown id is buffered in case a monitor start is about to claim it.
|
|
474
|
+
if (monitorRegistry?.handleExit(event.task_id, event)) return;
|
|
475
|
+
patchExited(event.task_id, event);
|
|
476
|
+
// Exit may share a socket read with wait done:false, before bash marks
|
|
477
|
+
// the id. Stash and fire on the late mark. Sync waits never mark.
|
|
478
|
+
if (exitGate.onExit(event.task_id, event, false) !== "notify") return;
|
|
479
|
+
deliverExit(event.task_id, event);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
client.onReconnect(() => {
|
|
483
|
+
void monitorRegistry?.rewatchAll().then(() => syncWithManager());
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// M3: subagent registry + in-process runner + fleet widget. The runner's
|
|
487
|
+
// per-generation admission goes through the registry (global concurrency
|
|
488
|
+
// cap); the session factory resolves models/cwd lazily from ctx.
|
|
489
|
+
const registry = new SubagentRegistry({
|
|
490
|
+
maxConcurrentChildren: subagentConfig.maxConcurrentChildren,
|
|
491
|
+
spawnBudgetPerHour: subagentConfig.spawnBudgetPerHour,
|
|
492
|
+
clock,
|
|
493
|
+
});
|
|
494
|
+
// M5: agent definitions, reloaded lazily (mtime-cached) per subagent call.
|
|
495
|
+
agentLoader = createAgentLoader({
|
|
496
|
+
userDir: join(homedir(), ".pi", "agent", "agents"),
|
|
497
|
+
projectDir: join(startCtx.cwd, ".pi", "agents"),
|
|
498
|
+
});
|
|
499
|
+
const agentLoadErrors = agentLoader.reload().errors;
|
|
500
|
+
if (agentLoadErrors.length > 0 && startCtx.hasUI) {
|
|
501
|
+
startCtx.ui.notify(
|
|
502
|
+
`pi-famulus: skipped ${agentLoadErrors.length} invalid agent definition(s): ` +
|
|
503
|
+
agentLoadErrors.map((e) => `${e.path} (${e.error})`).join("; "),
|
|
504
|
+
"warning",
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
const createSession = createPiSessionFn({
|
|
508
|
+
getModelRegistry: () => ctx?.modelRegistry ?? null,
|
|
509
|
+
getModelRuntime: () => {
|
|
510
|
+
const registry = ctx?.modelRegistry as { runtime?: unknown } | null | undefined;
|
|
511
|
+
return registry?.runtime;
|
|
512
|
+
},
|
|
513
|
+
getParentModel: () => ctx?.model,
|
|
514
|
+
getParentThinkingLevel: () => ctx?.thinkingLevel,
|
|
515
|
+
getScopedModels: () => ctx?.scopedModels ?? [],
|
|
516
|
+
getCwd: () => ctx?.cwd ?? process.cwd(),
|
|
517
|
+
customTools: (req) => {
|
|
518
|
+
const tools: Array<ToolDefinition<any, any, any>> = [
|
|
519
|
+
// M4: every child can reach the supervisor and its siblings.
|
|
520
|
+
createContactSupervisorTool(comms, req.childId),
|
|
521
|
+
createAgentMessageTool(comms, { kind: "child", childId: req.childId, runId: req.runId }, commsHost, clock),
|
|
522
|
+
];
|
|
523
|
+
// The no-background bash variant replaces the built-in bash inside
|
|
524
|
+
// child sessions (custom tools override builtins by name).
|
|
525
|
+
if (req.agent.tools.includes("bash")) {
|
|
526
|
+
tools.push(
|
|
527
|
+
createChildBashTool({
|
|
528
|
+
getClient: () => client,
|
|
529
|
+
home,
|
|
530
|
+
sessionId: () => ctx?.sessionManager.getSessionId() ?? "",
|
|
531
|
+
sessionEnv: () => (ctx ? sessionEnv(ctx) : {}),
|
|
532
|
+
trackTask,
|
|
533
|
+
childId: req.childId,
|
|
534
|
+
runId: req.runId,
|
|
535
|
+
clock,
|
|
536
|
+
}),
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
return tools;
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
const transcripts = new TranscriptWriter(home, clock);
|
|
543
|
+
const syncTranscript = (childId: string): string | undefined => {
|
|
544
|
+
const handle = registry.handle(childId);
|
|
545
|
+
if (!handle) return undefined;
|
|
546
|
+
return transcripts.sync(startCtx.sessionManager.getSessionId(), childId, handle.conversation());
|
|
547
|
+
};
|
|
548
|
+
const runner = new InProcessRunner({
|
|
549
|
+
createSession,
|
|
550
|
+
clock,
|
|
551
|
+
stallMs: subagentConfig.stallMs,
|
|
552
|
+
stallRetries: subagentConfig.stallRetries,
|
|
553
|
+
stallRetryDelayMs: subagentConfig.stallRetryDelayMs,
|
|
554
|
+
acquire: (req) => registry.admitChild(req.childId),
|
|
555
|
+
onActivity: (childId) => {
|
|
556
|
+
syncTranscript(childId);
|
|
557
|
+
},
|
|
558
|
+
onStall: (childId, attempt) => {
|
|
559
|
+
// One event per stall detection: an auto-resume follows unless the
|
|
560
|
+
// retry budget (stallRetries) is already spent.
|
|
561
|
+
logEvent("agent.stall", { child_id: childId, attempt });
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
registry.setRunner(runner);
|
|
565
|
+
subagentRegistry = registry;
|
|
566
|
+
// Persist child records so `pi-famulus ls` / task_list can see in-process agents.
|
|
567
|
+
const sessionIdForAgents = () => startCtx.sessionManager.getSessionId();
|
|
568
|
+
const previousAgentStatus = new Map<string, string>();
|
|
569
|
+
registry.onTransition((run) => {
|
|
570
|
+
const sid = sessionIdForAgents();
|
|
571
|
+
for (const c of run.children) {
|
|
572
|
+
const previousStatus = previousAgentStatus.get(c.childId);
|
|
573
|
+
if (c.status === "running" && previousStatus !== "running") {
|
|
574
|
+
logEvent("agent.start", {
|
|
575
|
+
child_id: c.childId,
|
|
576
|
+
run_id: run.runId,
|
|
577
|
+
name: c.name,
|
|
578
|
+
agent: c.agent,
|
|
579
|
+
...(c.model ? { model: c.model } : {}),
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
if (["completed", "failed", "interrupted"].includes(c.status) && previousStatus !== c.status) {
|
|
583
|
+
const error = c.result?.error;
|
|
584
|
+
if (error === "timeout") logEvent("agent.timeout", { child_id: c.childId });
|
|
585
|
+
logEvent("agent.settle", {
|
|
586
|
+
child_id: c.childId,
|
|
587
|
+
status: c.status,
|
|
588
|
+
...(error ? { error } : {}),
|
|
589
|
+
...(c.result?.stalls ? { stalls: c.result.stalls } : {}),
|
|
590
|
+
duration_ms: c.result?.durationMs ?? Math.max(0, clock.now() - c.startedAt),
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
previousAgentStatus.set(c.childId, c.status);
|
|
594
|
+
const rec: AgentChildRecord = {
|
|
595
|
+
v: 1,
|
|
596
|
+
kind: "agent",
|
|
597
|
+
child_id: c.childId,
|
|
598
|
+
run_id: run.runId,
|
|
599
|
+
session_id: sid,
|
|
600
|
+
name: c.name,
|
|
601
|
+
agent: c.agent,
|
|
602
|
+
...(c.model !== undefined ? { model: c.model } : {}),
|
|
603
|
+
status: c.status,
|
|
604
|
+
started_at: c.startedAt,
|
|
605
|
+
...(c.endedAt !== undefined ? { ended_at: c.endedAt } : {}),
|
|
606
|
+
...(c.result?.error ? { error: c.result.error } : {}),
|
|
607
|
+
...(c.result?.attempts !== undefined && c.result.attempts > 1
|
|
608
|
+
? { attempts: c.result.attempts }
|
|
609
|
+
: {}),
|
|
610
|
+
...(c.result?.stalls ? { stalls: c.result.stalls } : {}),
|
|
611
|
+
...(c.prompt !== undefined ? { prompt_head: headOf(c.prompt) } : {}),
|
|
612
|
+
...(c.result?.text ? { result_tail: tailOf(c.result.text) } : {}),
|
|
613
|
+
};
|
|
614
|
+
const endReason = agentEndReason(c.status, c.result);
|
|
615
|
+
if (endReason) rec.end_reason = endReason;
|
|
616
|
+
const transcript = syncTranscript(c.childId);
|
|
617
|
+
if (transcript) {
|
|
618
|
+
rec.transcript = transcript;
|
|
619
|
+
rec.tool_calls = transcripts.toolCallCount(c.childId);
|
|
620
|
+
}
|
|
621
|
+
writeAgentChildRecord(home, rec);
|
|
622
|
+
workIndex.upsert({
|
|
623
|
+
id: c.childId,
|
|
624
|
+
kind: "agent",
|
|
625
|
+
status: c.status,
|
|
626
|
+
title: `${c.name} (${c.agent})${c.model ? ` ${c.model}` : ""}`,
|
|
627
|
+
startedAt: c.startedAt,
|
|
628
|
+
...(c.endedAt !== undefined ? { endedAt: c.endedAt } : {}),
|
|
629
|
+
countsAsWorker: false,
|
|
630
|
+
runId: run.runId,
|
|
631
|
+
name: c.name,
|
|
632
|
+
agent: c.agent,
|
|
633
|
+
...(c.model !== undefined ? { model: c.model } : {}),
|
|
634
|
+
cwd: startCtx.cwd,
|
|
635
|
+
...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
|
|
636
|
+
...(c.preamble !== undefined ? { preamble: c.preamble } : {}),
|
|
637
|
+
...(c.result?.text ? { text: c.result.text } : {}),
|
|
638
|
+
...(c.result?.error ? { error: c.result.error } : {}),
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
if (startCtx.hasUI) {
|
|
643
|
+
fleetWidget = new FleetWidget({
|
|
644
|
+
index: workIndex,
|
|
645
|
+
getUi: () => (ctx?.hasUI ? (ctx.ui as never) : null),
|
|
646
|
+
clock,
|
|
647
|
+
});
|
|
648
|
+
fleetWidget.start();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Connect in the background: a cold manager spawn must not stall the
|
|
652
|
+
// first prompt. Tools call ensureAvailable() lazily before use.
|
|
653
|
+
const c = client;
|
|
654
|
+
void c
|
|
655
|
+
.connect()
|
|
656
|
+
.then((ok) => {
|
|
657
|
+
if (!ok && startCtx.hasUI) {
|
|
658
|
+
const detail = c.lastError();
|
|
659
|
+
const reason = detail ? ` (${detail})` : "";
|
|
660
|
+
const searched = managerPath ? `using ${managerPath}` : `looked in: ${describeManagerSearch(config, home)}`;
|
|
661
|
+
startCtx.ui.notify(
|
|
662
|
+
`pi-famulus unavailable${reason}; ${searched}. ` +
|
|
663
|
+
"Bash runs locally without auto-backgrounding, task_*/monitor are disabled, " +
|
|
664
|
+
"and subagents cannot run bash. Install it or set PI_FAMULUS_MANAGER_PATH (see README Install).",
|
|
665
|
+
"warning",
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
})
|
|
669
|
+
.catch(() => {});
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
pi.on("session_shutdown", async () => {
|
|
673
|
+
exitWatchdog.dispose();
|
|
674
|
+
fleetWidget?.dispose();
|
|
675
|
+
fleetWidget = null;
|
|
676
|
+
subagentRegistry?.disposeAll();
|
|
677
|
+
subagentRegistry = null;
|
|
678
|
+
agentLoader = null;
|
|
679
|
+
comms.dispose(); // resolve orphaned need_decision waiters
|
|
680
|
+
monitorRegistry?.disposeAll();
|
|
681
|
+
notifyCenter?.dispose();
|
|
682
|
+
const current = client;
|
|
683
|
+
client = null;
|
|
684
|
+
if (current) {
|
|
685
|
+
if (current.isAvailable()) {
|
|
686
|
+
await current.shutdownSession().catch(() => {});
|
|
687
|
+
}
|
|
688
|
+
await current.close();
|
|
689
|
+
}
|
|
690
|
+
ctx = null;
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
pi.on("agent_settled", async () => {
|
|
694
|
+
notifyCenter?.settled();
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
pi.on("before_agent_start", async (event) => {
|
|
698
|
+
applyBehaviorGuidelines(event.systemPromptOptions as { sections?: Record<string, string> });
|
|
699
|
+
});
|
|
700
|
+
}
|