pi-famulus 0.1.0

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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/bin/pi-famulus.js +29 -0
  4. package/package.json +49 -0
  5. package/src/agents/builtins.ts +41 -0
  6. package/src/agents/definition.ts +221 -0
  7. package/src/agents/loader.ts +119 -0
  8. package/src/bash-override.ts +372 -0
  9. package/src/behavior-guidelines.ts +42 -0
  10. package/src/clock.ts +169 -0
  11. package/src/comms/comms.ts +199 -0
  12. package/src/comms/mailbox.ts +156 -0
  13. package/src/comms/registry-host.ts +73 -0
  14. package/src/comms/reply-command.ts +52 -0
  15. package/src/comms/routing.ts +23 -0
  16. package/src/comms/tools.ts +339 -0
  17. package/src/comms/types.ts +68 -0
  18. package/src/config.ts +266 -0
  19. package/src/events.ts +61 -0
  20. package/src/exit-notify-gate.ts +69 -0
  21. package/src/exit-watchdog.ts +60 -0
  22. package/src/file-tail.ts +48 -0
  23. package/src/format.ts +276 -0
  24. package/src/index.ts +700 -0
  25. package/src/manager-client.ts +839 -0
  26. package/src/monitor-batching.ts +191 -0
  27. package/src/monitor.ts +517 -0
  28. package/src/native-manager.d.ts +9 -0
  29. package/src/native-manager.js +39 -0
  30. package/src/notify.ts +231 -0
  31. package/src/shell-exec.ts +145 -0
  32. package/src/subagent/agent-records.ts +168 -0
  33. package/src/subagent/child-bash.ts +183 -0
  34. package/src/subagent/conversation.ts +76 -0
  35. package/src/subagent/fleet-widget.ts +176 -0
  36. package/src/subagent/model-spec.ts +108 -0
  37. package/src/subagent/pi-runtime.ts +295 -0
  38. package/src/subagent/pool.ts +190 -0
  39. package/src/subagent/registry.ts +575 -0
  40. package/src/subagent/runner.ts +709 -0
  41. package/src/subagent/tool.ts +741 -0
  42. package/src/subagent/transcript.ts +71 -0
  43. package/src/subagent/types.ts +125 -0
  44. package/src/task-exit-notify.ts +21 -0
  45. package/src/task-tools.ts +330 -0
  46. package/src/tui/message-renderers.ts +183 -0
  47. package/src/tui/pi-tui-load.ts +186 -0
  48. package/src/tui/scroll-detail-view.ts +190 -0
  49. package/src/tui/task-output-paths.ts +46 -0
  50. package/src/tui/tasks-command.ts +515 -0
  51. package/src/tui/tool-component.ts +29 -0
  52. package/src/wake.ts +219 -0
  53. package/src/work-index.ts +182 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Monitor output batching primitives (design doc §4.4, Appendix A).
