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/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
- return { glyph: "⏵", color: "accent", text: `${taskId} running in background · /tasks` };
189
+ const { glyph, color } = statusGlyph(item?.status ?? "running");
190
+ return { glyph, color, text: `${taskId} running in background · /tasks` };
166
191
  }
167
- const secs = ((item.endedAt ?? now) - item.startedAt) / 1000;
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 ok
172
- ? { glyph: "✓", color: "success", text: `${taskId} finished${exit} · ${dur}` }
173
- : { glyph: "✗", color: "error", text: `${taskId} ${item.status}${exit} · ${dur} · /tasks` };
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
- pi.on("session_start", async (_event, startCtx) => {
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(startCtx.sessionManager.getSessionId(), childId, handle.conversation());
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 = () => startCtx.sessionManager.getSessionId();
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: startCtx.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
- if (startCtx.hasUI) {
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
- if (startCtx.hasUI) {
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
- startCtx.ui.notify(
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
- pi.on("session_shutdown", async () => {
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
  }
@@ -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 22s,
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.6";
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) this.helloWaiter.resolve();
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 {
@@ -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: (text: string) => void;
75
+ private readonly onFlush: LineBatcherOptions["onFlush"];
25
76
  private readonly clock: Clock;
26
-
27
- private partial = ""; // trailing bytes not yet terminated by \n
28
- private lines: string[] = []; // complete lines awaiting emission
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
- /** Feed a raw output chunk (may contain no/newlines or multiple lines). */
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 combined = this.partial + chunk;
44
- const parts = combined.split("\n");
45
- this.partial = parts.pop() ?? "";
46
- for (const line of parts) {
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
- if (this.lines.length > 0 || this.partial.length > 0) this.schedule();
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.lines = [];
67
- this.partial = "";
114
+ this.reset();
68
115
  }
69
116
 
70
- private capLine(line: string): string {
71
- if (line.length <= this.maxLineChars) return line;
72
- return line.slice(0, this.maxLineChars);
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
- const lines = this.lines;
95
- const partial = this.partial.length > 0 ? this.capLine(this.partial) : "";
96
- if (lines.length === 0 && partial.length === 0) return;
97
- this.lines = [];
98
- this.partial = "";
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