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/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
- const managerPath = resolveManagerPath(config, home);
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
- registerFamulusCommand(pi, {
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. Tools call ensureAvailable() lazily before use.
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 c
824
- .connect()
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;
@@ -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.7";
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 readonly managerPath: string | null;
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_;
@@ -13,19 +13,71 @@ export interface LineBatcherOptions {
13
13
  flushMs?: number; // batching window, default 200
14
14
  maxLineChars?: number; // per-line cap, default 500
15
15
  maxBatchChars?: number; // per-batch cap, default 3000
16
- onFlush: (text: string) => void;
16
+ onFlush: (text: string, info: { truncated: boolean; omittedChars: number }) => void;
17
17
  clock?: Clock;
18
18
  }
19
19
 
20
+ function sourceOfSlice(text: string, source: number, start: number, end: number): number {
21
+ if (text.length === 0 || end <= start) return 0;
22
+ return Math.round(source * ((end - start) / text.length));
23
+ }
24
+
25
+ /** Fixed-memory head/tail preview. Counts omitted source units, not generated ellipses. */
26
+ export class BoundedPreview {
27
+ private head = "";
28
+ private tail = "";
29
+ private sourceHead = 0;
30
+ private sourceTail = 0;
31
+ total = 0;
32
+ private sourceTotal = 0;
33
+ constructor(readonly limit: number) {
34
+ if (!Number.isSafeInteger(limit) || limit < 1) throw new RangeError("preview limit must be positive");
35
+ }
36
+ append(text: string, sourceChars = text.length): void {
37
+ const headLimit = Math.ceil((this.limit - 1) / 2);
38
+ if (this.total + text.length <= this.limit) {
39
+ this.head += text;
40
+ this.sourceHead += sourceChars;
41
+ } else {
42
+ const old = this.head;
43
+ const oldSourceHead = this.sourceHead;
44
+ if (this.total <= this.limit) {
45
+ const combined = old + text;
46
+ const combinedSource = oldSourceHead + sourceChars;
47
+ this.head = combined.slice(0, headLimit);
48
+ this.sourceHead = sourceOfSlice(combined, combinedSource, 0, this.head.length);
49
+ }
50
+ const tailLimit = this.limit - 1 - headLimit;
51
+ if (tailLimit > 0) {
52
+ const from = (this.tail || old).concat(text);
53
+ const fromSource = (this.tail ? this.sourceTail : oldSourceHead) + sourceChars;
54
+ this.tail = from.slice(-tailLimit);
55
+ this.sourceTail = sourceOfSlice(from, fromSource, from.length - this.tail.length, from.length);
56
+ } else {
57
+ this.tail = "";
58
+ this.sourceTail = 0;
59
+ }
60
+ }
61
+ this.total += text.length;
62
+ this.sourceTotal += sourceChars;
63
+ }
64
+ get omittedChars(): number {
65
+ if (this.total <= this.limit) return 0;
66
+ return Math.max(0, this.sourceTotal - this.sourceHead - this.sourceTail);
67
+ }
68
+ get text(): string { return this.total > this.limit ? `${this.head}…${this.tail}` : this.head; }
69
+ }
70
+
20
71
  export class LineBatcher {
21
72
  private readonly flushMs: number;
22
73
  private readonly maxLineChars: number;
23
74
  private readonly maxBatchChars: number;
24
- private readonly onFlush: (text: string) => void;
75
+ private readonly onFlush: LineBatcherOptions["onFlush"];
25
76
  private readonly clock: Clock;
26
-
27
- private partial = ""; // trailing bytes not yet terminated by \n
28
- private lines: string[] = []; // complete lines awaiting emission
77
+ private partial: BoundedPreview;
78
+ private lines: BoundedPreview;
79
+ private lineCount = 0;
80
+ private omittedLineChars = 0;
29
81
  private timer: ClockTimer | null = null;
30
82
  private disposed = false;
31
83
 
@@ -35,49 +87,52 @@ export class LineBatcher {
35
87
  this.maxBatchChars = opts.maxBatchChars ?? 3000;
36
88
  this.onFlush = opts.onFlush;
37
89
  this.clock = opts.clock ?? realClock;
90
+ this.partial = new BoundedPreview(this.maxLineChars);
91
+ this.lines = new BoundedPreview(this.maxBatchChars);
38
92
  }
39
93
 
40
- /** Feed a raw output chunk (may contain no/newlines or multiple lines). */
94
+ /** Bound retained data during ingestion, not just after the timer fires. */
41
95
  push(chunk: string): void {
42
96
  if (this.disposed || chunk.length === 0) return;
43
- const combined = this.partial + chunk;
44
- const parts = combined.split("\n");
45
- this.partial = parts.pop() ?? "";
46
- for (const line of parts) {
47
- this.lines.push(this.capLine(line));
97
+ const parts = chunk.split("\n");
98
+ for (let i = 0; i < parts.length; i++) {
99
+ this.partial.append(parts[i]);
100
+ if (i < parts.length - 1) this.finishLine();
48
101
  }
49
- if (this.lines.length > 0 || this.partial.length > 0) this.schedule();
102
+ this.schedule();
50
103
  }
51
104
 
52
- /**
53
- * Immediately emit the current buffer when non-empty, including any
54
- * unterminated trailing line. Used when a monitored process exits.
55
- */
56
105
  flush(): void {
57
106
  if (this.disposed) return;
58
107
  this.clearTimer();
59
108
  this.emit();
60
109
  }
61
110
 
62
- /** Clear the pending timer and drop buffered content. */
63
111
  dispose(): void {
64
112
  this.disposed = true;
65
113
  this.clearTimer();
66
- this.lines = [];
67
- this.partial = "";
114
+ this.reset();
68
115
  }
69
116
 
70
- private capLine(line: string): string {
71
- if (line.length <= this.maxLineChars) return line;
72
- return line.slice(0, this.maxLineChars);
117
+ private finishLine(): void {
118
+ if (this.lineCount++ > 0) this.lines.append("\n", 1);
119
+ const displayed = this.partial.text;
120
+ this.lines.append(displayed, this.partial.total - this.partial.omittedChars);
121
+ this.omittedLineChars += this.partial.omittedChars;
122
+ this.partial = new BoundedPreview(this.maxLineChars);
123
+ }
124
+
125
+ private reset(): void {
126
+ this.partial = new BoundedPreview(this.maxLineChars);
127
+ this.lines = new BoundedPreview(this.maxBatchChars);
128
+ this.lineCount = 0;
129
+ this.omittedLineChars = 0;
73
130
  }
74
131
 
75
132
  private schedule(): void {
76
133
  if (this.timer !== null) return;
77
134
  this.timer = this.clock.setTimeout(() => {
78
135
  this.timer = null;
79
- // Window end drains everything, including an unterminated trailing
80
- // line (no data loss); a fresh window starts on the next push.
81
136
  this.emit();
82
137
  }, this.flushMs);
83
138
  this.clock.unref?.(this.timer);
@@ -91,18 +146,11 @@ export class LineBatcher {
91
146
  }
92
147
 
93
148
  private emit(): void {
94
- const lines = this.lines;
95
- const partial = this.partial.length > 0 ? this.capLine(this.partial) : "";
96
- if (lines.length === 0 && partial.length === 0) return;
97
- this.lines = [];
98
- this.partial = "";
99
-
100
- let text = partial.length > 0 ? [...lines, partial].join("\n") : lines.join("\n");
101
- if (text.length > this.maxBatchChars) {
102
- // Keep the tail: for monitoring, the most recent lines matter most.
103
- text = `…${text.slice(-(this.maxBatchChars - 1))}`;
104
- }
105
- if (text.length > 0) this.onFlush(text);
149
+ if (this.partial.total > 0) this.finishLine();
150
+ const text = this.lines.text;
151
+ const omittedChars = this.omittedLineChars + this.lines.omittedChars;
152
+ this.reset();
153
+ if (text.length > 0) this.onFlush(text, { truncated: omittedChars > 0, omittedChars });
106
154
  }
107
155
  }
108
156
 
@@ -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
+ }