3
+ *
4
+ * LineBatcher turns arbitrary output chunks into batched line payloads:
5
+ * chunks are split on newlines, complete lines are capped per line, and
6
+ * emissions are coalesced over a 200ms window with a per-batch size cap.
7
+ *
8
+ * RateLimiter is a token bucket used to throttle monitor event injection.
9
+ */
10
+ import { realClock, type Clock, type ClockTimer } from "./clock";
11
+
12
+ export interface LineBatcherOptions {
13
+ flushMs?: number; // batching window, default 200
14
+ maxLineChars?: number; // per-line cap, default 500
15
+ maxBatchChars?: number; // per-batch cap, default 3000
16
+ onFlush: (text: string) => void;
17
+ clock?: Clock;
18
+ }
19
+
20
+ export class LineBatcher {
21
+ private readonly flushMs: number;
22
+ private readonly maxLineChars: number;
23
+ private readonly maxBatchChars: number;
24
+ private readonly onFlush: (text: string) => void;
25
+ private readonly clock: Clock;
26
+
27
+ private partial = ""; // trailing bytes not yet terminated by \n
28
+ private lines: string[] = []; // complete lines awaiting emission
29
+ private timer: ClockTimer | null = null;
30
+ private disposed = false;
31
+
32
+ constructor(opts: LineBatcherOptions) {
33
+ this.flushMs = opts.flushMs ?? 200;
34
+ this.maxLineChars = opts.maxLineChars ?? 500;
35
+ this.maxBatchChars = opts.maxBatchChars ?? 3000;
36
+ this.onFlush = opts.onFlush;
37
+ this.clock = opts.clock ?? realClock;
38
+ }
39
+
40
+ /** Feed a raw output chunk (may contain no/newlines or multiple lines). */
41
+ push(chunk: string): void {
42
+ 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));
48
+ }
49
+ if (this.lines.length > 0 || this.partial.length > 0) this.schedule();
50
+ }
51
+
52
+ /**
53
+ * Immediately emit the current buffer when non-empty, including any
54
+ * unterminated trailing line. Used when a monitored process exits.
55
+ */
56
+ flush(): void {
57
+ if (this.disposed) return;
58
+ this.clearTimer();
59
+ this.emit();
60
+ }
61
+
62
+ /** Clear the pending timer and drop buffered content. */
63
+ dispose(): void {
64
+ this.disposed = true;
65
+ this.clearTimer();
66
+ this.lines = [];
67
+ this.partial = "";
68
+ }
69
+
70
+ private capLine(line: string): string {
71
+ if (line.length <= this.maxLineChars) return line;
72
+ return line.slice(0, this.maxLineChars);
73
+ }
74
+
75
+ private schedule(): void {
76
+ if (this.timer !== null) return;
77
+ this.timer = this.clock.setTimeout(() => {
78
+ 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
+ this.emit();
82
+ }, this.flushMs);
83
+ this.clock.unref?.(this.timer);
84
+ }
85
+
86
+ private clearTimer(): void {
87
+ if (this.timer !== null) {
88
+ this.clock.clearTimeout(this.timer);
89
+ this.timer = null;
90
+ }
91
+ }
92
+
93
+ 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);
106
+ }
107
+ }
108
+
109
+ export interface SaturationWindowOptions {
110
+ windowMs: number;
111
+ dropRatio: number;
112
+ minimumBatches: number;
113
+ }
114
+
115
+ /** Tracks whether a rolling window has a sustained ratio of dropped batches. */
116
+ export class SaturationWindow {
117
+ private readonly windowMs: number;
118
+ private readonly dropRatio: number;
119
+ private readonly minimumBatches: number;
120
+ private samples: { at: number; dropped: boolean }[] = [];
121
+
122
+ constructor(opts: SaturationWindowOptions) {
123
+ this.windowMs = opts.windowMs;
124
+ this.dropRatio = opts.dropRatio;
125
+ this.minimumBatches = opts.minimumBatches;
126
+ }
127
+
128
+ record(dropped: boolean, at: number): void {
129
+ this.prune(at);
130
+ this.samples.push({ at, dropped });
131
+ }
132
+
133
+ isSaturated(now: number): boolean {
134
+ this.prune(now);
135
+ if (this.samples.length < this.minimumBatches) return false;
136
+ const first = this.samples[0];
137
+ // Require the retained samples to span a full window; sparse bursts alone
138
+ // should not be treated as sustained saturation.
139
+ if (!first || now - first.at < this.windowMs) return false;
140
+ const dropped = this.samples.reduce((count, sample) => count + Number(sample.dropped), 0);
141
+ return dropped / this.samples.length >= this.dropRatio;
142
+ }
143
+
144
+ private prune(now: number): void {
145
+ const cutoff = now - this.windowMs;
146
+ let firstLive = 0;
147
+ while (firstLive < this.samples.length && this.samples[firstLive].at < cutoff) firstLive++;
148
+ if (firstLive > 0) this.samples = this.samples.slice(firstLive);
149
+ }
150
+ }
151
+
152
+ export interface RateLimiterOptions {
153
+ capacity?: number; // default 10
154
+ refillIntervalMs?: number; // default 2000
155
+ refillAmount?: number; // default 1
156
+ clock?: Clock;
157
+ }
158
+
159
+ export class RateLimiter {
160
+ private readonly capacity: number;
161
+ private readonly refillAmount: number;
162
+ private tokens: number;
163
+ private readonly interval: ClockTimer;
164
+ private readonly clock: Clock;
165
+ private disposed = false;
166
+
167
+ constructor(opts?: RateLimiterOptions) {
168
+ this.capacity = opts?.capacity ?? 10;
169
+ this.refillAmount = opts?.refillAmount ?? 1;
170
+ const refillIntervalMs = opts?.refillIntervalMs ?? 2000;
171
+ this.clock = opts?.clock ?? realClock;
172
+ this.tokens = this.capacity;
173
+ this.interval = this.clock.setInterval(() => {
174
+ this.tokens = Math.min(this.capacity, this.tokens + this.refillAmount);
175
+ }, refillIntervalMs);
176
+ this.clock.unref?.(this.interval);
177
+ }
178
+
179
+ /** Consume `n` tokens (default 1); returns false when insufficient. */
180
+ tryConsume(n: number = 1): boolean {
181
+ if (this.disposed) return false;
182
+ if (this.tokens < n) return false;
183
+ this.tokens -= n;
184
+ return true;
185
+ }
186
+
187
+ dispose(): void {
188
+ this.disposed = true;
189
+ this.clock.clearInterval(this.interval);
190
+ }
191
+ }