pi-famulus 0.1.7 → 0.1.9
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 +14 -7
- package/bin/pi-famulus.js +2 -2
- package/package.json +8 -8
- package/src/bash-override.ts +34 -26
- package/src/bounded-output.ts +167 -0
- package/src/comms/reply-command.ts +1 -1
- package/src/config.ts +2 -6
- package/src/format.ts +33 -7
- package/src/index.ts +39 -8
- package/src/manager-client.ts +24 -2
- package/src/monitor-batching.ts +84 -36
- package/src/monitor-output.ts +159 -0
- package/src/monitor.ts +148 -37
- package/src/native-manager.d.ts +30 -0
- package/src/native-manager.js +260 -11
- package/src/notify.ts +100 -17
- package/src/prompts.generated.ts +14 -11
- 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/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 -84
- package/src/tui/tool-component.ts +6 -2
- package/src/wake.ts +27 -1
package/src/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { createRegistryCommsHost } from "./comms/registry-host";
|
|
|
24
24
|
import { createAgentMessageTool, createContactSupervisorTool } from "./comms/tools";
|
|
25
25
|
import { registerReplyCommand } from "./comms/reply-command";
|
|
26
26
|
import { describeManagerSearch, getFamulusHome, loadConfig, resolveManagerPath, resolveSubagentConfig } from "./config";
|
|
27
|
+
import { ensureNativeManagerInstalled } from "./native-manager.js";
|
|
27
28
|
import type { TaskExitInfo } from "./format";
|
|
28
29
|
import { EXTENSION_VERSION, ManagerClient, type ManagerEvent, type TaskRecord } from "./manager-client";
|
|
29
30
|
import { createMonitorTool, exitEventFromRecord, MonitorRegistry } from "./monitor";
|
|
@@ -99,7 +100,28 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
99
100
|
registerBehaviorGuidelines(pi);
|
|
100
101
|
const home = getFamulusHome();
|
|
101
102
|
const config = loadConfig(home);
|
|
102
|
-
|
|
103
|
+
// pi update reinstalls extensions with npm. omit=optional drops the platform
|
|
104
|
+
// package during that install. Fetch it in the background so a slow or
|
|
105
|
+
// unreachable registry cannot stall extension startup.
|
|
106
|
+
let managerPath = resolveManagerPath(config, home);
|
|
107
|
+
let famulusInfo: {
|
|
108
|
+
extensionVersion: string;
|
|
109
|
+
managerPath: string | null;
|
|
110
|
+
describeSearch: () => string;
|
|
111
|
+
getClient: () => ManagerClient | null;
|
|
112
|
+
} | null = null;
|
|
113
|
+
const nativeRestore: Promise<string | null> = managerPath
|
|
114
|
+
? Promise.resolve(managerPath)
|
|
115
|
+
: Promise.resolve()
|
|
116
|
+
.then(() => ensureNativeManagerInstalled())
|
|
117
|
+
.then((path) => {
|
|
118
|
+
if (path) {
|
|
119
|
+
managerPath = path;
|
|
120
|
+
if (famulusInfo) famulusInfo.managerPath = path;
|
|
121
|
+
}
|
|
122
|
+
return path;
|
|
123
|
+
})
|
|
124
|
+
.catch(() => null);
|
|
103
125
|
const clock = realClock;
|
|
104
126
|
|
|
105
127
|
// Claude-style transcript pills for notifications (TUI only; no-ops elsewhere).
|
|
@@ -302,6 +324,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
302
324
|
status: toExitStatus(event),
|
|
303
325
|
exitCode: event.exit_code ?? null,
|
|
304
326
|
...(typeof event.signal === "string" && event.signal ? { signal: event.signal } : {}),
|
|
327
|
+
...(event.end_reason ? { endReason: event.end_reason } : {}),
|
|
305
328
|
durationMs: event.duration_ms ?? 0,
|
|
306
329
|
outputPath: event.output_path ?? "",
|
|
307
330
|
preview: readPreview(event.output_path, 4000),
|
|
@@ -381,7 +404,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
381
404
|
noteStopRequested: (id) => monitorRegistry?.noteStopRequested(id),
|
|
382
405
|
}),
|
|
383
406
|
);
|
|
384
|
-
pi.registerTool(createMonitorTool(monitorRegistry));
|
|
407
|
+
pi.registerTool(createMonitorTool(monitorRegistry, () => workIndex));
|
|
385
408
|
registerTasksCommand(pi, {
|
|
386
409
|
getRegistry: () => subagentRegistry,
|
|
387
410
|
getIndex: () => workIndex,
|
|
@@ -391,12 +414,13 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
391
414
|
clock,
|
|
392
415
|
syncWithManager: deps.syncWithManager,
|
|
393
416
|
});
|
|
394
|
-
|
|
417
|
+
famulusInfo = {
|
|
395
418
|
extensionVersion: EXTENSION_VERSION,
|
|
396
419
|
managerPath,
|
|
397
420
|
describeSearch: () => describeManagerSearch(config, home),
|
|
398
421
|
getClient: () => client,
|
|
399
|
-
}
|
|
422
|
+
};
|
|
423
|
+
registerFamulusCommand(pi, famulusInfo);
|
|
400
424
|
monitorRegistry.onChange(() => {
|
|
401
425
|
for (const mon of monitorRegistry.listActive()) {
|
|
402
426
|
const existing = workIndex.get(mon.taskId);
|
|
@@ -556,7 +580,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
556
580
|
return;
|
|
557
581
|
}
|
|
558
582
|
if (event.event === "output" && event.task_id && typeof event.chunk === "string") {
|
|
559
|
-
monitorRegistry?.handleOutput(event.task_id, event.chunk, event.next_cursor);
|
|
583
|
+
monitorRegistry?.handleOutput(event.task_id, event.chunk, event.next_cursor, event.monitor_records);
|
|
560
584
|
return;
|
|
561
585
|
}
|
|
562
586
|
if (event.event === "task_exited" && event.task_id) {
|
|
@@ -818,11 +842,18 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
818
842
|
startFleetWidget(startCtx);
|
|
819
843
|
|
|
820
844
|
// Connect in the background: a cold manager spawn must not stall the
|
|
821
|
-
// first prompt.
|
|
845
|
+
// first prompt. The cache install, if one is running, finishes first so a
|
|
846
|
+
// missing optional package is not reported before it can be restored.
|
|
847
|
+
// Tools call ensureAvailable() lazily before use.
|
|
822
848
|
const c = client;
|
|
823
|
-
void
|
|
824
|
-
.
|
|
849
|
+
void nativeRestore
|
|
850
|
+
.then((path) => {
|
|
851
|
+
if (client !== c || !c) return null;
|
|
852
|
+
if (path) c.useManagerPath(path);
|
|
853
|
+
return c.connect();
|
|
854
|
+
})
|
|
825
855
|
.then((ok) => {
|
|
856
|
+
if (ok === null || client !== c || !c) return;
|
|
826
857
|
if (ok) {
|
|
827
858
|
if (c.protocolLevel() >= 4) void reconcileAgentLeases(c).catch(() => {});
|
|
828
859
|
return;
|
package/src/manager-client.ts
CHANGED
|
@@ -18,7 +18,7 @@ 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.9";
|
|
22
22
|
export { EXTENSION_VERSION };
|
|
23
23
|
// 5: speaks per-kind agent admission and queued acquire.
|
|
24
24
|
const PROTOCOL = 5;
|
|
@@ -52,6 +52,8 @@ export interface StartRequest {
|
|
|
52
52
|
run_in_background?: boolean;
|
|
53
53
|
timeout_ms?: number | null;
|
|
54
54
|
origin?: TaskOrigin;
|
|
55
|
+
/** Human-readable name (monitor description). */
|
|
56
|
+
label?: string;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
export interface StartResponse {
|
|
@@ -91,6 +93,19 @@ export interface TaskRecord {
|
|
|
91
93
|
end_reason?: string;
|
|
92
94
|
}
|
|
93
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
|
+
|
|
94
109
|
/** Server-pushed event (§3.3). Fields beyond `event` depend on the event kind. */
|
|
95
110
|
export interface ManagerEvent {
|
|
96
111
|
event: "task_started" | "output" | "task_exited" | "session_rebound" | string;
|
|
@@ -100,6 +115,8 @@ export interface ManagerEvent {
|
|
|
100
115
|
pid?: number;
|
|
101
116
|
chunk?: string;
|
|
102
117
|
next_cursor?: number;
|
|
118
|
+
/** Absent on legacy managers/replay fallback; [] means Rust framing is active. */
|
|
119
|
+
monitor_records?: MonitorOutputRecord[];
|
|
103
120
|
exit_code?: number | null;
|
|
104
121
|
signal?: string | null;
|
|
105
122
|
duration_ms?: number;
|
|
@@ -268,7 +285,7 @@ function delay(clock: Clock, ms: number): Promise<void> {
|
|
|
268
285
|
export class ManagerClient {
|
|
269
286
|
private readonly home: string;
|
|
270
287
|
private readonly sessionId: string;
|
|
271
|
-
private
|
|
288
|
+
private managerPath: string | null;
|
|
272
289
|
private readonly piPid: number;
|
|
273
290
|
private readonly cwd: string | undefined;
|
|
274
291
|
private readonly log: (message: string) => void;
|
|
@@ -310,6 +327,11 @@ export class ManagerClient {
|
|
|
310
327
|
return this.state === "connected";
|
|
311
328
|
}
|
|
312
329
|
|
|
330
|
+
/** A binary found after construction. The first path wins. */
|
|
331
|
+
useManagerPath(path: string): void {
|
|
332
|
+
if (!this.managerPath) this.managerPath = path;
|
|
333
|
+
}
|
|
334
|
+
|
|
313
335
|
/** Maximum protocol level reported by the connected manager; 0 means unknown/legacy. */
|
|
314
336
|
protocolLevel(): number {
|
|
315
337
|
return this.protocolLevel_;
|
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
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { LineBatcher } from "./monitor-batching";
|
|
2
|
+
import { realClock, type Clock } from "./clock";
|
|
3
|
+
import type { MonitorOutputRecord } from "./manager-client";
|
|
4
|
+
import type { MonitorOutputInfo } from "./wake";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_DEDUP_WINDOW_MS = 60_000;
|
|
7
|
+
export const MAX_DEDUP_KEYS = 2048;
|
|
8
|
+
export type FingerprintMode = "exact" | "ignore-timestamps";
|
|
9
|
+
export interface MonitorOutputBatch { text: string; output: MonitorOutputInfo }
|
|
10
|
+
interface Identity { phase: "buffered" | "queued" | "emitted"; lastEmitted?: number; preview: string }
|
|
11
|
+
|
|
12
|
+
/** Merge original-byte range envelopes, not a promise of contiguous identical events. */
|
|
13
|
+
export function mergeOutputInfo(a: MonitorOutputInfo, b: MonitorOutputInfo): MonitorOutputInfo {
|
|
14
|
+
const min = (x?: number, y?: number) => x === undefined ? y : y === undefined ? x : Math.min(x, y);
|
|
15
|
+
const max = (x?: number, y?: number) => x === undefined ? y : y === undefined ? x : Math.max(x, y);
|
|
16
|
+
return {
|
|
17
|
+
firstObservedAt: min(a.firstObservedAt, b.firstObservedAt),
|
|
18
|
+
lastObservedAt: max(a.lastObservedAt, b.lastObservedAt),
|
|
19
|
+
cursorStart: min(a.cursorStart, b.cursorStart), cursorEnd: max(a.cursorEnd, b.cursorEnd),
|
|
20
|
+
truncated: a.truncated || b.truncated,
|
|
21
|
+
suppressedCount: (a.suppressedCount ?? 0) + (b.suppressedCount ?? 0),
|
|
22
|
+
omittedChars: (a.omittedChars ?? 0) + (b.omittedChars ?? 0),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Rust owns framing and hashing; this module owns the notification window.
|
|
28
|
+
* Pending identities are suppressed until onDelivered starts their fixed window.
|
|
29
|
+
* No timers send repeat-only summaries. Original bytes stay in the manager log.
|
|
30
|
+
*/
|
|
31
|
+
export class MonitorOutputBuffer {
|
|
32
|
+
private readonly clock: Clock;
|
|
33
|
+
private readonly batcher: LineBatcher;
|
|
34
|
+
private readonly identities = new Map<string, Identity>();
|
|
35
|
+
private batchKeys = new Set<string>();
|
|
36
|
+
private batchOutput: MonitorOutputInfo = {};
|
|
37
|
+
private suppressed: MonitorOutputInfo = {};
|
|
38
|
+
private disposed = false;
|
|
39
|
+
private lastNow = 0;
|
|
40
|
+
|
|
41
|
+
constructor(private readonly options: {
|
|
42
|
+
clock?: Clock;
|
|
43
|
+
windowMs?: number;
|
|
44
|
+
mode?: FingerprintMode;
|
|
45
|
+
onFlush: (batch: MonitorOutputBatch) => boolean;
|
|
46
|
+
onSuppressed?: () => void;
|
|
47
|
+
}) {
|
|
48
|
+
this.clock = options.clock ?? realClock;
|
|
49
|
+
this.batcher = new LineBatcher({ clock: this.clock, onFlush: (text, info) => {
|
|
50
|
+
this.batchOutput = mergeOutputInfo(this.batchOutput, info);
|
|
51
|
+
this.emit(text);
|
|
52
|
+
} });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Stable callback identity: NotifyCenter retains it once per pending monitor. */
|
|
56
|
+
readonly onDelivered = (visiblePreview?: string): void => {
|
|
57
|
+
const now = this.now();
|
|
58
|
+
const visibleLines = visiblePreview === undefined ? undefined : new Set(visiblePreview ? visiblePreview.split("\n") : []);
|
|
59
|
+
const queuedPreviews = new Map<string, number>();
|
|
60
|
+
if (visibleLines) {
|
|
61
|
+
for (const identity of this.identities.values()) {
|
|
62
|
+
if (identity.phase === "queued") {
|
|
63
|
+
queuedPreviews.set(identity.preview, (queuedPreviews.get(identity.preview) ?? 0) + 1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const [key, identity] of this.identities) {
|
|
68
|
+
if (identity.phase !== "queued") continue;
|
|
69
|
+
// Both local batching and busy coalescing can omit an entire identity.
|
|
70
|
+
// Never pretend the model saw it: truncated representatives fail open.
|
|
71
|
+
// Distinct fingerprints can share a truncated preview; fail those open too.
|
|
72
|
+
const ambiguous = (queuedPreviews.get(identity.preview) ?? 0) > 1;
|
|
73
|
+
if (visibleLines && (ambiguous || !visibleLines.has(identity.preview))) {
|
|
74
|
+
if (identity.lastEmitted === undefined) this.identities.delete(key);
|
|
75
|
+
else identity.phase = "emitted";
|
|
76
|
+
} else {
|
|
77
|
+
identity.phase = "emitted";
|
|
78
|
+
identity.lastEmitted = now;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
push(record: MonitorOutputRecord): void {
|
|
84
|
+
if (this.disposed || !record.preview) return;
|
|
85
|
+
const now = this.now();
|
|
86
|
+
const info: MonitorOutputInfo = {
|
|
87
|
+
firstObservedAt: record.observed_at, lastObservedAt: record.observed_at,
|
|
88
|
+
cursorStart: record.cursor_start, cursorEnd: record.cursor_end, truncated: record.truncated,
|
|
89
|
+
};
|
|
90
|
+
const hash = record.kind === "line"
|
|
91
|
+
? this.options.mode === "ignore-timestamps" ? record.timestamp_fingerprint : record.fingerprint
|
|
92
|
+
: undefined;
|
|
93
|
+
const window = this.options.windowMs ?? DEFAULT_DEDUP_WINDOW_MS;
|
|
94
|
+
const key = window > 0 ? hash : undefined;
|
|
95
|
+
if (key) {
|
|
96
|
+
const previous = this.identities.get(key);
|
|
97
|
+
if (previous && (previous.phase !== "emitted" || now - previous.lastEmitted! < window)) {
|
|
98
|
+
this.suppressed = mergeOutputInfo(this.suppressed, { ...info, suppressedCount: 1 });
|
|
99
|
+
this.options.onSuppressed?.();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Capacity eviction fails open: extra output is preferable to false suppression.
|
|
103
|
+
if (!previous && this.identities.size >= MAX_DEDUP_KEYS) {
|
|
104
|
+
const oldest = this.identities.keys().next().value;
|
|
105
|
+
if (oldest !== undefined) {
|
|
106
|
+
this.identities.delete(oldest);
|
|
107
|
+
this.batchKeys.delete(oldest);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
this.identities.set(key, { phase: "buffered", lastEmitted: previous?.lastEmitted, preview: record.preview });
|
|
111
|
+
this.batchKeys.add(key);
|
|
112
|
+
}
|
|
113
|
+
this.batchOutput = mergeOutputInfo(mergeOutputInfo(this.batchOutput, this.suppressed), info);
|
|
114
|
+
this.suppressed = {};
|
|
115
|
+
this.batcher.push(`${record.preview}\n`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
flush(): void { this.batcher.flush(); }
|
|
119
|
+
|
|
120
|
+
/** Attach unreported repeats to the terminal notice without another event/wake. */
|
|
121
|
+
takeSummary(): MonitorOutputInfo {
|
|
122
|
+
const summary = this.suppressed;
|
|
123
|
+
this.suppressed = {};
|
|
124
|
+
return summary;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
dispose(): void {
|
|
128
|
+
this.disposed = true;
|
|
129
|
+
this.batcher.dispose();
|
|
130
|
+
this.identities.clear();
|
|
131
|
+
this.batchKeys.clear();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private now(): number {
|
|
135
|
+
this.lastNow = Math.max(this.lastNow, this.clock.now());
|
|
136
|
+
return this.lastNow;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private emit(text: string): void {
|
|
140
|
+
const keys = this.batchKeys;
|
|
141
|
+
const output = this.batchOutput;
|
|
142
|
+
this.batchKeys = new Set();
|
|
143
|
+
this.batchOutput = {};
|
|
144
|
+
for (const key of keys) {
|
|
145
|
+
const identity = this.identities.get(key);
|
|
146
|
+
if (identity?.phase === "buffered") identity.phase = "queued";
|
|
147
|
+
}
|
|
148
|
+
if (!this.options.onFlush({ text, output })) {
|
|
149
|
+
// A rate-limited batch wasn't delivered: don't start a suppression window.
|
|
150
|
+
for (const key of keys) {
|
|
151
|
+
const identity = this.identities.get(key);
|
|
152
|
+
if (identity?.phase !== "queued") continue;
|
|
153
|
+
if (identity.lastEmitted === undefined) this.identities.delete(key);
|
|
154
|
+
else identity.phase = "emitted";
|
|
155
|
+
}
|
|
156
|
+
this.suppressed = mergeOutputInfo(this.suppressed, { ...output, suppressedCount: output.suppressedCount ?? 0 });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|