pi-famulus 0.1.7 → 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 +14 -7
- 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/format.ts +33 -7
- package/src/index.ts +3 -2
- package/src/manager-client.ts +18 -1
- package/src/monitor-batching.ts +84 -36
- package/src/monitor-output.ts +159 -0
- package/src/monitor.ts +148 -37
- 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/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
|
+
}
|