pi-famulus 0.1.6 → 0.1.8
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 +154 -25
- package/package.json +32 -7
- package/src/bash-override.ts +34 -26
- package/src/bounded-output.ts +167 -0
- package/src/comms/reply-command.ts +1 -1
- package/src/format.ts +33 -7
- package/src/index.ts +96 -18
- package/src/manager-client.ts +38 -3
- package/src/monitor-batching.ts +84 -36
- package/src/monitor-output.ts +159 -0
- package/src/monitor.ts +148 -37
- package/src/native-manager.js +0 -2
- package/src/notify.ts +100 -17
- package/src/prompts.generated.ts +14 -11
- package/src/reload-handoff.ts +137 -0
- package/src/shell-exec.ts +28 -2
- package/src/shell-start.ts +25 -0
- package/src/subagent/child-bash.ts +2 -1
- package/src/subagent/conversation.ts +57 -12
- package/src/subagent/fleet-widget.ts +8 -8
- package/src/subagent/runner.ts +2 -2
- package/src/subagent/tool.ts +2 -1
- package/src/task-tools.ts +32 -13
- package/src/tui/conversation-view.ts +402 -0
- package/src/tui/famulus-command.ts +71 -0
- package/src/tui/message-renderers.ts +16 -11
- package/src/tui/scroll-detail-view.ts +218 -99
- package/src/tui/task-detail-view.ts +417 -0
- package/src/tui/tasks-command.ts +117 -88
- package/src/tui/tool-component.ts +6 -2
- package/src/wake.ts +27 -1
package/src/format.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
formatFamulusWake,
|
|
9
9
|
shellWakeTitle,
|
|
10
10
|
type FamulusWake,
|
|
11
|
+
type MonitorOutputInfo,
|
|
11
12
|
wakeDuration,
|
|
12
13
|
type FormattedWake,
|
|
13
14
|
type OverrunShell,
|
|
@@ -15,6 +16,21 @@ import {
|
|
|
15
16
|
type WakeItem,
|
|
16
17
|
} from "./wake";
|
|
17
18
|
import { fill } from "./prompts.generated";
|
|
19
|
+
import { shellStartStatus } from "./shell-start";
|
|
20
|
+
import { statusGlyph } from "./tui/tool-component";
|
|
21
|
+
|
|
22
|
+
/** `850ms`, `12s`, `4m05s`, `2h03m`, `3d04h`: the same style as `pi-famulus top`. */
|
|
23
|
+
export function formatDuration(ms: number): string {
|
|
24
|
+
const total = Math.max(0, Math.floor(ms));
|
|
25
|
+
if (total < 1000) return `${total}ms`;
|
|
26
|
+
const secs = Math.floor(total / 1000);
|
|
27
|
+
if (secs < 60) return `${secs}s`;
|
|
28
|
+
const m = Math.floor(secs / 60);
|
|
29
|
+
if (m < 60) return `${m}m${String(secs % 60).padStart(2, "0")}s`;
|
|
30
|
+
const h = Math.floor(m / 60);
|
|
31
|
+
if (h < 24) return `${h}h${String(m % 60).padStart(2, "0")}m`;
|
|
32
|
+
return `${Math.floor(h / 24)}d${String(h % 24).padStart(2, "0")}h`;
|
|
33
|
+
}
|
|
18
34
|
|
|
19
35
|
export { FAMULUS_WAKE_LEAD_IN } from "./wake";
|
|
20
36
|
export type { FormattedWake, WakeItem } from "./wake";
|
|
@@ -84,6 +100,8 @@ export interface TaskExitInfo {
|
|
|
84
100
|
preview: string;
|
|
85
101
|
/** Signal name from the manager, when the exit was a signal. */
|
|
86
102
|
signal?: string;
|
|
103
|
+
/** The manager's end_reason, when it sent one. */
|
|
104
|
+
endReason?: string;
|
|
87
105
|
}
|
|
88
106
|
|
|
89
107
|
/** Shorten a command for one-line display inside notifications. */
|
|
@@ -92,6 +110,8 @@ function displayCommand(command: string): string {
|
|
|
92
110
|
}
|
|
93
111
|
|
|
94
112
|
function statusPhrase(info: TaskExitInfo): string {
|
|
113
|
+
const startFailure = shellStartStatus(info.endReason);
|
|
114
|
+
if (startFailure) return startFailure;
|
|
95
115
|
const code = info.exitCode === null ? null : `exit code ${info.exitCode}`;
|
|
96
116
|
switch (info.status) {
|
|
97
117
|
case "completed":
|
|
@@ -160,17 +180,22 @@ export function backgroundRowText(
|
|
|
160
180
|
taskId: string,
|
|
161
181
|
item: { status: string; exitCode?: number | null; startedAt: number; endedAt?: number } | undefined,
|
|
162
182
|
now: number,
|
|
183
|
+
indexed = false,
|
|
163
184
|
): { glyph: string; color: string; text: string } {
|
|
185
|
+
// Missing from the index: pruned after it finished, or not yet re-listed
|
|
186
|
+
// after a reload. Either way the state is unknown, so claim neither.
|
|
187
|
+
if (!item && indexed) return { glyph: "·", color: "dim", text: `${taskId} · status in /tasks` };
|
|
164
188
|
if (!item || item.status === "running" || item.status === "pending") {
|
|
165
|
-
|
|
189
|
+
const { glyph, color } = statusGlyph(item?.status ?? "running");
|
|
190
|
+
return { glyph, color, text: `${taskId} running in background · /tasks` };
|
|
166
191
|
}
|
|
167
|
-
const
|
|
168
|
-
const dur = secs < 60 ? `${secs.toFixed(1)}s` : `${Math.floor(secs / 60)}m${Math.round(secs % 60)}s`;
|
|
192
|
+
const dur = formatDuration((item.endedAt ?? now) - item.startedAt);
|
|
169
193
|
const exit = item.exitCode === null || item.exitCode === undefined ? "" : ` · exit ${item.exitCode}`;
|
|
170
194
|
const ok = item.status === "completed" && (item.exitCode === 0 || item.exitCode === null || item.exitCode === undefined);
|
|
171
|
-
return
|
|
172
|
-
|
|
173
|
-
|
|
195
|
+
if (ok) return { ...statusGlyph("completed"), text: `${taskId} finished${exit} · ${dur}` };
|
|
196
|
+
// A non-zero exit fails the row even when the task "completed".
|
|
197
|
+
const { glyph, color } = statusGlyph(item.status === "completed" ? "failed" : item.status);
|
|
198
|
+
return { glyph, color, text: `${taskId} ${item.status}${exit} · ${dur} · /tasks` };
|
|
174
199
|
}
|
|
175
200
|
|
|
176
201
|
/** Injected payload for a batch of monitor output lines (§4.4 / §4.5). */
|
|
@@ -179,7 +204,7 @@ export function formatMonitorEvent(
|
|
|
179
204
|
taskId: string,
|
|
180
205
|
batchText: string,
|
|
181
206
|
status?: string,
|
|
182
|
-
extras: { eventCount?: number; droppedLines?: number } = {},
|
|
207
|
+
extras: { eventCount?: number; droppedLines?: number; output?: MonitorOutputInfo } = {},
|
|
183
208
|
): FormattedWake {
|
|
184
209
|
return formatFamulusWake({
|
|
185
210
|
kind: "monitor",
|
|
@@ -189,6 +214,7 @@ export function formatMonitorEvent(
|
|
|
189
214
|
event: batchText,
|
|
190
215
|
...(extras.eventCount !== undefined ? { eventCount: extras.eventCount } : {}),
|
|
191
216
|
...(extras.droppedLines !== undefined ? { droppedLines: extras.droppedLines } : {}),
|
|
217
|
+
...(extras.output ? { output: extras.output } : {}),
|
|
192
218
|
});
|
|
193
219
|
}
|
|
194
220
|
|
package/src/index.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { createAgentMessageTool, createContactSupervisorTool } from "./comms/too
|
|
|
25
25
|
import { registerReplyCommand } from "./comms/reply-command";
|
|
26
26
|
import { describeManagerSearch, getFamulusHome, loadConfig, resolveManagerPath, resolveSubagentConfig } from "./config";
|
|
27
27
|
import type { TaskExitInfo } from "./format";
|
|
28
|
-
import { ManagerClient, type ManagerEvent, type TaskRecord } from "./manager-client";
|
|
28
|
+
import { EXTENSION_VERSION, ManagerClient, type ManagerEvent, type TaskRecord } from "./manager-client";
|
|
29
29
|
import { createMonitorTool, exitEventFromRecord, MonitorRegistry } from "./monitor";
|
|
30
30
|
import { admitAgentChild, reregisterAgentLeases } from "./subagent/admission";
|
|
31
31
|
import { NotifyCenter } from "./notify";
|
|
@@ -48,6 +48,8 @@ import { SubagentRegistry } from "./subagent/registry";
|
|
|
48
48
|
import { InProcessRunner } from "./subagent/runner";
|
|
49
49
|
import { createSubagentTool } from "./subagent/tool";
|
|
50
50
|
import { createTaskListTool, createTaskOutputTool, createTaskStopTool } from "./task-tools";
|
|
51
|
+
import { createPiBinding, parkRuntime, takeParkedRuntime } from "./reload-handoff";
|
|
52
|
+
import { registerFamulusCommand } from "./tui/famulus-command";
|
|
51
53
|
import { registerFamulusMessageRenderers } from "./tui/message-renderers";
|
|
52
54
|
import { registerTasksCommand } from "./tui/tasks-command";
|
|
53
55
|
import { stderrPathFor } from "./tui/task-output-paths";
|
|
@@ -69,8 +71,31 @@ function toExitStatus(event: ManagerEvent): TaskExitInfo["status"] {
|
|
|
69
71
|
return "failed";
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
interface FamulusRuntime {
|
|
75
|
+
attach(pi: ExtensionAPI): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
72
78
|
export default function (pi: ExtensionAPI): void {
|
|
73
79
|
if (isFamulusChildSession(pi)) return;
|
|
80
|
+
// A /reload parked the previous instance's runtime: adopt it so its shells,
|
|
81
|
+
// monitors and subagents keep running (see reload-handoff.ts). Another
|
|
82
|
+
// version's runtime is torn down instead; its code may not match ours.
|
|
83
|
+
const parked = takeParkedRuntime<FamulusRuntime>();
|
|
84
|
+
if (parked?.version === EXTENSION_VERSION) {
|
|
85
|
+
parked.runtime.attach(pi);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const retiring = parked?.dispose().catch(() => {});
|
|
89
|
+
createFamulusRuntime(retiring).attach(pi);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Everything one pi session owns. It registers on a PiBinding rather than on
|
|
94
|
+
* pi directly, so a /reload can re-attach it to the next extension instance.
|
|
95
|
+
*/
|
|
96
|
+
function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
97
|
+
const binding = createPiBinding();
|
|
98
|
+
const pi = binding.api;
|
|
74
99
|
registerBehaviorGuidelines(pi);
|
|
75
100
|
const home = getFamulusHome();
|
|
76
101
|
const config = loadConfig(home);
|
|
@@ -277,6 +302,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
277
302
|
status: toExitStatus(event),
|
|
278
303
|
exitCode: event.exit_code ?? null,
|
|
279
304
|
...(typeof event.signal === "string" && event.signal ? { signal: event.signal } : {}),
|
|
305
|
+
...(event.end_reason ? { endReason: event.end_reason } : {}),
|
|
280
306
|
durationMs: event.duration_ms ?? 0,
|
|
281
307
|
outputPath: event.output_path ?? "",
|
|
282
308
|
preview: readPreview(event.output_path, 4000),
|
|
@@ -356,7 +382,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
356
382
|
noteStopRequested: (id) => monitorRegistry?.noteStopRequested(id),
|
|
357
383
|
}),
|
|
358
384
|
);
|
|
359
|
-
pi.registerTool(createMonitorTool(monitorRegistry));
|
|
385
|
+
pi.registerTool(createMonitorTool(monitorRegistry, () => workIndex));
|
|
360
386
|
registerTasksCommand(pi, {
|
|
361
387
|
getRegistry: () => subagentRegistry,
|
|
362
388
|
getIndex: () => workIndex,
|
|
@@ -366,6 +392,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
366
392
|
clock,
|
|
367
393
|
syncWithManager: deps.syncWithManager,
|
|
368
394
|
});
|
|
395
|
+
registerFamulusCommand(pi, {
|
|
396
|
+
extensionVersion: EXTENSION_VERSION,
|
|
397
|
+
managerPath,
|
|
398
|
+
describeSearch: () => describeManagerSearch(config, home),
|
|
399
|
+
getClient: () => client,
|
|
400
|
+
});
|
|
369
401
|
monitorRegistry.onChange(() => {
|
|
370
402
|
for (const mon of monitorRegistry.listActive()) {
|
|
371
403
|
const existing = workIndex.get(mon.taskId);
|
|
@@ -432,8 +464,37 @@ export default function (pi: ExtensionAPI): void {
|
|
|
432
464
|
// each child session via customTools, see below).
|
|
433
465
|
pi.registerTool(createAgentMessageTool(comms, { kind: "parent" }, commsHost, clock));
|
|
434
466
|
|
|
435
|
-
|
|
467
|
+
/** Session whose runtime a /reload parked; the reloaded instance's session_start resumes it. */
|
|
468
|
+
let parkedSessionId: string | null = null;
|
|
469
|
+
const startFleetWidget = (startCtx: ExtensionContext) => {
|
|
470
|
+
if (!startCtx.hasUI) return;
|
|
471
|
+
fleetWidget = new FleetWidget({
|
|
472
|
+
index: workIndex,
|
|
473
|
+
getUi: () => (ctx?.hasUI ? (ctx.ui as never) : null),
|
|
474
|
+
clock,
|
|
475
|
+
});
|
|
476
|
+
fleetWidget.start();
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
pi.on("session_start", async (event, startCtx) => {
|
|
480
|
+
if (parkedSessionId !== null) {
|
|
481
|
+
const resumed = event.reason === "reload" && startCtx.sessionManager.getSessionId() === parkedSessionId;
|
|
482
|
+
parkedSessionId = null;
|
|
483
|
+
if (resumed) {
|
|
484
|
+
// Same session after /reload: the client, registry and monitors kept
|
|
485
|
+
// running; only the context and the UI belong to the new instance.
|
|
486
|
+
ctx = startCtx;
|
|
487
|
+
binding.ready();
|
|
488
|
+
startFleetWidget(startCtx);
|
|
489
|
+
void syncWithManager();
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
binding.discardQueued();
|
|
493
|
+
await teardown();
|
|
494
|
+
}
|
|
495
|
+
await retiring;
|
|
436
496
|
ctx = startCtx;
|
|
497
|
+
binding.ready();
|
|
437
498
|
notifyCenter?.dispose();
|
|
438
499
|
notifyCenter = new NotifyCenter({
|
|
439
500
|
sendMessage: (msg, opts) => pi.sendMessage(msg, opts),
|
|
@@ -496,7 +557,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
496
557
|
return;
|
|
497
558
|
}
|
|
498
559
|
if (event.event === "output" && event.task_id && typeof event.chunk === "string") {
|
|
499
|
-
monitorRegistry?.handleOutput(event.task_id, event.chunk, event.next_cursor);
|
|
560
|
+
monitorRegistry?.handleOutput(event.task_id, event.chunk, event.next_cursor, event.monitor_records);
|
|
500
561
|
return;
|
|
501
562
|
}
|
|
502
563
|
if (event.event === "task_exited" && event.task_id) {
|
|
@@ -586,11 +647,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
586
647
|
return tools;
|
|
587
648
|
},
|
|
588
649
|
});
|
|
650
|
+
// The registry outlives startCtx across a /reload, which leaves startCtx
|
|
651
|
+
// stale; its closures keep plain values instead.
|
|
652
|
+
const sessionId = startCtx.sessionManager.getSessionId();
|
|
653
|
+
const sessionCwd = startCtx.cwd;
|
|
589
654
|
const transcripts = new TranscriptWriter(home, clock);
|
|
590
655
|
const syncTranscript = (childId: string): string | undefined => {
|
|
591
656
|
const handle = registry.handle(childId);
|
|
592
657
|
if (!handle) return undefined;
|
|
593
|
-
return transcripts.sync(
|
|
658
|
+
return transcripts.sync(sessionId, childId, handle.conversation());
|
|
594
659
|
};
|
|
595
660
|
const runner = new InProcessRunner({
|
|
596
661
|
createSession,
|
|
@@ -657,7 +722,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
657
722
|
registry.setRunner(runner);
|
|
658
723
|
subagentRegistry = registry;
|
|
659
724
|
// Persist child records so `pi-famulus ls` / task_list can see in-process agents.
|
|
660
|
-
const sessionIdForAgents = () =>
|
|
725
|
+
const sessionIdForAgents = () => sessionId;
|
|
661
726
|
const previousAgentStatus = new Map<string, string>();
|
|
662
727
|
registry.onTransition((run) => {
|
|
663
728
|
const sid = sessionIdForAgents();
|
|
@@ -743,7 +808,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
743
808
|
workKind: c.workKind ?? "other",
|
|
744
809
|
...(c.queueMs !== undefined ? { queueMs: c.queueMs } : {}),
|
|
745
810
|
...(c.model !== undefined ? { model: c.model } : {}),
|
|
746
|
-
cwd:
|
|
811
|
+
cwd: sessionCwd,
|
|
747
812
|
...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
|
|
748
813
|
...(c.preamble !== undefined ? { preamble: c.preamble } : {}),
|
|
749
814
|
...(c.result?.text ? { text: c.result.text } : {}),
|
|
@@ -751,14 +816,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
751
816
|
});
|
|
752
817
|
}
|
|
753
818
|
});
|
|
754
|
-
|
|
755
|
-
fleetWidget = new FleetWidget({
|
|
756
|
-
index: workIndex,
|
|
757
|
-
getUi: () => (ctx?.hasUI ? (ctx.ui as never) : null),
|
|
758
|
-
clock,
|
|
759
|
-
});
|
|
760
|
-
fleetWidget.start();
|
|
761
|
-
}
|
|
819
|
+
startFleetWidget(startCtx);
|
|
762
820
|
|
|
763
821
|
// Connect in the background: a cold manager spawn must not stall the
|
|
764
822
|
// first prompt. Tools call ensureAvailable() lazily before use.
|
|
@@ -770,11 +828,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
770
828
|
if (c.protocolLevel() >= 4) void reconcileAgentLeases(c).catch(() => {});
|
|
771
829
|
return;
|
|
772
830
|
}
|
|
773
|
-
|
|
831
|
+
// A /reload may have replaced startCtx by now; report to the live one.
|
|
832
|
+
const current = ctx;
|
|
833
|
+
if (current?.hasUI) {
|
|
774
834
|
const detail = c.lastError();
|
|
775
835
|
const reason = detail ? ` (${detail})` : "";
|
|
776
836
|
const searched = managerPath ? `using ${managerPath}` : `looked in: ${describeManagerSearch(config, home)}`;
|
|
777
|
-
|
|
837
|
+
current.ui.notify(
|
|
778
838
|
`pi-famulus unavailable${reason}; ${searched}. ` +
|
|
779
839
|
"Bash runs locally without auto-backgrounding, task_*/monitor are disabled, " +
|
|
780
840
|
"and subagents cannot run bash. Install it or set PI_FAMULUS_MANAGER_PATH (see README Install).",
|
|
@@ -785,7 +845,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
785
845
|
.catch(() => {});
|
|
786
846
|
});
|
|
787
847
|
|
|
788
|
-
|
|
848
|
+
const teardown = async (): Promise<void> => {
|
|
789
849
|
exitWatchdog.dispose();
|
|
790
850
|
fleetWidget?.dispose();
|
|
791
851
|
fleetWidget = null;
|
|
@@ -807,6 +867,22 @@ export default function (pi: ExtensionAPI): void {
|
|
|
807
867
|
}
|
|
808
868
|
}
|
|
809
869
|
ctx = null;
|
|
870
|
+
};
|
|
871
|
+
const runtime: FamulusRuntime = { attach: (host) => binding.attach(host) };
|
|
872
|
+
|
|
873
|
+
pi.on("session_shutdown", async (event) => {
|
|
874
|
+
if (event.reason === "reload" && ctx) {
|
|
875
|
+
// Keep the session's shells, monitors and subagents running; the
|
|
876
|
+
// reloaded instance adopts this runtime.
|
|
877
|
+
parkedSessionId = ctx.sessionManager.getSessionId();
|
|
878
|
+
fleetWidget?.dispose();
|
|
879
|
+
fleetWidget = null;
|
|
880
|
+
ctx = null;
|
|
881
|
+
binding.detach();
|
|
882
|
+
parkRuntime({ version: EXTENSION_VERSION, runtime, dispose: teardown });
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
await teardown();
|
|
810
886
|
});
|
|
811
887
|
|
|
812
888
|
pi.on("agent_settled", async () => {
|
|
@@ -822,4 +898,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
822
898
|
});
|
|
823
899
|
return message ? { message } : undefined;
|
|
824
900
|
});
|
|
901
|
+
|
|
902
|
+
return runtime;
|
|
825
903
|
}
|
package/src/manager-client.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* lifetime-lock holder cleans up stale socket/pid files.
|
|
6
6
|
* - Request/response multiplexing over a single long-lived connection.
|
|
7
7
|
* - Server events dispatched to registered handlers.
|
|
8
|
-
* - On unexpected disconnect: immediate then exponential-backoff reconnect for
|
|
8
|
+
* - On unexpected disconnect: immediate then exponential-backoff reconnect for 27s,
|
|
9
9
|
* re-hello after reconnect. If all attempts fail the client is marked unavailable
|
|
10
10
|
* and callers are expected to degrade (bash falls back to local execution).
|
|
11
11
|
*/
|
|
@@ -18,7 +18,8 @@ import { famulusPaths } from "./config";
|
|
|
18
18
|
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
19
19
|
|
|
20
20
|
const MAX_FRAME_BYTES = 4 * 1024 * 1024; // 4 MiB (§3.3)
|
|
21
|
-
const EXTENSION_VERSION = "0.1.
|
|
21
|
+
const EXTENSION_VERSION = "0.1.8";
|
|
22
|
+
export { EXTENSION_VERSION };
|
|
22
23
|
// 5: speaks per-kind agent admission and queued acquire.
|
|
23
24
|
const PROTOCOL = 5;
|
|
24
25
|
const HELLO_TIMEOUT_MS = 5000;
|
|
@@ -51,6 +52,8 @@ export interface StartRequest {
|
|
|
51
52
|
run_in_background?: boolean;
|
|
52
53
|
timeout_ms?: number | null;
|
|
53
54
|
origin?: TaskOrigin;
|
|
55
|
+
/** Human-readable name (monitor description). */
|
|
56
|
+
label?: string;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
export interface StartResponse {
|
|
@@ -90,6 +93,19 @@ export interface TaskRecord {
|
|
|
90
93
|
end_reason?: string;
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
/** Rust-framed logical output. Fingerprints cover full content, not the preview. */
|
|
97
|
+
export interface MonitorOutputRecord {
|
|
98
|
+
version: 1;
|
|
99
|
+
kind: "line" | "progress";
|
|
100
|
+
observed_at: number;
|
|
101
|
+
cursor_start: number;
|
|
102
|
+
cursor_end: number;
|
|
103
|
+
preview: string;
|
|
104
|
+
truncated: boolean;
|
|
105
|
+
fingerprint?: string;
|
|
106
|
+
timestamp_fingerprint?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
93
109
|
/** Server-pushed event (§3.3). Fields beyond `event` depend on the event kind. */
|
|
94
110
|
export interface ManagerEvent {
|
|
95
111
|
event: "task_started" | "output" | "task_exited" | "session_rebound" | string;
|
|
@@ -99,6 +115,8 @@ export interface ManagerEvent {
|
|
|
99
115
|
pid?: number;
|
|
100
116
|
chunk?: string;
|
|
101
117
|
next_cursor?: number;
|
|
118
|
+
/** Absent on legacy managers/replay fallback; [] means Rust framing is active. */
|
|
119
|
+
monitor_records?: MonitorOutputRecord[];
|
|
102
120
|
exit_code?: number | null;
|
|
103
121
|
signal?: string | null;
|
|
104
122
|
duration_ms?: number;
|
|
@@ -281,6 +299,7 @@ export class ManagerClient {
|
|
|
281
299
|
private eventHandlers = new Set<EventHandler>();
|
|
282
300
|
private reconnectHandlers = new Set<() => void>();
|
|
283
301
|
private protocolLevel_ = 0;
|
|
302
|
+
private daemonInfo: { version: string; pid: number } | null = null;
|
|
284
303
|
private state: ClientState = "disconnected";
|
|
285
304
|
private intentionalClose = false;
|
|
286
305
|
private rebound = false;
|
|
@@ -313,6 +332,11 @@ export class ManagerClient {
|
|
|
313
332
|
return this.protocolLevel_;
|
|
314
333
|
}
|
|
315
334
|
|
|
335
|
+
/** Version and pid the connected daemon reported on hello; null before the first hello. */
|
|
336
|
+
managerInfo(): { version: string; pid: number } | null {
|
|
337
|
+
return this.daemonInfo;
|
|
338
|
+
}
|
|
339
|
+
|
|
316
340
|
/** Last connect/reconnect failure reason (empty when never failed / currently connected). */
|
|
317
341
|
lastError(): string {
|
|
318
342
|
return this.lastFailureMessage;
|
|
@@ -792,7 +816,10 @@ export class ManagerClient {
|
|
|
792
816
|
}
|
|
793
817
|
if (this.helloWaiter && typeof msg.ok === "boolean") {
|
|
794
818
|
// Unnamed response: correlate by order with the outstanding hello.
|
|
795
|
-
if (msg.ok === true)
|
|
819
|
+
if (msg.ok === true) {
|
|
820
|
+
this.recordDaemonInfo(msg);
|
|
821
|
+
this.helloWaiter.resolve();
|
|
822
|
+
}
|
|
796
823
|
else this.helloWaiter.reject(errorFromResponse(msg));
|
|
797
824
|
this.helloWaiter = null;
|
|
798
825
|
return;
|
|
@@ -800,6 +827,13 @@ export class ManagerClient {
|
|
|
800
827
|
this.log(`unmatched message: ${JSON.stringify(msg).slice(0, 200)}`);
|
|
801
828
|
}
|
|
802
829
|
|
|
830
|
+
private recordDaemonInfo(hello: Record<string, unknown>): void {
|
|
831
|
+
this.daemonInfo = {
|
|
832
|
+
version: typeof hello.version === "string" ? hello.version : "unknown",
|
|
833
|
+
pid: typeof hello.pid === "number" ? hello.pid : 0,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
803
837
|
private settle(id: string, msg: Record<string, unknown>): void {
|
|
804
838
|
const entry = this.pending.get(id);
|
|
805
839
|
if (!entry) return;
|
|
@@ -811,6 +845,7 @@ export class ManagerClient {
|
|
|
811
845
|
if (entry.message.type === "hello") {
|
|
812
846
|
const serverMaximum = typeof msg.protocol === "number" ? msg.protocol : 0;
|
|
813
847
|
this.protocolLevel_ = Math.min(PROTOCOL, serverMaximum);
|
|
848
|
+
this.recordDaemonInfo(msg);
|
|
814
849
|
}
|
|
815
850
|
entry.resolve(msg);
|
|
816
851
|
} else {
|
package/src/monitor-batching.ts
CHANGED
|
@@ -13,19 +13,71 @@ export interface LineBatcherOptions {
|
|
|
13
13
|
flushMs?: number; // batching window, default 200
|
|
14
14
|
maxLineChars?: number; // per-line cap, default 500
|
|
15
15
|
maxBatchChars?: number; // per-batch cap, default 3000
|
|
16
|
-
onFlush: (text: string) => void;
|
|
16
|
+
onFlush: (text: string, info: { truncated: boolean; omittedChars: number }) => void;
|
|
17
17
|
clock?: Clock;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
function sourceOfSlice(text: string, source: number, start: number, end: number): number {
|
|
21
|
+
if (text.length === 0 || end <= start) return 0;
|
|
22
|
+
return Math.round(source * ((end - start) / text.length));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Fixed-memory head/tail preview. Counts omitted source units, not generated ellipses. */
|
|
26
|
+
export class BoundedPreview {
|
|
27
|
+
private head = "";
|
|
28
|
+
private tail = "";
|
|
29
|
+
private sourceHead = 0;
|
|
30
|
+
private sourceTail = 0;
|
|
31
|
+
total = 0;
|
|
32
|
+
private sourceTotal = 0;
|
|
33
|
+
constructor(readonly limit: number) {
|
|
34
|
+
if (!Number.isSafeInteger(limit) || limit < 1) throw new RangeError("preview limit must be positive");
|
|
35
|
+
}
|
|
36
|
+
append(text: string, sourceChars = text.length): void {
|
|
37
|
+
const headLimit = Math.ceil((this.limit - 1) / 2);
|
|
38
|
+
if (this.total + text.length <= this.limit) {
|
|
39
|
+
this.head += text;
|
|
40
|
+
this.sourceHead += sourceChars;
|
|
41
|
+
} else {
|
|
42
|
+
const old = this.head;
|
|
43
|
+
const oldSourceHead = this.sourceHead;
|
|
44
|
+
if (this.total <= this.limit) {
|
|
45
|
+
const combined = old + text;
|
|
46
|
+
const combinedSource = oldSourceHead + sourceChars;
|
|
47
|
+
this.head = combined.slice(0, headLimit);
|
|
48
|
+
this.sourceHead = sourceOfSlice(combined, combinedSource, 0, this.head.length);
|
|
49
|
+
}
|
|
50
|
+
const tailLimit = this.limit - 1 - headLimit;
|
|
51
|
+
if (tailLimit > 0) {
|
|
52
|
+
const from = (this.tail || old).concat(text);
|
|
53
|
+
const fromSource = (this.tail ? this.sourceTail : oldSourceHead) + sourceChars;
|
|
54
|
+
this.tail = from.slice(-tailLimit);
|
|
55
|
+
this.sourceTail = sourceOfSlice(from, fromSource, from.length - this.tail.length, from.length);
|
|
56
|
+
} else {
|
|
57
|
+
this.tail = "";
|
|
58
|
+
this.sourceTail = 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
this.total += text.length;
|
|
62
|
+
this.sourceTotal += sourceChars;
|
|
63
|
+
}
|
|
64
|
+
get omittedChars(): number {
|
|
65
|
+
if (this.total <= this.limit) return 0;
|
|
66
|
+
return Math.max(0, this.sourceTotal - this.sourceHead - this.sourceTail);
|
|
67
|
+
}
|
|
68
|
+
get text(): string { return this.total > this.limit ? `${this.head}…${this.tail}` : this.head; }
|
|
69
|
+
}
|
|
70
|
+
|
|
20
71
|
export class LineBatcher {
|
|
21
72
|
private readonly flushMs: number;
|
|
22
73
|
private readonly maxLineChars: number;
|
|
23
74
|
private readonly maxBatchChars: number;
|
|
24
|
-
private readonly onFlush:
|
|
75
|
+
private readonly onFlush: LineBatcherOptions["onFlush"];
|
|
25
76
|
private readonly clock: Clock;
|
|
26
|
-
|
|
27
|
-
private
|
|
28
|
-
private
|
|
77
|
+
private partial: BoundedPreview;
|
|
78
|
+
private lines: BoundedPreview;
|
|
79
|
+
private lineCount = 0;
|
|
80
|
+
private omittedLineChars = 0;
|
|
29
81
|
private timer: ClockTimer | null = null;
|
|
30
82
|
private disposed = false;
|
|
31
83
|
|
|
@@ -35,49 +87,52 @@ export class LineBatcher {
|
|
|
35
87
|
this.maxBatchChars = opts.maxBatchChars ?? 3000;
|
|
36
88
|
this.onFlush = opts.onFlush;
|
|
37
89
|
this.clock = opts.clock ?? realClock;
|
|
90
|
+
this.partial = new BoundedPreview(this.maxLineChars);
|
|
91
|
+
this.lines = new BoundedPreview(this.maxBatchChars);
|
|
38
92
|
}
|
|
39
93
|
|
|
40
|
-
/**
|
|
94
|
+
/** Bound retained data during ingestion, not just after the timer fires. */
|
|
41
95
|
push(chunk: string): void {
|
|
42
96
|
if (this.disposed || chunk.length === 0) return;
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
this.lines.push(this.capLine(line));
|
|
97
|
+
const parts = chunk.split("\n");
|
|
98
|
+
for (let i = 0; i < parts.length; i++) {
|
|
99
|
+
this.partial.append(parts[i]);
|
|
100
|
+
if (i < parts.length - 1) this.finishLine();
|
|
48
101
|
}
|
|
49
|
-
|
|
102
|
+
this.schedule();
|
|
50
103
|
}
|
|
51
104
|
|
|
52
|
-
/**
|
|
53
|
-
* Immediately emit the current buffer when non-empty, including any
|
|
54
|
-
* unterminated trailing line. Used when a monitored process exits.
|
|
55
|
-
*/
|
|
56
105
|
flush(): void {
|
|
57
106
|
if (this.disposed) return;
|
|
58
107
|
this.clearTimer();
|
|
59
108
|
this.emit();
|
|
60
109
|
}
|
|
61
110
|
|
|
62
|
-
/** Clear the pending timer and drop buffered content. */
|
|
63
111
|
dispose(): void {
|
|
64
112
|
this.disposed = true;
|
|
65
113
|
this.clearTimer();
|
|
66
|
-
this.
|
|
67
|
-
this.partial = "";
|
|
114
|
+
this.reset();
|
|
68
115
|
}
|
|
69
116
|
|
|
70
|
-
private
|
|
71
|
-
if (
|
|
72
|
-
|
|
117
|
+
private finishLine(): void {
|
|
118
|
+
if (this.lineCount++ > 0) this.lines.append("\n", 1);
|
|
119
|
+
const displayed = this.partial.text;
|
|
120
|
+
this.lines.append(displayed, this.partial.total - this.partial.omittedChars);
|
|
121
|
+
this.omittedLineChars += this.partial.omittedChars;
|
|
122
|
+
this.partial = new BoundedPreview(this.maxLineChars);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private reset(): void {
|
|
126
|
+
this.partial = new BoundedPreview(this.maxLineChars);
|
|
127
|
+
this.lines = new BoundedPreview(this.maxBatchChars);
|
|
128
|
+
this.lineCount = 0;
|
|
129
|
+
this.omittedLineChars = 0;
|
|
73
130
|
}
|
|
74
131
|
|
|
75
132
|
private schedule(): void {
|
|
76
133
|
if (this.timer !== null) return;
|
|
77
134
|
this.timer = this.clock.setTimeout(() => {
|
|
78
135
|
this.timer = null;
|
|
79
|
-
// Window end drains everything, including an unterminated trailing
|
|
80
|
-
// line (no data loss); a fresh window starts on the next push.
|
|
81
136
|
this.emit();
|
|
82
137
|
}, this.flushMs);
|
|
83
138
|
this.clock.unref?.(this.timer);
|
|
@@ -91,18 +146,11 @@ export class LineBatcher {
|
|
|
91
146
|
}
|
|
92
147
|
|
|
93
148
|
private emit(): void {
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
this.
|
|
98
|
-
this.
|
|
99
|
-
|
|
100
|
-
let text = partial.length > 0 ? [...lines, partial].join("\n") : lines.join("\n");
|
|
101
|
-
if (text.length > this.maxBatchChars) {
|
|
102
|
-
// Keep the tail: for monitoring, the most recent lines matter most.
|
|
103
|
-
text = `…${text.slice(-(this.maxBatchChars - 1))}`;
|
|
104
|
-
}
|
|
105
|
-
if (text.length > 0) this.onFlush(text);
|
|
149
|
+
if (this.partial.total > 0) this.finishLine();
|
|
150
|
+
const text = this.lines.text;
|
|
151
|
+
const omittedChars = this.omittedLineChars + this.lines.omittedChars;
|
|
152
|
+
this.reset();
|
|
153
|
+
if (text.length > 0) this.onFlush(text, { truncated: omittedChars > 0, omittedChars });
|
|
106
154
|
}
|
|
107
155
|
}
|
|
108
156
|
|