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/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) => this.onBatch(entry, 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
- if (early.chars + chunk.length > EARLY_MAX_CHARS) return;
209
- early.chunks.push({ chunk, cursor: nextCursor });
210
- early.chars += chunk.length;
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) return;
217
- const startCursor = nextCursor - deliver.byteLength;
218
- if (startCursor < entry.cursor) {
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
- entry.cursor = nextCursor;
222
- } else {
223
- entry.cursor += deliver.byteLength;
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 notice already sent
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): void {
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: no rate-limit stop or wake.
346
- if (entry.saturation.isSaturated(now) && !entry.stopRequested) void this.autoStop(entry);
347
- return;
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
- this.deps.getNotifyCenter()?.notifyMonitorEvent(entry.description, entry.taskId, text, droppedLines, {
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
- const failed = context.isError || /\b(failed|killed|orphaned|error)\b/i.test(text);
493
- const { color, glyph } = statusGlyph(failed ? "failed" : "completed", failed);
494
- const line = `${theme.fg(color as "error", glyph)} ${firstLine}${expanded ? "" : theme.fg("dim", " · manage via /tasks")}`;
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; lastEvent: string; droppedLines: number; quiet: boolean }
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.deliver(message, true);
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
- lastEvent: event,
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 summary = item.eventCount > 1
153
- ? `${item.eventCount} events · last: ${item.lastEvent}`
154
- : item.lastEvent;
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
- if (quiet || item.quiet) this.notify(message, { passive: true });
161
- else this.deliver(message);
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
  }