pi-famulus 0.1.9 → 0.2.1
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/README.md +4 -4
- package/package.json +8 -8
- package/src/bounded-output.ts +17 -1
- package/src/child-mode.ts +214 -0
- package/src/comms/comms.ts +18 -13
- package/src/comms/registry-host.ts +10 -0
- package/src/comms/tools.ts +9 -2
- package/src/comms/types.ts +9 -3
- package/src/config.ts +16 -0
- package/src/format.ts +4 -0
- package/src/index.ts +391 -36
- package/src/manager-client.ts +147 -5
- package/src/notify.ts +1 -0
- package/src/pi-command.ts +123 -0
- package/src/prompts.generated.ts +8 -9
- package/src/subagent/agent-records.ts +51 -2
- package/src/subagent/hosted-session.ts +314 -0
- package/src/subagent/overrun.ts +11 -0
- package/src/subagent/pi-runtime.ts +4 -0
- package/src/subagent/registry.ts +78 -0
- package/src/subagent/runner.ts +94 -17
- package/src/subagent/tool.ts +79 -9
- package/src/subagent/types.ts +10 -1
- package/src/tui/attach-command.ts +164 -0
- package/src/work-index.ts +6 -0
package/src/index.ts
CHANGED
|
@@ -19,11 +19,12 @@ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-w
|
|
|
19
19
|
import { resolveAgent as resolveAgentDef } from "./agents/definition";
|
|
20
20
|
import { createAgentLoader, type AgentLoader } from "./agents/loader";
|
|
21
21
|
import { createBashOverride } from "./bash-override";
|
|
22
|
+
import { cleanupSessionSpillFiles } from "./bounded-output";
|
|
22
23
|
import { createComms, type CommsWithOrigin } from "./comms/comms";
|
|
23
24
|
import { createRegistryCommsHost } from "./comms/registry-host";
|
|
24
|
-
import { createAgentMessageTool
|
|
25
|
+
import { createAgentMessageTool } from "./comms/tools";
|
|
25
26
|
import { registerReplyCommand } from "./comms/reply-command";
|
|
26
|
-
import { describeManagerSearch, getFamulusHome, loadConfig, resolveManagerPath, resolveSubagentConfig } from "./config";
|
|
27
|
+
import { describeManagerSearch, getFamulusHome, loadConfig, resolveManagerPath, resolveSubagentConfig, taskOutputPath } from "./config";
|
|
27
28
|
import { ensureNativeManagerInstalled } from "./native-manager.js";
|
|
28
29
|
import type { TaskExitInfo } from "./format";
|
|
29
30
|
import { EXTENSION_VERSION, ManagerClient, type ManagerEvent, type TaskRecord } from "./manager-client";
|
|
@@ -31,11 +32,12 @@ import { createMonitorTool, exitEventFromRecord, MonitorRegistry } from "./monit
|
|
|
31
32
|
import { admitAgentChild, reregisterAgentLeases } from "./subagent/admission";
|
|
32
33
|
import { NotifyCenter } from "./notify";
|
|
33
34
|
import { onWakeMessageEnd, registryStatusLookup } from "./wake-delivery";
|
|
34
|
-
import { createChildBashTool } from "./subagent/child-bash";
|
|
35
35
|
import { ChildShellTracker, createOverrunNotifier } from "./subagent/overrun";
|
|
36
36
|
import {
|
|
37
37
|
agentEndReason,
|
|
38
38
|
headOf,
|
|
39
|
+
loadAgentChildRecords,
|
|
40
|
+
findAgentChildRecord,
|
|
39
41
|
tailOf,
|
|
40
42
|
updateAgentChildMetrics,
|
|
41
43
|
writeAgentChildRecord,
|
|
@@ -43,8 +45,13 @@ import {
|
|
|
43
45
|
} from "./subagent/agent-records";
|
|
44
46
|
import { TranscriptWriter } from "./subagent/transcript";
|
|
45
47
|
import { FleetWidget } from "./subagent/fleet-widget";
|
|
46
|
-
import { WorkIndex, type WorkItem } from "./work-index";
|
|
47
|
-
import {
|
|
48
|
+
import { printModeDropWarning, WORK_RETAIN_MS, WorkIndex, type WorkItem } from "./work-index";
|
|
49
|
+
import { fileURLToPath } from "node:url";
|
|
50
|
+
import { AGENT_WORK_KINDS, type AgentWorkKind } from "./subagent/types";
|
|
51
|
+
import { isFamulusChildSession, modelCandidates } from "./subagent/pi-runtime";
|
|
52
|
+
import { createHostedSessionFn } from "./subagent/hosted-session";
|
|
53
|
+
import { isHostedChildProcess, registerChildMode } from "./child-mode";
|
|
54
|
+
import { openAttachView, registerAttachCommand } from "./tui/attach-command";
|
|
48
55
|
import { SubagentRegistry } from "./subagent/registry";
|
|
49
56
|
import { InProcessRunner } from "./subagent/runner";
|
|
50
57
|
import { createSubagentTool } from "./subagent/tool";
|
|
@@ -56,6 +63,52 @@ import { registerTasksCommand } from "./tui/tasks-command";
|
|
|
56
63
|
import { stderrPathFor } from "./tui/task-output-paths";
|
|
57
64
|
import { shellWakeTitle } from "./wake";
|
|
58
65
|
|
|
66
|
+
async function promptIfHostedSession(
|
|
67
|
+
c: ManagerClient,
|
|
68
|
+
live: () => ExtensionContext | null,
|
|
69
|
+
query: { sessionFile?: string | null; sessionId?: string },
|
|
70
|
+
where: "start" | "switch",
|
|
71
|
+
): Promise<"takeover" | "stay" | "none"> {
|
|
72
|
+
if (!c.isAvailable() || c.protocolLevel() < 6) return "none";
|
|
73
|
+
const found = await c.hostedLookup({
|
|
74
|
+
...(query.sessionId ? { sessionId: query.sessionId } : {}),
|
|
75
|
+
...(query.sessionFile ? { sessionFile: query.sessionFile } : {}),
|
|
76
|
+
}).catch(() => null);
|
|
77
|
+
if (!found || found.residency !== "warm" || typeof found.child_id !== "string") return "none";
|
|
78
|
+
const uiCtx = live();
|
|
79
|
+
if (!uiCtx) return "none";
|
|
80
|
+
if (where === "start" && query.sessionId && uiCtx.sessionManager.getSessionId() !== query.sessionId) return "none";
|
|
81
|
+
if (!uiCtx.hasUI) {
|
|
82
|
+
uiCtx.ui.notify(
|
|
83
|
+
"This session file belongs to a warm hosted subagent. Use /attach from the parent, or open it in the TUI to take over.",
|
|
84
|
+
"warning",
|
|
85
|
+
);
|
|
86
|
+
return "stay";
|
|
87
|
+
}
|
|
88
|
+
const choice = await uiCtx.ui.select("This session is hosted by pi-famulus", [
|
|
89
|
+
"Attach to the hosted process",
|
|
90
|
+
"Take over",
|
|
91
|
+
"Cancel",
|
|
92
|
+
]);
|
|
93
|
+
if (choice === "Take over") {
|
|
94
|
+
try {
|
|
95
|
+
await c.agentTakeover(found.child_id);
|
|
96
|
+
return "takeover";
|
|
97
|
+
} catch (err) {
|
|
98
|
+
uiCtx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
99
|
+
return "stay";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (choice === "Attach to the hosted process") {
|
|
103
|
+
try {
|
|
104
|
+
await openAttachView(uiCtx, c, found.child_id);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
uiCtx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return "stay";
|
|
110
|
+
}
|
|
111
|
+
|
|
59
112
|
/** Read only the tail of a task output file for the notification preview (≤ maxChars). */
|
|
60
113
|
export function readPreview(outputPath: string | undefined, maxChars: number): string {
|
|
61
114
|
if (!outputPath) return "";
|
|
@@ -64,7 +117,8 @@ export function readPreview(outputPath: string | undefined, maxChars: number): s
|
|
|
64
117
|
return tail.text.length <= maxChars ? tail.text : tail.text.slice(-maxChars);
|
|
65
118
|
}
|
|
66
119
|
|
|
67
|
-
|
|
120
|
+
/** Map a manager exit event to the background-wake status. Exported for tests. */
|
|
121
|
+
export function toExitStatus(event: ManagerEvent): TaskExitInfo["status"] {
|
|
68
122
|
// The manager died without shutting down; its runner took the task down.
|
|
69
123
|
if (event.end_reason === "manager-crash") return "orphaned";
|
|
70
124
|
if (event.signal) return "killed";
|
|
@@ -77,6 +131,10 @@ interface FamulusRuntime {
|
|
|
77
131
|
}
|
|
78
132
|
|
|
79
133
|
export default function (pi: ExtensionAPI): void {
|
|
134
|
+
if (isHostedChildProcess()) {
|
|
135
|
+
registerChildMode(pi);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
80
138
|
if (isFamulusChildSession(pi)) return;
|
|
81
139
|
// A /reload parked the previous instance's runtime: adopt it so its shells,
|
|
82
140
|
// monitors and subagents keep running (see reload-handoff.ts). Another
|
|
@@ -166,6 +224,8 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
166
224
|
* is mis-labeled as a parent "Background command" wake (§4.2 / §4.6).
|
|
167
225
|
*/
|
|
168
226
|
const notifyOnExit = new Set<string>();
|
|
227
|
+
/** Sessions that already received the crash-recovery notice. */
|
|
228
|
+
const recoveredSessions = new Set<string>();
|
|
169
229
|
const exitGate = new ExitNotifyGate<ManagerEvent>({ clock });
|
|
170
230
|
const workIndex = new WorkIndex({ clock });
|
|
171
231
|
const patchExited = (taskId: string, event: ManagerEvent): void => {
|
|
@@ -235,6 +295,136 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
235
295
|
}
|
|
236
296
|
return out;
|
|
237
297
|
};
|
|
298
|
+
/**
|
|
299
|
+
* Parent-owned shells this process did not start (the previous pi died
|
|
300
|
+
* while the daemon stayed up). Running ones join the index and get an
|
|
301
|
+
* exit wake; recent finished ones are delivered the same way.
|
|
302
|
+
* manager-shutdown / manager-crash leftovers stay for the crash notice.
|
|
303
|
+
*/
|
|
304
|
+
const adoptParentShells = (tasks: TaskRecord[]): void => {
|
|
305
|
+
const sid = ctx?.sessionManager.getSessionId() ?? "";
|
|
306
|
+
const now = clock.now();
|
|
307
|
+
for (const task of tasks) {
|
|
308
|
+
if (task.kind !== "shell") continue;
|
|
309
|
+
if (task.origin?.via === "child-bash") continue;
|
|
310
|
+
if (sid && task.session_id && task.session_id !== sid) continue;
|
|
311
|
+
if (workIndex.get(task.task_id) || taskMeta.has(task.task_id)) continue;
|
|
312
|
+
const startedAt = task.started_at ?? now;
|
|
313
|
+
taskMeta.set(task.task_id, {
|
|
314
|
+
kind: "shell",
|
|
315
|
+
command: task.command ?? "",
|
|
316
|
+
cwd: task.cwd,
|
|
317
|
+
startedAt,
|
|
318
|
+
});
|
|
319
|
+
if (task.status === "running") {
|
|
320
|
+
upsertBackgroundTask(task.task_id, startedAt);
|
|
321
|
+
markNotifyOnExit(task.task_id);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (task.end_reason === "manager-shutdown" || task.end_reason === "manager-crash") continue;
|
|
325
|
+
if (task.ended_at != null && now - task.ended_at > WORK_RETAIN_MS) continue;
|
|
326
|
+
if (task.status !== "completed" && task.status !== "failed" && task.status !== "killed") continue;
|
|
327
|
+
upsertBackgroundTask(task.task_id, startedAt);
|
|
328
|
+
deliverExit(task.task_id, exitEventFromRecord(task));
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
/**
|
|
332
|
+
* A running disk record whose hosted process is gone: interrupt the live
|
|
333
|
+
* handle when this process still holds it (daemon crash), otherwise
|
|
334
|
+
* write the record. Warm children stay for resume.
|
|
335
|
+
*/
|
|
336
|
+
const settleDeadHostedChildren = async (
|
|
337
|
+
sessionId: string,
|
|
338
|
+
opts: { interruptLive?: boolean } = {},
|
|
339
|
+
): Promise<AgentChildRecord[]> => {
|
|
340
|
+
const manager = client;
|
|
341
|
+
const registry = subagentRegistry;
|
|
342
|
+
const center = notifyCenter;
|
|
343
|
+
const stale = loadAgentChildRecords(home, { sessionId, includeTerminal: false });
|
|
344
|
+
const lost: AgentChildRecord[] = [];
|
|
345
|
+
for (const rec of stale) {
|
|
346
|
+
if (rec.status !== "running" && rec.status !== "pending") continue;
|
|
347
|
+
const handle = registry?.handle(rec.child_id);
|
|
348
|
+
// A local admission queue has no hosted process to look up yet.
|
|
349
|
+
if (handle?.status() === "pending") continue;
|
|
350
|
+
let live = false;
|
|
351
|
+
if (manager?.isAvailable() && manager.protocolLevel() >= 6) {
|
|
352
|
+
const found =
|
|
353
|
+
await manager.hostedLookup({ sessionId: rec.child_id }).catch(() => null);
|
|
354
|
+
if (client !== manager || subagentRegistry !== registry || notifyCenter !== center) return [];
|
|
355
|
+
live = Boolean(found && (found.residency === "warm" || found.activity === "running" || found.activity === "starting"));
|
|
356
|
+
}
|
|
357
|
+
if (live) continue;
|
|
358
|
+
if (handle && (handle.status() === "running" || handle.status() === "pending")) {
|
|
359
|
+
if (!opts.interruptLive) continue;
|
|
360
|
+
await handle.interrupt().catch(() => {});
|
|
361
|
+
if (client !== manager || subagentRegistry !== registry || notifyCenter !== center) return [];
|
|
362
|
+
} else {
|
|
363
|
+
writeAgentChildRecord(home, {
|
|
364
|
+
...rec,
|
|
365
|
+
status: "interrupted",
|
|
366
|
+
ended_at: rec.ended_at ?? clock.now(),
|
|
367
|
+
end_reason: "interrupted",
|
|
368
|
+
error: rec.error ?? "parent session ended",
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
lost.push(rec);
|
|
372
|
+
}
|
|
373
|
+
return lost;
|
|
374
|
+
};
|
|
375
|
+
/**
|
|
376
|
+
* The previous process died. Settle stale running agent records (the child
|
|
377
|
+
* is gone) and tell the model about that work and about commands the
|
|
378
|
+
* daemon already ended as manager-shutdown / manager-crash.
|
|
379
|
+
* A hosted child that is still warm is left for resume.
|
|
380
|
+
*/
|
|
381
|
+
const recoverCrashedSession = async (sessionId: string): Promise<void> => {
|
|
382
|
+
if (!sessionId || recoveredSessions.has(sessionId)) return;
|
|
383
|
+
const manager = client;
|
|
384
|
+
const center = notifyCenter;
|
|
385
|
+
recoveredSessions.add(sessionId);
|
|
386
|
+
const interrupted =
|
|
387
|
+
await settleDeadHostedChildren(sessionId);
|
|
388
|
+
if (client !== manager || notifyCenter !== center) return;
|
|
389
|
+
let killed: TaskRecord[] = [];
|
|
390
|
+
if (manager?.isAvailable()) {
|
|
391
|
+
try {
|
|
392
|
+
const tasks = await manager.list(true);
|
|
393
|
+
if (client !== manager || notifyCenter !== center) return;
|
|
394
|
+
killed = tasks.filter((task) => {
|
|
395
|
+
if (task.session_id && task.session_id !== sessionId) return false;
|
|
396
|
+
if (task.kind !== "shell") return false;
|
|
397
|
+
return task.end_reason === "manager-shutdown" || task.end_reason === "manager-crash";
|
|
398
|
+
});
|
|
399
|
+
} catch {
|
|
400
|
+
killed = [];
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (interrupted.length === 0 && killed.length === 0) {
|
|
404
|
+
recoveredSessions.delete(sessionId);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const lines = [
|
|
408
|
+
"System wake — not a new user message.",
|
|
409
|
+
"",
|
|
410
|
+
"This session was reopened after the previous process ended.",
|
|
411
|
+
];
|
|
412
|
+
for (const rec of interrupted) {
|
|
413
|
+
lines.push(`Interrupted subagent ${rec.child_id} (${rec.name}).`);
|
|
414
|
+
}
|
|
415
|
+
for (const task of killed) {
|
|
416
|
+
lines.push(`Command ${task.task_id} ended while the session was gone (${task.end_reason ?? task.status}).`);
|
|
417
|
+
}
|
|
418
|
+
center?.notify({
|
|
419
|
+
customType: "pi-famulus-wake",
|
|
420
|
+
content: lines.join("\n"),
|
|
421
|
+
details: {
|
|
422
|
+
kind: "session-recovery",
|
|
423
|
+
children: interrupted.map((rec) => rec.child_id),
|
|
424
|
+
tasks: killed.map((task) => task.task_id),
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
};
|
|
238
428
|
/**
|
|
239
429
|
* Settle everything the manager reports as ended that we still show live:
|
|
240
430
|
* monitors in the registry and shell/monitor rows in the index. Runs when
|
|
@@ -246,6 +436,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
246
436
|
*/
|
|
247
437
|
const syncWithManager = async (): Promise<void> => {
|
|
248
438
|
const c = client;
|
|
439
|
+
const center = notifyCenter;
|
|
249
440
|
let tasks: TaskRecord[];
|
|
250
441
|
if (c && c.isAvailable()) {
|
|
251
442
|
try {
|
|
@@ -260,7 +451,9 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
260
451
|
if (!disk) return;
|
|
261
452
|
tasks = disk;
|
|
262
453
|
}
|
|
454
|
+
if (client !== c || notifyCenter !== center) return;
|
|
263
455
|
monitorRegistry?.reconcile(tasks);
|
|
456
|
+
adoptParentShells(tasks);
|
|
264
457
|
for (const task of workIndex.staleLive(tasks)) {
|
|
265
458
|
const event = exitEventFromRecord(task);
|
|
266
459
|
// Route through the gate: if the real task_exited is processed after
|
|
@@ -414,6 +607,10 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
414
607
|
clock,
|
|
415
608
|
syncWithManager: deps.syncWithManager,
|
|
416
609
|
});
|
|
610
|
+
registerAttachCommand(pi, {
|
|
611
|
+
getClient: () => client,
|
|
612
|
+
getRegistry: () => subagentRegistry,
|
|
613
|
+
});
|
|
417
614
|
famulusInfo = {
|
|
418
615
|
extensionVersion: EXTENSION_VERSION,
|
|
419
616
|
managerPath,
|
|
@@ -447,9 +644,11 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
447
644
|
// M4: comms host bridges the registry and the NotifyCenter. A single Comms
|
|
448
645
|
// instance lives at extension scope (mailbox is per-run namespaced; pending
|
|
449
646
|
// waiters are resolved via dispose() on session_shutdown).
|
|
647
|
+
let watchResume: (childId: string) => void = () => {};
|
|
450
648
|
const commsHost = createRegistryCommsHost({
|
|
451
649
|
getRegistry: () => subagentRegistry,
|
|
452
650
|
getNotifyCenter: () => notifyCenter,
|
|
651
|
+
onResumeAccepted: (childId) => watchResume(childId),
|
|
453
652
|
});
|
|
454
653
|
const comms: CommsWithOrigin = createComms(commsHost, {
|
|
455
654
|
decisionTimeoutMs: subagentConfig.decisionTimeoutMs,
|
|
@@ -466,11 +665,48 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
466
665
|
defaultTimeoutMs: subagentConfig.timeoutMs,
|
|
467
666
|
defaultConcurrency: subagentConfig.concurrency,
|
|
468
667
|
clock,
|
|
668
|
+
registerResumeWatch: (watch) => {
|
|
669
|
+
watchResume = watch;
|
|
670
|
+
},
|
|
469
671
|
resolveAgent: (name) => {
|
|
470
672
|
const loader = agentLoader;
|
|
471
673
|
if (!loader) return resolveAgentDef([], name); // builtins-only fallback
|
|
472
674
|
return resolveAgentDef(loader.reload().definitions, name);
|
|
473
675
|
},
|
|
676
|
+
recoverChild: async (childId) => {
|
|
677
|
+
const registry = subagentRegistry;
|
|
678
|
+
if (!registry) return undefined;
|
|
679
|
+
const rec = findAgentChildRecord(home, childId);
|
|
680
|
+
if (!rec) return undefined;
|
|
681
|
+
if (registry.handle(rec.child_id)) {
|
|
682
|
+
return registry.get(rec.run_id);
|
|
683
|
+
}
|
|
684
|
+
const workKind = AGENT_WORK_KINDS.includes(rec.work_kind as AgentWorkKind)
|
|
685
|
+
? rec.work_kind as AgentWorkKind
|
|
686
|
+
: undefined;
|
|
687
|
+
const loader = agentLoader;
|
|
688
|
+
const agent = resolveAgentDef(loader ? loader.reload().definitions : [], rec.agent);
|
|
689
|
+
registry.adoptExisting({
|
|
690
|
+
runId: rec.run_id,
|
|
691
|
+
childId: rec.child_id,
|
|
692
|
+
name: rec.name,
|
|
693
|
+
agent: rec.agent,
|
|
694
|
+
workKind,
|
|
695
|
+
startedAt: rec.started_at,
|
|
696
|
+
prompt: rec.prompt_head,
|
|
697
|
+
});
|
|
698
|
+
await registry.attachChild({
|
|
699
|
+
childId: rec.child_id,
|
|
700
|
+
runId: rec.run_id,
|
|
701
|
+
name: rec.name,
|
|
702
|
+
prompt: rec.prompt_head ?? "",
|
|
703
|
+
agent,
|
|
704
|
+
timeoutMs: subagentConfig.timeoutMs,
|
|
705
|
+
depth: 1,
|
|
706
|
+
...(workKind ? { workKind } : {}),
|
|
707
|
+
});
|
|
708
|
+
return registry.get(rec.run_id);
|
|
709
|
+
},
|
|
474
710
|
listModels: () => {
|
|
475
711
|
const current = ctx?.model;
|
|
476
712
|
return modelCandidates({
|
|
@@ -546,11 +782,78 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
546
782
|
sessionId: startCtx.sessionManager.getSessionId(),
|
|
547
783
|
managerPath,
|
|
548
784
|
cwd: startCtx.cwd,
|
|
785
|
+
sessionFile: startCtx.sessionManager.getSessionFile?.(),
|
|
549
786
|
clock,
|
|
550
787
|
log: () => {}, // keep quiet; degradation is surfaced via tools
|
|
551
788
|
});
|
|
552
789
|
|
|
790
|
+
// Hosted children run bash in their own process. The parent learns the
|
|
791
|
+
// foreground shell from task_started (origin child-bash) so overrun wakes
|
|
792
|
+
// still carry <shell>.
|
|
793
|
+
const childShells = new ChildShellTracker();
|
|
794
|
+
const parentSessionId = startCtx.sessionManager.getSessionId();
|
|
795
|
+
// Bind this session's notify centre and client. After /new /resume /fork
|
|
796
|
+
// the outer `notifyCenter` is replaced; late events on the old client
|
|
797
|
+
// must not follow that rebinding.
|
|
798
|
+
const sessionNotify = notifyCenter;
|
|
799
|
+
const sessionClient = client;
|
|
553
800
|
client.onEvent((event) => {
|
|
801
|
+
if (client !== sessionClient) return;
|
|
802
|
+
const hosted = event as ManagerEvent & {
|
|
803
|
+
child_id?: string;
|
|
804
|
+
reason?: string;
|
|
805
|
+
request_id?: string;
|
|
806
|
+
message?: string;
|
|
807
|
+
name?: string;
|
|
808
|
+
residency?: string;
|
|
809
|
+
driver?: string;
|
|
810
|
+
last_text?: string;
|
|
811
|
+
request?: { type?: string; id?: string; method?: string; title?: string; message?: string };
|
|
812
|
+
};
|
|
813
|
+
if (hosted.event === "agent_comms" && hosted.child_id && hosted.request_id) {
|
|
814
|
+
const reason = hosted.reason === "need_decision" ? "need_decision" : "progress_update";
|
|
815
|
+
void comms.contactSupervisor(hosted.child_id, reason, hosted.message ?? "").then((reply) => {
|
|
816
|
+
if (reason === "need_decision") void client?.agentCommsReply(hosted.request_id!, hosted.child_id!, reply).catch(() => {});
|
|
817
|
+
}).catch(() => {});
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (hosted.event === "agent_ui" && hosted.child_id) {
|
|
821
|
+
const request = hosted.request ?? {};
|
|
822
|
+
const method = request.method;
|
|
823
|
+
if (method !== "select" && method !== "confirm" && method !== "input" && method !== "editor") return;
|
|
824
|
+
const title = typeof request.title === "string" ? request.title : "";
|
|
825
|
+
const body = typeof request.message === "string" ? request.message : "";
|
|
826
|
+
sessionNotify?.notify({
|
|
827
|
+
customType: "pi-famulus-wake",
|
|
828
|
+
content:
|
|
829
|
+
`System wake — not a new user message.\n\n` +
|
|
830
|
+
`Subagent ${hosted.child_id} is waiting for a ${method}` +
|
|
831
|
+
(title ? `: ${title}` : ".") +
|
|
832
|
+
(body ? `\n${body}` : ""),
|
|
833
|
+
details: {
|
|
834
|
+
kind: "supervisor-request",
|
|
835
|
+
from: hosted.child_id,
|
|
836
|
+
method,
|
|
837
|
+
...(title ? { title } : {}),
|
|
838
|
+
...(body ? { message: body } : {}),
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (hosted.event === "agent_detach" && hosted.child_id) {
|
|
844
|
+
sessionNotify?.notify({
|
|
845
|
+
customType: "pi-famulus-wake",
|
|
846
|
+
content: `System wake — not a new user message.\n\nThe user talked to ${hosted.name ?? hosted.child_id} directly.\n${hosted.last_text ?? ""}`,
|
|
847
|
+
details: { kind: "supervisor-update", from: hosted.child_id, name: hosted.name ?? hosted.child_id, message: hosted.last_text ?? "" },
|
|
848
|
+
});
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (hosted.event === "agent_state" && hosted.child_id) {
|
|
852
|
+
const handle = subagentRegistry?.handle(hosted.child_id) as { pauseStall?: () => void; resumeStall?: () => void } | undefined;
|
|
853
|
+
if (hosted.driver === "user") handle?.pauseStall?.();
|
|
854
|
+
else handle?.resumeStall?.();
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
554
857
|
if (event.event === "task_started" && event.task_id) {
|
|
555
858
|
const prior = taskMeta.get(event.task_id);
|
|
556
859
|
taskMeta.set(event.task_id, {
|
|
@@ -562,6 +865,14 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
562
865
|
? { startedAt: prior.startedAt }
|
|
563
866
|
: {}),
|
|
564
867
|
});
|
|
868
|
+
if (event.origin?.via === "child-bash" && event.origin.child_id) {
|
|
869
|
+
childShells.start(event.origin.child_id, {
|
|
870
|
+
taskId: event.task_id,
|
|
871
|
+
command: event.command ?? "",
|
|
872
|
+
startedAt: typeof event.ts === "number" ? event.ts : clock.now(),
|
|
873
|
+
outputPath: taskOutputPath(home, parentSessionId, event.task_id),
|
|
874
|
+
});
|
|
875
|
+
}
|
|
565
876
|
if (notifyOnExit.has(event.task_id) && typeof event.ts === "number") {
|
|
566
877
|
upsertBackgroundTask(event.task_id, event.ts);
|
|
567
878
|
}
|
|
@@ -584,6 +895,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
584
895
|
return;
|
|
585
896
|
}
|
|
586
897
|
if (event.event === "task_exited" && event.task_id) {
|
|
898
|
+
childShells.endByTask(event.task_id);
|
|
587
899
|
// A known monitor closes (and patches the index via onExited); an
|
|
588
900
|
// unknown id is buffered in case a monitor start is about to claim it.
|
|
589
901
|
if (monitorRegistry?.handleExit(event.task_id, event)) return;
|
|
@@ -595,20 +907,39 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
595
907
|
}
|
|
596
908
|
});
|
|
597
909
|
client.onReconnect(() => {
|
|
910
|
+
if (client !== sessionClient) return;
|
|
598
911
|
// A daemon restart may drop permits; a same-session rebind may retain
|
|
599
912
|
// them. Keep each kind for idempotent re-registration and mark all local
|
|
600
913
|
// leases pending until the daemon confirms them.
|
|
601
914
|
for (const childId of globalAgentLeases.keys()) pendingAgentLeases.add(childId);
|
|
602
|
-
void monitorRegistry?.rewatchAll().then(() =>
|
|
915
|
+
void monitorRegistry?.rewatchAll().then(() => {
|
|
916
|
+
if (client === sessionClient) return syncWithManager();
|
|
917
|
+
});
|
|
603
918
|
void (async () => {
|
|
604
|
-
const manager =
|
|
605
|
-
if (
|
|
919
|
+
const manager = sessionClient;
|
|
920
|
+
if (manager.protocolLevel() < 4) {
|
|
606
921
|
capacityNotice("daemon too old");
|
|
607
922
|
return;
|
|
608
923
|
}
|
|
609
924
|
// The registry and its child ids survive socket reconnects in this
|
|
610
925
|
// extension process; held ids include resumable interrupted children.
|
|
611
926
|
await reconcileAgentLeases(manager);
|
|
927
|
+
if (client !== manager) return;
|
|
928
|
+
const sid = parentSessionId;
|
|
929
|
+
const lost =
|
|
930
|
+
await settleDeadHostedChildren(sid, { interruptLive: true });
|
|
931
|
+
if (lost.length > 0) {
|
|
932
|
+
sessionNotify?.notify({
|
|
933
|
+
customType: "pi-famulus-wake",
|
|
934
|
+
content: [
|
|
935
|
+
"System wake — not a new user message.",
|
|
936
|
+
"",
|
|
937
|
+
"The daemon restarted. Hosted children that were running are gone.",
|
|
938
|
+
...lost.map((rec) => `Interrupted subagent ${rec.child_id} (${rec.name}).`),
|
|
939
|
+
].join("\n"),
|
|
940
|
+
details: { kind: "session-recovery", children: lost.map((rec) => rec.child_id) },
|
|
941
|
+
});
|
|
942
|
+
}
|
|
612
943
|
})().catch(() => {});
|
|
613
944
|
});
|
|
614
945
|
|
|
@@ -633,9 +964,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
633
964
|
"warning",
|
|
634
965
|
);
|
|
635
966
|
}
|
|
636
|
-
|
|
637
|
-
const childShells = new ChildShellTracker();
|
|
638
|
-
const createSession = createPiSessionFn({
|
|
967
|
+
const createSession = createHostedSessionFn({
|
|
639
968
|
getModelRegistry: () => ctx?.modelRegistry ?? null,
|
|
640
969
|
getModelRuntime: () => {
|
|
641
970
|
const registry = ctx?.modelRegistry as { runtime?: unknown } | null | undefined;
|
|
@@ -646,29 +975,10 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
646
975
|
getScopedModels: () => ctx?.scopedModels ?? [],
|
|
647
976
|
getCwd: () => ctx?.cwd ?? process.cwd(),
|
|
648
977
|
getProjectTrusted: () => ctx?.isProjectTrusted() ?? false,
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
createAgentMessageTool(comms, { kind: "child", childId: req.childId, runId: req.runId }, commsHost, clock),
|
|
654
|
-
];
|
|
655
|
-
// The no-background bash variant replaces the built-in bash inside
|
|
656
|
-
// child sessions (custom tools override builtins by name).
|
|
657
|
-
tools.push(
|
|
658
|
-
createChildBashTool({
|
|
659
|
-
getClient: () => client,
|
|
660
|
-
home,
|
|
661
|
-
sessionId: () => ctx?.sessionManager.getSessionId() ?? "",
|
|
662
|
-
sessionEnv: () => (ctx ? sessionEnv(ctx) : {}),
|
|
663
|
-
trackTask,
|
|
664
|
-
childId: req.childId,
|
|
665
|
-
runId: req.runId,
|
|
666
|
-
clock,
|
|
667
|
-
shells: childShells,
|
|
668
|
-
}),
|
|
669
|
-
);
|
|
670
|
-
return tools;
|
|
671
|
-
},
|
|
978
|
+
getClient: () => client,
|
|
979
|
+
getHome: () => home,
|
|
980
|
+
extensionEntry: fileURLToPath(import.meta.url),
|
|
981
|
+
piCommand: subagentConfig.piCommand,
|
|
672
982
|
});
|
|
673
983
|
// The registry outlives startCtx across a /reload, which leaves startCtx
|
|
674
984
|
// stale; its closures keep plain values instead.
|
|
@@ -724,6 +1034,23 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
724
1034
|
// One event per stall detection: an auto-resume follows unless the
|
|
725
1035
|
// retry budget (stallRetries) is already spent.
|
|
726
1036
|
logEvent("agent.stall", { child_id: childId, attempt });
|
|
1037
|
+
notifyCenter?.notify({
|
|
1038
|
+
customType: "pi-famulus-wake",
|
|
1039
|
+
content:
|
|
1040
|
+
`System wake — not a new user message.\n\n` +
|
|
1041
|
+
`Subagent ${childId} stalled with no activity (attempt ${attempt}); retrying.`,
|
|
1042
|
+
details: { kind: "subagent-stall", child_id: childId, attempt },
|
|
1043
|
+
});
|
|
1044
|
+
},
|
|
1045
|
+
onToolLoop: (childId, toolName, args) => {
|
|
1046
|
+
const argText = args && typeof args === "object" ? JSON.stringify(args) : String(args ?? "");
|
|
1047
|
+
notifyCenter?.notify({
|
|
1048
|
+
customType: "pi-famulus-wake",
|
|
1049
|
+
content:
|
|
1050
|
+
`System wake — not a new user message.\n\n` +
|
|
1051
|
+
`Subagent ${childId} called ${toolName} three times in a row with the same arguments${argText ? `: ${argText}` : "."}`,
|
|
1052
|
+
details: { kind: "subagent-loop", child_id: childId, tool: toolName, args },
|
|
1053
|
+
});
|
|
727
1054
|
},
|
|
728
1055
|
// Soft deadline: the child keeps running; the parent decides.
|
|
729
1056
|
onOverrun: createOverrunNotifier({
|
|
@@ -811,6 +1138,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
811
1138
|
};
|
|
812
1139
|
const endReason = agentEndReason(c.status, c.result);
|
|
813
1140
|
if (endReason) rec.end_reason = endReason;
|
|
1141
|
+
if (c.result?.retryAfter !== undefined) rec.retry_after = c.result.retryAfter;
|
|
814
1142
|
const transcript = syncTranscript(c.childId);
|
|
815
1143
|
if (transcript) {
|
|
816
1144
|
rec.transcript = transcript;
|
|
@@ -840,12 +1168,16 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
840
1168
|
}
|
|
841
1169
|
});
|
|
842
1170
|
startFleetWidget(startCtx);
|
|
1171
|
+
if (client?.isAvailable()) {
|
|
1172
|
+
await recoverCrashedSession(startCtx.sessionManager.getSessionId());
|
|
1173
|
+
}
|
|
843
1174
|
|
|
844
1175
|
// Connect in the background: a cold manager spawn must not stall the
|
|
845
1176
|
// first prompt. The cache install, if one is running, finishes first so a
|
|
846
1177
|
// missing optional package is not reported before it can be restored.
|
|
847
1178
|
// Tools call ensureAvailable() lazily before use.
|
|
848
|
-
|
|
1179
|
+
if (client !== sessionClient) return;
|
|
1180
|
+
const c = sessionClient;
|
|
849
1181
|
void nativeRestore
|
|
850
1182
|
.then((path) => {
|
|
851
1183
|
if (client !== c || !c) return null;
|
|
@@ -856,6 +1188,11 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
856
1188
|
if (ok === null || client !== c || !c) return;
|
|
857
1189
|
if (ok) {
|
|
858
1190
|
if (c.protocolLevel() >= 4) void reconcileAgentLeases(c).catch(() => {});
|
|
1191
|
+
const file = startCtx.sessionManager.getSessionFile?.();
|
|
1192
|
+
const sid = startCtx.sessionManager.getSessionId();
|
|
1193
|
+
void promptIfHostedSession(c, () => ctx, { sessionFile: file, sessionId: sid }, "start").catch(() => {});
|
|
1194
|
+
void syncWithManager();
|
|
1195
|
+
void recoverCrashedSession(sid);
|
|
859
1196
|
return;
|
|
860
1197
|
}
|
|
861
1198
|
// A /reload may have replaced startCtx by now; report to the live one.
|
|
@@ -885,6 +1222,8 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
885
1222
|
agentLoader = null;
|
|
886
1223
|
monitorRegistry?.disposeAll();
|
|
887
1224
|
notifyCenter?.dispose();
|
|
1225
|
+
recoveredSessions.clear();
|
|
1226
|
+
cleanupSessionSpillFiles();
|
|
888
1227
|
const current = client;
|
|
889
1228
|
client = null;
|
|
890
1229
|
if (current) {
|
|
@@ -900,6 +1239,14 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
900
1239
|
};
|
|
901
1240
|
const runtime: FamulusRuntime = { attach: (host) => binding.attach(host) };
|
|
902
1241
|
|
|
1242
|
+
pi.on("session_before_switch", async (event, switchCtx) => {
|
|
1243
|
+
const file = event.targetSessionFile;
|
|
1244
|
+
if (!file || !client?.isAvailable()) return;
|
|
1245
|
+
const outcome = await promptIfHostedSession(client, () => switchCtx, { sessionFile: file }, "switch");
|
|
1246
|
+
if (outcome === "none" || outcome === "takeover") return;
|
|
1247
|
+
return { cancel: true };
|
|
1248
|
+
});
|
|
1249
|
+
|
|
903
1250
|
pi.on("session_shutdown", async (event) => {
|
|
904
1251
|
if (event.reason === "reload" && ctx) {
|
|
905
1252
|
// Keep the session's shells, monitors and subagents running; the
|
|
@@ -912,6 +1259,14 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
912
1259
|
parkRuntime({ version: EXTENSION_VERSION, runtime, dispose: teardown });
|
|
913
1260
|
return;
|
|
914
1261
|
}
|
|
1262
|
+
if (String((ctx as { mode?: string } | null)?.mode) === "print") {
|
|
1263
|
+
const ids = [
|
|
1264
|
+
...workIndex.list().filter((item) => item.status === "running" || item.status === "pending").map((item) => item.id),
|
|
1265
|
+
...(subagentRegistry?.activeChildren() ?? []).map((child) => child.childId),
|
|
1266
|
+
];
|
|
1267
|
+
const warning = printModeDropWarning([...new Set(ids)]);
|
|
1268
|
+
if (warning) process.stderr.write(warning);
|
|
1269
|
+
}
|
|
915
1270
|
await teardown();
|
|
916
1271
|
});
|
|
917
1272
|
|