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.ts
CHANGED
|
@@ -8,16 +8,20 @@
|
|
|
8
8
|
* 30-second window.
|
|
9
9
|
*/
|
|
10
10
|
import { Type } from "typebox";
|
|
11
|
+
import { shellStartStatus } from "./shell-start";
|
|
11
12
|
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
12
13
|
import { formatMonitorEvent } from "./format";
|
|
13
14
|
import { MONITOR_IDLE_INSTRUCTION } from "./behavior-guidelines";
|
|
14
15
|
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
15
16
|
|
|
16
|
-
import type { ManagerClient, ManagerEvent, TaskRecord } from "./manager-client";
|
|
17
|
+
import type { ManagerClient, ManagerEvent, MonitorOutputRecord, TaskRecord } from "./manager-client";
|
|
18
|
+
import { DEFAULT_DEDUP_WINDOW_MS, MonitorOutputBuffer, mergeOutputInfo, type FingerprintMode } from "./monitor-output";
|
|
19
|
+
import type { MonitorOutputInfo } from "./wake";
|
|
17
20
|
import { LineBatcher, RateLimiter, SaturationWindow } from "./monitor-batching";
|
|
18
21
|
import type { NotifyCenter } from "./notify";
|
|
19
22
|
import { fill, fillList, PROMPTS } from "./prompts.generated";
|
|
20
23
|
import { statusGlyph, toolComponent } from "./tui/tool-component";
|
|
24
|
+
import type { WorkIndex } from "./work-index";
|
|
21
25
|
|
|
22
26
|
|
|
23
27
|
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
@@ -75,6 +79,10 @@ interface MonitorEntry {
|
|
|
75
79
|
description: string;
|
|
76
80
|
startedAt: number;
|
|
77
81
|
batcher: LineBatcher;
|
|
82
|
+
output: MonitorOutputBuffer;
|
|
83
|
+
legacyOutput: MonitorOutputInfo;
|
|
84
|
+
recordCursor: number;
|
|
85
|
+
lastRecordKind?: "line" | "progress";
|
|
78
86
|
limiter: RateLimiter;
|
|
79
87
|
saturation: SaturationWindow;
|
|
80
88
|
droppedLinesPending: number;
|
|
@@ -82,10 +90,12 @@ interface MonitorEntry {
|
|
|
82
90
|
stopped: boolean;
|
|
83
91
|
cursor: number;
|
|
84
92
|
recovering: boolean;
|
|
85
|
-
queuedOutput: { chunk: string; cursor?: number }[];
|
|
93
|
+
queuedOutput: { chunk: string; cursor?: number; records?: MonitorOutputRecord[] }[];
|
|
86
94
|
lastEventAt?: number;
|
|
87
95
|
/** The model asked to stop it (task_stop): what is left is not news. */
|
|
88
96
|
stopRequested?: boolean;
|
|
97
|
+
/** Prevents autoStop from re-entering while it drains the 200ms batcher. */
|
|
98
|
+
stopping?: boolean;
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
export class MonitorRegistry {
|
|
@@ -93,7 +103,7 @@ export class MonitorRegistry {
|
|
|
93
103
|
private readonly clock: Clock;
|
|
94
104
|
private readonly entries = new Map<string, MonitorEntry>();
|
|
95
105
|
private readonly changeListeners = new Set<() => void>();
|
|
96
|
-
private readonly early = new Map<string, { chunks: { chunk: string; cursor?: number }[]; chars: number; exit?: ManagerEvent }>();
|
|
106
|
+
private readonly early = new Map<string, { chunks: { chunk: string; cursor?: number; records?: MonitorOutputRecord[] }[]; chars: number; exit?: ManagerEvent }>();
|
|
97
107
|
|
|
98
108
|
constructor(deps: MonitorDeps) {
|
|
99
109
|
this.deps = deps;
|
|
@@ -130,7 +140,7 @@ export class MonitorRegistry {
|
|
|
130
140
|
|
|
131
141
|
/** Start a monitor process and subscribe to its output stream. */
|
|
132
142
|
async start(
|
|
133
|
-
params: { command: string; description: string; timeout_ms?: number; persistent?: boolean },
|
|
143
|
+
params: { command: string; description: string; timeout_ms?: number; persistent?: boolean; dedup_window_ms?: number; fingerprint_mode?: FingerprintMode },
|
|
134
144
|
ctx: ExtensionContext,
|
|
135
145
|
): Promise<{ taskId: string; timeoutMs: number | null }> {
|
|
136
146
|
const client = this.deps.getClient();
|
|
@@ -138,6 +148,12 @@ export class MonitorRegistry {
|
|
|
138
148
|
throw new Error("pi-famulus is not available in this session; monitor is disabled");
|
|
139
149
|
}
|
|
140
150
|
|
|
151
|
+
if (params.dedup_window_ms !== undefined && (!Number.isSafeInteger(params.dedup_window_ms) || params.dedup_window_ms < 0 || params.dedup_window_ms > 3_600_000)) {
|
|
152
|
+
throw new RangeError("dedup_window_ms must be an integer between 0 and 3600000");
|
|
153
|
+
}
|
|
154
|
+
if (params.fingerprint_mode !== undefined && params.fingerprint_mode !== "exact" && params.fingerprint_mode !== "ignore-timestamps") {
|
|
155
|
+
throw new RangeError("fingerprint_mode must be exact or ignore-timestamps");
|
|
156
|
+
}
|
|
141
157
|
const persistent = params.persistent === true;
|
|
142
158
|
let timeoutMs: number | null = null;
|
|
143
159
|
if (!persistent) {
|
|
@@ -153,14 +169,19 @@ export class MonitorRegistry {
|
|
|
153
169
|
run_in_background: true,
|
|
154
170
|
timeout_ms: null, // timeout is enforced extension-side to control the notice
|
|
155
171
|
origin: { via: "monitor" },
|
|
172
|
+
label: params.description,
|
|
156
173
|
});
|
|
157
174
|
this.deps.trackTask(task_id, { kind: "monitor", command: params.command, cwd: ctx.cwd });
|
|
175
|
+
this.deps.logEvent?.("monitor.start", { id: task_id, description: params.description });
|
|
158
176
|
|
|
159
177
|
const entry: MonitorEntry = {
|
|
160
178
|
taskId: task_id,
|
|
161
179
|
description: params.description,
|
|
162
180
|
startedAt: this.clock.now(),
|
|
163
181
|
batcher: null as unknown as LineBatcher, // assigned below (self-reference in callback)
|
|
182
|
+
output: null as unknown as MonitorOutputBuffer,
|
|
183
|
+
legacyOutput: {},
|
|
184
|
+
recordCursor: -1,
|
|
164
185
|
limiter: new RateLimiter({ clock: this.clock }),
|
|
165
186
|
saturation: new SaturationWindow({
|
|
166
187
|
windowMs: SATURATION_WINDOW_MS,
|
|
@@ -175,9 +196,20 @@ export class MonitorRegistry {
|
|
|
175
196
|
queuedOutput: [],
|
|
176
197
|
};
|
|
177
198
|
entry.batcher = new LineBatcher({
|
|
178
|
-
onFlush: (text) =>
|
|
199
|
+
onFlush: (text, info) => {
|
|
200
|
+
const output = mergeOutputInfo(entry.legacyOutput, info);
|
|
201
|
+
entry.legacyOutput = {};
|
|
202
|
+
this.onBatch(entry, text, output);
|
|
203
|
+
},
|
|
179
204
|
clock: this.clock,
|
|
180
205
|
});
|
|
206
|
+
entry.output = new MonitorOutputBuffer({
|
|
207
|
+
clock: this.clock,
|
|
208
|
+
windowMs: params.dedup_window_ms ?? DEFAULT_DEDUP_WINDOW_MS,
|
|
209
|
+
mode: params.fingerprint_mode ?? "exact",
|
|
210
|
+
onFlush: ({ text, output }) => this.onBatch(entry, text, output, entry.output.onDelivered),
|
|
211
|
+
onSuppressed: () => this.deps.logEvent?.("monitor.dedupe", { id: entry.taskId }),
|
|
212
|
+
});
|
|
181
213
|
if (timeoutMs !== null) {
|
|
182
214
|
entry.timeoutTimer = this.clock.setTimeout(() => {
|
|
183
215
|
void this.timeout(entry);
|
|
@@ -189,7 +221,7 @@ export class MonitorRegistry {
|
|
|
189
221
|
// Replay what arrived before we knew the id: lines first, then the exit.
|
|
190
222
|
const early = this.early.get(task_id);
|
|
191
223
|
this.early.delete(task_id);
|
|
192
|
-
for (const output of early?.chunks ?? []) this.acceptOutput(entry, output.chunk, output.cursor);
|
|
224
|
+
for (const output of early?.chunks ?? []) this.acceptOutput(entry, output.chunk, output.cursor, output.records);
|
|
193
225
|
if (early?.exit) this.handleExit(task_id, early.exit);
|
|
194
226
|
// Older managers only stream after an explicit watch; newer ones already do.
|
|
195
227
|
else await client.watch(task_id).catch(() => {});
|
|
@@ -197,32 +229,60 @@ export class MonitorRegistry {
|
|
|
197
229
|
}
|
|
198
230
|
|
|
199
231
|
/** Handle a watched output event from the manager. */
|
|
200
|
-
handleOutput(taskId: string, chunk: string, nextCursor?: number): void {
|
|
232
|
+
handleOutput(taskId: string, chunk: string, nextCursor?: number, records?: MonitorOutputRecord[]): void {
|
|
201
233
|
const entry = this.entries.get(taskId);
|
|
202
234
|
if (entry) {
|
|
203
|
-
if (entry.recovering) entry.queuedOutput.push({ chunk, cursor: nextCursor });
|
|
204
|
-
else this.acceptOutput(entry, chunk, nextCursor);
|
|
235
|
+
if (entry.recovering) entry.queuedOutput.push({ chunk, cursor: nextCursor, records });
|
|
236
|
+
else this.acceptOutput(entry, chunk, nextCursor, records);
|
|
205
237
|
return;
|
|
206
238
|
}
|
|
207
239
|
const early = this.earlyFor(taskId);
|
|
208
|
-
|
|
209
|
-
early.
|
|
210
|
-
|
|
240
|
+
const cost = chunk.length + (records?.reduce((n, r) => n + r.preview.length + 256, 0) ?? 0);
|
|
241
|
+
if (early.chars + cost > EARLY_MAX_CHARS) {
|
|
242
|
+
// Metadata expansion (e.g. hundreds of empty lines) must not discard
|
|
243
|
+
// startup bytes which fit the budget. Convert the buffered prefix too:
|
|
244
|
+
// it may contain raw fragments held for a later completed-line record.
|
|
245
|
+
const rawCost = early.chunks.reduce((n, output) => n + output.chunk.length, 0) + chunk.length;
|
|
246
|
+
if (rawCost > EARLY_MAX_CHARS) return;
|
|
247
|
+
for (const output of early.chunks) output.records = undefined;
|
|
248
|
+
early.chunks.push({ chunk, cursor: nextCursor });
|
|
249
|
+
early.chars = rawCost;
|
|
250
|
+
} else {
|
|
251
|
+
early.chunks.push({ chunk, cursor: nextCursor, records });
|
|
252
|
+
early.chars += cost;
|
|
253
|
+
}
|
|
211
254
|
}
|
|
212
255
|
|
|
213
|
-
private acceptOutput(entry: MonitorEntry, chunk: string, nextCursor?: number): void {
|
|
256
|
+
private acceptOutput(entry: MonitorEntry, chunk: string, nextCursor?: number, records?: MonitorOutputRecord[]): void {
|
|
214
257
|
let deliver = Buffer.from(chunk, "utf8");
|
|
258
|
+
const previousCursor = entry.cursor;
|
|
215
259
|
if (typeof nextCursor === "number") {
|
|
216
|
-
if (nextCursor <= entry.cursor)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
deliver = deliver.subarray(entry.cursor - startCursor);
|
|
260
|
+
if (nextCursor <= entry.cursor) deliver = Buffer.alloc(0);
|
|
261
|
+
else {
|
|
262
|
+
const startCursor = nextCursor - deliver.byteLength;
|
|
263
|
+
if (startCursor < entry.cursor) deliver = deliver.subarray(entry.cursor - startCursor);
|
|
264
|
+
entry.cursor = nextCursor;
|
|
220
265
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
266
|
+
} else entry.cursor += deliver.byteLength;
|
|
267
|
+
if (records !== undefined) {
|
|
268
|
+
// Empty metadata arrays deliberately hold raw fragments for Rust framing.
|
|
269
|
+
entry.batcher.flush();
|
|
270
|
+
for (const record of records) {
|
|
271
|
+
if (!validRecord(record, entry.cursor)) continue;
|
|
272
|
+
if (record.cursor_end < entry.recordCursor || (record.cursor_end === entry.recordCursor && !(entry.lastRecordKind === "progress" && record.kind === "line"))) continue;
|
|
273
|
+
entry.recordCursor = record.cursor_end;
|
|
274
|
+
entry.lastRecordKind = record.kind;
|
|
275
|
+
entry.output.push(record);
|
|
276
|
+
}
|
|
277
|
+
} else if (deliver.byteLength > 0) {
|
|
278
|
+
// Legacy manager or unavailable replay metadata: fail open, never hash in JS.
|
|
279
|
+
entry.output.flush();
|
|
280
|
+
entry.legacyOutput = mergeOutputInfo(entry.legacyOutput, {
|
|
281
|
+
firstObservedAt: this.clock.now(), lastObservedAt: this.clock.now(),
|
|
282
|
+
cursorStart: Math.max(previousCursor, entry.cursor - deliver.byteLength), cursorEnd: entry.cursor,
|
|
283
|
+
});
|
|
284
|
+
entry.batcher.push(deliver.toString("utf8"));
|
|
224
285
|
}
|
|
225
|
-
if (deliver.byteLength > 0) entry.batcher.push(deliver.toString("utf8"));
|
|
226
286
|
}
|
|
227
287
|
|
|
228
288
|
/**
|
|
@@ -252,10 +312,12 @@ export class MonitorRegistry {
|
|
|
252
312
|
}
|
|
253
313
|
// Drain remaining buffered lines before closing out.
|
|
254
314
|
entry.batcher.flush();
|
|
315
|
+
entry.output.flush();
|
|
255
316
|
const alreadyStopped = entry.stopped;
|
|
256
317
|
this.cleanup(entry);
|
|
257
318
|
this.deps.onExited?.(taskId, event);
|
|
258
|
-
if (alreadyStopped) return true; // timeout/saturation
|
|
319
|
+
if (alreadyStopped) return true; // timeout/saturation owns the pending summary
|
|
320
|
+
const summary = entry.output.takeSummary();
|
|
259
321
|
const exitCode = event.exit_code ?? null;
|
|
260
322
|
// A clean exit soon after an event is the command finishing on it
|
|
261
323
|
// (grep -m1), unless someone stopped it (TUI stop: end_reason "tui").
|
|
@@ -272,9 +334,9 @@ export class MonitorRegistry {
|
|
|
272
334
|
formatMonitorEvent(
|
|
273
335
|
entry.description,
|
|
274
336
|
entry.taskId,
|
|
275
|
-
`Monitor process exited (exit code ${exitCode === null ? "null" : exitCode}, after ${duration}). No further events will be delivered.${entry.droppedLinesPending > 0 ? ` ${entry.droppedLinesPending} output lines were dropped.` : ""}`,
|
|
337
|
+
`Monitor process exited (exit code ${exitCode === null ? "null" : exitCode}, after ${duration}).${startFailureNote(event.end_reason)} No further events will be delivered.${entry.droppedLinesPending > 0 ? ` ${entry.droppedLinesPending} output lines were dropped.` : ""}`,
|
|
276
338
|
"exited",
|
|
277
|
-
{ droppedLines: entry.droppedLinesPending },
|
|
339
|
+
{ droppedLines: entry.droppedLinesPending, ...(summary.suppressedCount ? { output: summary } : {}) },
|
|
278
340
|
),
|
|
279
341
|
{ passive: causedByEvent || entry.stopRequested === true, quietEvents: entry.stopRequested === true },
|
|
280
342
|
);
|
|
@@ -300,7 +362,7 @@ export class MonitorRegistry {
|
|
|
300
362
|
return closed;
|
|
301
363
|
}
|
|
302
364
|
|
|
303
|
-
private earlyFor(taskId: string): { chunks: { chunk: string; cursor?: number }[]; chars: number; exit?: ManagerEvent } {
|
|
365
|
+
private earlyFor(taskId: string): { chunks: { chunk: string; cursor?: number; records?: MonitorOutputRecord[] }[]; chars: number; exit?: ManagerEvent } {
|
|
304
366
|
let early = this.early.get(taskId);
|
|
305
367
|
if (!early) {
|
|
306
368
|
early = { chunks: [], chars: 0 };
|
|
@@ -321,7 +383,7 @@ export class MonitorRegistry {
|
|
|
321
383
|
await client.watch(entry.taskId).catch(() => {});
|
|
322
384
|
entry.recovering = false;
|
|
323
385
|
const queued = entry.queuedOutput.splice(0).sort((a, b) => (a.cursor ?? 0) - (b.cursor ?? 0));
|
|
324
|
-
for (const output of queued) this.acceptOutput(entry, output.chunk, output.cursor);
|
|
386
|
+
for (const output of queued) this.acceptOutput(entry, output.chunk, output.cursor, output.records);
|
|
325
387
|
}
|
|
326
388
|
}
|
|
327
389
|
|
|
@@ -333,8 +395,10 @@ export class MonitorRegistry {
|
|
|
333
395
|
this.early.clear();
|
|
334
396
|
}
|
|
335
397
|
|
|
336
|
-
private onBatch(entry: MonitorEntry, text: string)
|
|
337
|
-
if (entry.stopped) return;
|
|
398
|
+
private onBatch(entry: MonitorEntry, text: string, output?: MonitorOutputInfo, onDelivered?: () => void): boolean {
|
|
399
|
+
if (entry.stopped) return false;
|
|
400
|
+
const center = this.deps.getNotifyCenter();
|
|
401
|
+
if (!center) return false;
|
|
338
402
|
const now = this.clock.now();
|
|
339
403
|
const accepted = entry.limiter.tryConsume();
|
|
340
404
|
entry.saturation.record(!accepted, now);
|
|
@@ -342,23 +406,30 @@ export class MonitorRegistry {
|
|
|
342
406
|
const droppedLines = text.split("\n").length;
|
|
343
407
|
this.deps.logEvent?.("monitor.drop", { id: entry.taskId, lines: droppedLines });
|
|
344
408
|
entry.droppedLinesPending += droppedLines;
|
|
345
|
-
// Already stopping at the model's request
|
|
346
|
-
|
|
347
|
-
|
|
409
|
+
// Already stopping at the model's request, or draining for autoStop:
|
|
410
|
+
// no nested rate-limit stop or wake.
|
|
411
|
+
if (entry.saturation.isSaturated(now) && !entry.stopRequested && !entry.stopping) void this.autoStop(entry);
|
|
412
|
+
return false;
|
|
348
413
|
}
|
|
349
414
|
const droppedLines = entry.droppedLinesPending;
|
|
350
415
|
entry.droppedLinesPending = 0;
|
|
351
416
|
entry.lastEventAt = now;
|
|
352
417
|
// Lines still buffered when the model stopped it: recorded, coalesced
|
|
353
418
|
// with that monitor's other pending output, and never a wake.
|
|
354
|
-
|
|
419
|
+
center.notifyMonitorEvent(entry.description, entry.taskId, text, droppedLines, {
|
|
355
420
|
quiet: entry.stopRequested === true,
|
|
421
|
+
output,
|
|
422
|
+
onDelivered,
|
|
356
423
|
});
|
|
424
|
+
return true;
|
|
357
425
|
}
|
|
358
426
|
|
|
359
427
|
/** Timeout reached: stop the process and notify (§4.4). */
|
|
360
428
|
private async timeout(entry: MonitorEntry): Promise<void> {
|
|
361
429
|
if (entry.stopped) return;
|
|
430
|
+
entry.batcher.flush();
|
|
431
|
+
entry.output.flush();
|
|
432
|
+
if (entry.stopped) return; // A flush may already have tripped overload protection.
|
|
362
433
|
entry.stopped = true;
|
|
363
434
|
this.deps.logEvent?.("monitor.stop", { id: entry.taskId, reason: "timeout" });
|
|
364
435
|
const client = this.deps.getClient();
|
|
@@ -370,7 +441,7 @@ export class MonitorRegistry {
|
|
|
370
441
|
"[Monitor timed out — re-arm if needed.]",
|
|
371
442
|
|
|
372
443
|
"timeout",
|
|
373
|
-
{ droppedLines: entry.droppedLinesPending },
|
|
444
|
+
{ droppedLines: entry.droppedLinesPending, output: entry.output.takeSummary() },
|
|
374
445
|
),
|
|
375
446
|
);
|
|
376
447
|
this.deps.toast?.(`Monitor "${entry.description}" timed out — re-arm if needed.`, "warning");
|
|
@@ -380,6 +451,10 @@ export class MonitorRegistry {
|
|
|
380
451
|
|
|
381
452
|
/** Rate limiter saturated for too long: stop and notify (§4.4). */
|
|
382
453
|
private async autoStop(entry: MonitorEntry): Promise<void> {
|
|
454
|
+
if (entry.stopped || entry.stopping) return;
|
|
455
|
+
entry.stopping = true;
|
|
456
|
+
entry.batcher.flush();
|
|
457
|
+
entry.output.flush();
|
|
383
458
|
if (entry.stopped) return;
|
|
384
459
|
entry.stopped = true;
|
|
385
460
|
this.deps.logEvent?.("monitor.stop", { id: entry.taskId, reason: "rate-limit" });
|
|
@@ -392,7 +467,7 @@ export class MonitorRegistry {
|
|
|
392
467
|
"[Monitor stopped: at least half of output batches were dropped in the last 30s.]",
|
|
393
468
|
|
|
394
469
|
"stopped",
|
|
395
|
-
{ droppedLines: entry.droppedLinesPending },
|
|
470
|
+
{ droppedLines: entry.droppedLinesPending, output: entry.output.takeSummary() },
|
|
396
471
|
),
|
|
397
472
|
);
|
|
398
473
|
this.deps.toast?.(
|
|
@@ -406,6 +481,7 @@ export class MonitorRegistry {
|
|
|
406
481
|
private cleanup(entry: MonitorEntry): void {
|
|
407
482
|
entry.stopped = true;
|
|
408
483
|
entry.batcher.dispose();
|
|
484
|
+
entry.output.dispose();
|
|
409
485
|
entry.limiter.dispose();
|
|
410
486
|
if (entry.timeoutTimer !== null) {
|
|
411
487
|
this.clock.clearTimeout(entry.timeoutTimer);
|
|
@@ -416,6 +492,14 @@ export class MonitorRegistry {
|
|
|
416
492
|
}
|
|
417
493
|
}
|
|
418
494
|
|
|
495
|
+
function validRecord(record: MonitorOutputRecord, cursor: number): boolean {
|
|
496
|
+
return record?.version === 1 && (record.kind === "line" || record.kind === "progress") &&
|
|
497
|
+
typeof record.preview === "string" && record.preview.length <= 2000 &&
|
|
498
|
+
Number.isSafeInteger(record.cursor_start) && record.cursor_start >= 0 &&
|
|
499
|
+
Number.isSafeInteger(record.cursor_end) && record.cursor_end >= record.cursor_start && record.cursor_end <= cursor &&
|
|
500
|
+
Number.isFinite(record.observed_at) && record.observed_at >= 0;
|
|
501
|
+
}
|
|
502
|
+
|
|
419
503
|
/** A task_exited-shaped event rebuilt from a manager record (reconcile). */
|
|
420
504
|
export function exitEventFromRecord(task: TaskRecord): ManagerEvent {
|
|
421
505
|
return {
|
|
@@ -451,10 +535,13 @@ const monitorParameters = Type.Object({
|
|
|
451
535
|
}),
|
|
452
536
|
),
|
|
453
537
|
persistent: Type.Optional(Type.Boolean({ description: PROMPTS["tools.monitor.param.persistent"] })),
|
|
538
|
+
dedup_window_ms: Type.Optional(Type.Integer({ minimum: 0, maximum: 3_600_000, description: PROMPTS["tools.monitor.param.dedup_window_ms"] })),
|
|
539
|
+
fingerprint_mode: Type.Optional(Type.Union([Type.Literal("exact"), Type.Literal("ignore-timestamps")], { description: PROMPTS["tools.monitor.param.fingerprint_mode"] })),
|
|
454
540
|
});
|
|
455
541
|
|
|
456
542
|
export function createMonitorTool(
|
|
457
543
|
registry: MonitorRegistry,
|
|
544
|
+
getIndex?: () => WorkIndex | null,
|
|
458
545
|
): ToolDefinition<typeof monitorParameters, { task_id: string; timeout_ms: number | null }> {
|
|
459
546
|
return {
|
|
460
547
|
name: "monitor",
|
|
@@ -489,10 +576,34 @@ export function createMonitorTool(
|
|
|
489
576
|
.map((c) => c.text)
|
|
490
577
|
.join("\n");
|
|
491
578
|
const firstLine = text.split("\n")[0];
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const
|
|
579
|
+
// Live status from the work index (a started monitor is running, not
|
|
580
|
+
// done); the tool's own error flag when the start itself failed.
|
|
581
|
+
const taskId = (result.details as { task_id?: string } | undefined)?.task_id;
|
|
582
|
+
const index = getIndex?.() ?? null;
|
|
583
|
+
const item = taskId ? index?.get(taskId) : undefined;
|
|
584
|
+
const state = context.state as { unsubscribe?: () => void } | undefined;
|
|
585
|
+
if (index && taskId && state && !state.unsubscribe && (item?.status === "running" || item?.status === "pending")) {
|
|
586
|
+
state.unsubscribe = index.onChange(() => {
|
|
587
|
+
const now = index.get(taskId)?.status;
|
|
588
|
+
if (now !== "running" && now !== "pending") {
|
|
589
|
+
state.unsubscribe?.();
|
|
590
|
+
state.unsubscribe = () => {};
|
|
591
|
+
}
|
|
592
|
+
context.invalidate?.();
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
// Live monitors are indexed before this renders, so a missing row is a
|
|
596
|
+
// finished one that was pruned or predates a reload.
|
|
597
|
+
const status = context.isError ? "failed" : item?.status ?? (taskId && !index ? "running" : "completed");
|
|
598
|
+
const { color, glyph } = statusGlyph(status, context.isError);
|
|
599
|
+
const line = `${theme.fg(color as "error", glyph)} ${firstLine}${expanded ? "" : theme.fg("dim", " · /tasks")}`;
|
|
495
600
|
return toolComponent([line]) as never;
|
|
496
601
|
},
|
|
497
602
|
};
|
|
498
603
|
}
|
|
604
|
+
|
|
605
|
+
/** " <why>" when the monitor's Git Bash never started; "" otherwise. */
|
|
606
|
+
function startFailureNote(endReason: string | undefined): string {
|
|
607
|
+
const status = shellStartStatus(endReason);
|
|
608
|
+
return status ? ` ${status}` : "";
|
|
609
|
+
}
|
package/src/notify.ts
CHANGED
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
* ever delivered once.
|
|
15
15
|
*/
|
|
16
16
|
import { formatMonitorEvent, formatTaskNotification, type TaskExitInfo } from "./format";
|
|
17
|
-
import { FAMULUS_WAKE_CUSTOM_TYPE, stampWakeAsOf, wakeIds, type WakeItem } from "./wake";
|
|
17
|
+
import { FAMULUS_WAKE_CUSTOM_TYPE, stampWakeAsOf, wakeIds, type MonitorOutputInfo, type WakeItem } from "./wake";
|
|
18
18
|
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
type MonitorDeliveryAck = (visiblePreview?: string) => void;
|
|
22
|
+
|
|
21
23
|
export interface NotifyMessage {
|
|
22
24
|
customType: string;
|
|
23
25
|
content: string;
|
|
@@ -42,6 +44,64 @@ export interface NotifyCenterDeps {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
|
|
47
|
+
// Reserve 100 of the 3000 preview characters for the event-count prefix and
|
|
48
|
+
// omission marker. No raw pending text grows with the number/size of events.
|
|
49
|
+
const MONITOR_PREVIEW_CHARS = 2900;
|
|
50
|
+
const MONITOR_PREVIEW_HALF = MONITOR_PREVIEW_CHARS / 2;
|
|
51
|
+
interface MonitorPreview {
|
|
52
|
+
head: string;
|
|
53
|
+
tail: string;
|
|
54
|
+
omittedChars: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function clipUtf16Start(text: string, max: number): string {
|
|
58
|
+
if (text.length <= max) return text;
|
|
59
|
+
const out = text.slice(0, max);
|
|
60
|
+
const last = out.charCodeAt(out.length - 1);
|
|
61
|
+
return last >= 0xd800 && last <= 0xdbff ? out.slice(0, -1) : out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function clipUtf16End(text: string, max: number): string {
|
|
65
|
+
if (text.length <= max) return text;
|
|
66
|
+
const out = text.slice(-max);
|
|
67
|
+
const first = out.charCodeAt(0);
|
|
68
|
+
return first >= 0xdc00 && first <= 0xdfff ? out.slice(1) : out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function appendMonitorPreview(previous: MonitorPreview | undefined, event: string): MonitorPreview {
|
|
72
|
+
const total = previous
|
|
73
|
+
? previous.head.length + previous.tail.length + previous.omittedChars + 1 + event.length
|
|
74
|
+
: event.length;
|
|
75
|
+
if (total <= MONITOR_PREVIEW_CHARS) {
|
|
76
|
+
return { head: previous ? `${previous.head}\n${event}` : event, tail: "", omittedChars: 0 };
|
|
77
|
+
}
|
|
78
|
+
const head = previous
|
|
79
|
+
? clipUtf16Start(`${previous.head}\n${clipUtf16Start(event, MONITOR_PREVIEW_HALF)}`, MONITOR_PREVIEW_HALF)
|
|
80
|
+
: clipUtf16Start(event, MONITOR_PREVIEW_HALF);
|
|
81
|
+
const tail = event.length >= MONITOR_PREVIEW_HALF
|
|
82
|
+
? clipUtf16End(event, MONITOR_PREVIEW_HALF)
|
|
83
|
+
: clipUtf16End(`${previous?.tail || previous?.head || ""}\n${event}`, MONITOR_PREVIEW_HALF);
|
|
84
|
+
return { head, tail, omittedChars: total - head.length - tail.length };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function mergeMonitorOutput(previous: MonitorOutputInfo | undefined, next: MonitorOutputInfo | undefined): MonitorOutputInfo | undefined {
|
|
88
|
+
if (!previous && !next) return undefined;
|
|
89
|
+
const output = { ...previous };
|
|
90
|
+
for (const key of ["firstObservedAt", "cursorStart"] as const) {
|
|
91
|
+
const value = next?.[key];
|
|
92
|
+
if (value !== undefined) output[key] = Math.min(previous?.[key] ?? value, value);
|
|
93
|
+
}
|
|
94
|
+
for (const key of ["lastObservedAt", "cursorEnd"] as const) {
|
|
95
|
+
const value = next?.[key];
|
|
96
|
+
if (value !== undefined) output[key] = Math.max(previous?.[key] ?? value, value);
|
|
97
|
+
}
|
|
98
|
+
for (const key of ["suppressedCount", "omittedChars"] as const) {
|
|
99
|
+
if (previous?.[key] !== undefined || next?.[key] !== undefined) output[key] = (previous?.[key] ?? 0) + (next?.[key] ?? 0);
|
|
100
|
+
}
|
|
101
|
+
if (previous?.truncated !== undefined || next?.truncated !== undefined) output.truncated = !!previous?.truncated || !!next?.truncated;
|
|
102
|
+
return output;
|
|
103
|
+
}
|
|
104
|
+
|
|
45
105
|
export class NotifyCenter {
|
|
46
106
|
private readonly deps: NotifyCenterDeps;
|
|
47
107
|
private readonly batchMs: number;
|
|
@@ -49,14 +109,14 @@ export class NotifyCenter {
|
|
|
49
109
|
private pendingExits: TaskExitInfo[] = [];
|
|
50
110
|
private readonly pendingMonitors = new Map<
|
|
51
111
|
string,
|
|
52
|
-
{ description: string; eventCount: number;
|
|
112
|
+
{ description: string; eventCount: number; preview: MonitorPreview; droppedLines: number; quiet: boolean; output?: MonitorOutputInfo; onDelivered?: MonitorDeliveryAck }
|
|
53
113
|
>();
|
|
54
114
|
private readonly seen = new Set<string>();
|
|
55
115
|
private timer: ClockTimer | null = null;
|
|
56
116
|
private disposed = false;
|
|
57
117
|
/** A wake started or steered a run that has not settled yet. */
|
|
58
118
|
private runPending = false;
|
|
59
|
-
private held: NotifyMessage[] = [];
|
|
119
|
+
private held: { message: NotifyMessage; onDelivered?: MonitorDeliveryAck; sourcePreview?: string }[] = [];
|
|
60
120
|
|
|
61
121
|
constructor(deps: NotifyCenterDeps) {
|
|
62
122
|
this.deps = deps;
|
|
@@ -94,13 +154,17 @@ export class NotifyCenter {
|
|
|
94
154
|
if (wake?.kind === "monitor" && typeof wake.id === "string") this.flushMonitor(wake.id, opts.quietEvents);
|
|
95
155
|
if (!opts.passive) {
|
|
96
156
|
this.deliver(message);
|
|
97
|
-
} else if (this.runPending || !this.deps.isIdle()) {
|
|
98
|
-
this.held.push(message);
|
|
99
157
|
} else {
|
|
100
|
-
this.
|
|
158
|
+
this.deliverPassive(message);
|
|
101
159
|
}
|
|
102
160
|
}
|
|
103
161
|
|
|
162
|
+
private deliverPassive(message: NotifyMessage, onDelivered?: MonitorDeliveryAck, sourcePreview?: string): void {
|
|
163
|
+
message = stampWakeAsOf(message, this.clock.now());
|
|
164
|
+
if (this.runPending || !this.deps.isIdle()) this.held.push({ message, onDelivered, sourcePreview });
|
|
165
|
+
else this.deliver(message, true, onDelivered, sourcePreview);
|
|
166
|
+
}
|
|
167
|
+
|
|
104
168
|
/**
|
|
105
169
|
* The agent settled: the run any earlier wake started is over. Flush
|
|
106
170
|
* coalesced monitor output, then, unless that started another run, the
|
|
@@ -111,7 +175,7 @@ export class NotifyCenter {
|
|
|
111
175
|
this.runPending = false;
|
|
112
176
|
this.flushMonitorEvents();
|
|
113
177
|
if (this.runPending) return;
|
|
114
|
-
for (const message of this.held.splice(0)) this.deliver(message, true);
|
|
178
|
+
for (const { message, onDelivered, sourcePreview } of this.held.splice(0)) this.deliver(message, true, onDelivered, sourcePreview);
|
|
115
179
|
}
|
|
116
180
|
|
|
117
181
|
/**
|
|
@@ -119,21 +183,30 @@ export class NotifyCenter {
|
|
|
119
183
|
* monitor. `quiet`: output of a monitor the model stopped; it is only
|
|
120
184
|
* coalesced, and its summary is delivered without a turn (with the exit
|
|
121
185
|
* notice, or at settle), pending events of that monitor included.
|
|
186
|
+
* `onDelivered` receives surviving source lines after sendMessage returns, not
|
|
187
|
+
* enqueue; generated headings/omission markers are never acknowledgement
|
|
188
|
+
* evidence. Callers must reuse one stable callback per monitor: retaining one
|
|
189
|
+
* callback bounds pending acknowledgements without silently dropping any.
|
|
122
190
|
*/
|
|
123
|
-
notifyMonitorEvent(description: string, taskId: string, event: string, droppedLines = 0, opts: { quiet?: boolean } = {}): void {
|
|
191
|
+
notifyMonitorEvent(description: string, taskId: string, event: string, droppedLines = 0, opts: { quiet?: boolean; onDelivered?: MonitorDeliveryAck; output?: MonitorOutputInfo } = {}): void {
|
|
124
192
|
if (this.disposed) return;
|
|
125
193
|
const pending = this.pendingMonitors.get(taskId);
|
|
194
|
+
if (pending?.onDelivered && opts.onDelivered && pending.onDelivered !== opts.onDelivered) {
|
|
195
|
+
throw new Error(`Monitor ${taskId} requires a stable onDelivered callback while pending`);
|
|
196
|
+
}
|
|
126
197
|
if (this.deps.isIdle() && !pending && !opts.quiet) {
|
|
127
|
-
const wake = formatMonitorEvent(description, taskId, event, undefined, { droppedLines });
|
|
128
|
-
this.deliver({ customType: wake.customType, content: wake.content, details: wake.details });
|
|
198
|
+
const wake = formatMonitorEvent(description, taskId, event, undefined, { droppedLines, output: opts.output });
|
|
199
|
+
this.deliver({ customType: wake.customType, content: wake.content, details: wake.details }, false, opts.onDelivered);
|
|
129
200
|
return;
|
|
130
201
|
}
|
|
131
202
|
this.pendingMonitors.set(taskId, {
|
|
132
203
|
description,
|
|
133
204
|
eventCount: (pending?.eventCount ?? 0) + 1,
|
|
134
|
-
|
|
205
|
+
preview: appendMonitorPreview(pending?.preview, event),
|
|
135
206
|
droppedLines: (pending?.droppedLines ?? 0) + droppedLines,
|
|
136
207
|
quiet: (pending?.quiet ?? false) || opts.quiet === true,
|
|
208
|
+
output: mergeMonitorOutput(pending?.output, opts.output),
|
|
209
|
+
onDelivered: pending?.onDelivered ?? opts.onDelivered,
|
|
137
210
|
});
|
|
138
211
|
if (this.deps.isIdle() && !opts.quiet) this.flushMonitorEvents();
|
|
139
212
|
}
|
|
@@ -149,16 +222,25 @@ export class NotifyCenter {
|
|
|
149
222
|
const item = this.pendingMonitors.get(taskId);
|
|
150
223
|
if (!item) return;
|
|
151
224
|
this.pendingMonitors.delete(taskId);
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
225
|
+
const { head, tail, omittedChars } = item.preview;
|
|
226
|
+
const preview = omittedChars > 0 ? `${head}\n[... ${omittedChars} characters omitted ...]\n${tail}` : head;
|
|
227
|
+
const summary = item.eventCount > 1 ? `${item.eventCount} events\n${preview}` : preview;
|
|
155
228
|
const wake = formatMonitorEvent(item.description, taskId, summary, undefined, {
|
|
156
229
|
eventCount: item.eventCount,
|
|
157
230
|
droppedLines: item.droppedLines,
|
|
231
|
+
output: mergeMonitorOutput(item.output, omittedChars > 0 ? { truncated: true, omittedChars } : undefined),
|
|
158
232
|
});
|
|
159
233
|
const message = { customType: wake.customType, content: wake.content, details: wake.details };
|
|
160
|
-
|
|
161
|
-
|
|
234
|
+
// Exclude generated text and boundary fragments: a clipped middle source
|
|
235
|
+
// line named e.g. "30 events" was not seen just because our heading says it.
|
|
236
|
+
const sourcePreview = omittedChars > 0
|
|
237
|
+
? [
|
|
238
|
+
head.slice(0, Math.max(0, head.lastIndexOf("\n"))),
|
|
239
|
+
tail.includes("\n") ? tail.slice(tail.indexOf("\n") + 1) : "",
|
|
240
|
+
].filter(Boolean).join("\n")
|
|
241
|
+
: head;
|
|
242
|
+
if (quiet || item.quiet) this.deliverPassive(message, item.onDelivered, sourcePreview);
|
|
243
|
+
else this.deliver(message, false, item.onDelivered, sourcePreview);
|
|
162
244
|
}
|
|
163
245
|
|
|
164
246
|
/** Flush any pending task exit notifications now. */
|
|
@@ -204,7 +286,7 @@ export class NotifyCenter {
|
|
|
204
286
|
});
|
|
205
287
|
}
|
|
206
288
|
|
|
207
|
-
private deliver(message: NotifyMessage, passive = false): void {
|
|
289
|
+
private deliver(message: NotifyMessage, passive = false, onDelivered?: MonitorDeliveryAck, sourcePreview?: string): void {
|
|
208
290
|
// Wakes built here (task batches, monitor events) are stamped now.
|
|
209
291
|
message = stampWakeAsOf(message, this.clock.now());
|
|
210
292
|
const msg = { ...message, display: true };
|
|
@@ -227,5 +309,6 @@ export class NotifyCenter {
|
|
|
227
309
|
} else {
|
|
228
310
|
this.deps.sendMessage(msg, { deliverAs: "steer" });
|
|
229
311
|
}
|
|
312
|
+
onDelivered?.(sourcePreview ?? (details?.kind === "monitor" ? (message.details as { event?: string }).event : undefined));
|
|
230
313
|
}
|
|
231
314
|
}
|