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.
- package/LICENSE +21 -0
- package/README.md +36 -0
- package/bin/pi-famulus.js +29 -0
- package/package.json +49 -0
- package/src/agents/builtins.ts +41 -0
- package/src/agents/definition.ts +221 -0
- package/src/agents/loader.ts +119 -0
- package/src/bash-override.ts +372 -0
- package/src/behavior-guidelines.ts +42 -0
- package/src/clock.ts +169 -0
- package/src/comms/comms.ts +199 -0
- package/src/comms/mailbox.ts +156 -0
- package/src/comms/registry-host.ts +73 -0
- package/src/comms/reply-command.ts +52 -0
- package/src/comms/routing.ts +23 -0
- package/src/comms/tools.ts +339 -0
- package/src/comms/types.ts +68 -0
- package/src/config.ts +266 -0
- package/src/events.ts +61 -0
- package/src/exit-notify-gate.ts +69 -0
- package/src/exit-watchdog.ts +60 -0
- package/src/file-tail.ts +48 -0
- package/src/format.ts +276 -0
- package/src/index.ts +700 -0
- package/src/manager-client.ts +839 -0
- package/src/monitor-batching.ts +191 -0
- package/src/monitor.ts +517 -0
- package/src/native-manager.d.ts +9 -0
- package/src/native-manager.js +39 -0
- package/src/notify.ts +231 -0
- package/src/shell-exec.ts +145 -0
- package/src/subagent/agent-records.ts +168 -0
- package/src/subagent/child-bash.ts +183 -0
- package/src/subagent/conversation.ts +76 -0
- package/src/subagent/fleet-widget.ts +176 -0
- package/src/subagent/model-spec.ts +108 -0
- package/src/subagent/pi-runtime.ts +295 -0
- package/src/subagent/pool.ts +190 -0
- package/src/subagent/registry.ts +575 -0
- package/src/subagent/runner.ts +709 -0
- package/src/subagent/tool.ts +741 -0
- package/src/subagent/transcript.ts +71 -0
- package/src/subagent/types.ts +125 -0
- package/src/task-exit-notify.ts +21 -0
- package/src/task-tools.ts +330 -0
- package/src/tui/message-renderers.ts +183 -0
- package/src/tui/pi-tui-load.ts +186 -0
- package/src/tui/scroll-detail-view.ts +190 -0
- package/src/tui/task-output-paths.ts +46 -0
- package/src/tui/tasks-command.ts +515 -0
- package/src/tui/tool-component.ts +29 -0
- package/src/wake.ts +219 -0
- package/src/work-index.ts +182 -0
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* monitor tool (design doc §4.4).
|
|
3
|
+
*
|
|
4
|
+
* Starts a long-lived `kind:"monitor"` process via pi-famulus, watches its
|
|
5
|
+
* output stream, and injects line batches as <pi-famulus-wake kind="monitor"> messages.
|
|
6
|
+
* Batching (LineBatcher) and throttling (RateLimiter) happen extension-side;
|
|
7
|
+
* a monitor is stopped when at least half its batches are dropped in a rolling
|
|
8
|
+
* 30-second window.
|
|
9
|
+
*/
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { formatMonitorEvent } from "./format";
|
|
13
|
+
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
14
|
+
|
|
15
|
+
import type { ManagerClient, ManagerEvent, TaskRecord } from "./manager-client";
|
|
16
|
+
import { LineBatcher, RateLimiter, SaturationWindow } from "./monitor-batching";
|
|
17
|
+
import type { NotifyCenter } from "./notify";
|
|
18
|
+
import { statusGlyph, toolComponent } from "./tui/tool-component";
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
22
|
+
const MIN_TIMEOUT_MS = 1_000;
|
|
23
|
+
const MAX_TIMEOUT_MS = 3_600_000;
|
|
24
|
+
/** Rolling-window drop ratio required before a monitor is auto-stopped. */
|
|
25
|
+
const SATURATION_WINDOW_MS = 30_000;
|
|
26
|
+
const SATURATION_DROP_RATIO = 0.5;
|
|
27
|
+
const SATURATION_MIN_BATCHES = 10;
|
|
28
|
+
/**
|
|
29
|
+
* A clean exit this soon after an event was caused by it (`grep -m1`). Its
|
|
30
|
+
* notice is appended without starting a turn: as a wake it cost every such monitor a
|
|
31
|
+
* turn spent acknowledging "exited" (eval batch 1, 2026-09-29).
|
|
32
|
+
*/
|
|
33
|
+
const EXIT_AFTER_EVENT_MS = 2_000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Model-facing, after the start line. The transcript row shows the first line only.
|
|
37
|
+
* After Claude Code's monitor start result: say everything that will arrive,
|
|
38
|
+
* so there is nothing left to verify, and name the polls. With "do not check
|
|
39
|
+
* on it", models still called task_list once while "waiting" (eval
|
|
40
|
+
* 2026-09-30c: 5 of the 6 FAILs outside grok). Its "Keep working" was tried
|
|
41
|
+
* and dropped: gpt-6-luna started duplicate monitors (eval 2026-09-30d).
|
|
42
|
+
*/
|
|
43
|
+
export const MONITOR_STARTED_INSTRUCTION =
|
|
44
|
+
'You will get a <pi-famulus-wake kind="monitor"> for each event, and a notice when it exits or times out. ' +
|
|
45
|
+
"Do not poll it (task_list, task_output, or reading what it watches) or sleep. " +
|
|
46
|
+
"If nothing else is left to do, reply to the user now with no tool call.";
|
|
47
|
+
/**
|
|
48
|
+
* Events for ids the registry does not know yet. The manager streams a monitor
|
|
49
|
+
* from spawn, so output and even the exit can arrive before `start()` has the
|
|
50
|
+
* task id (same socket read as the start response). Bounded: unrelated shell
|
|
51
|
+
* exits land here too and age out.
|
|
52
|
+
*/
|
|
53
|
+
const EARLY_MAX_IDS = 32;
|
|
54
|
+
const EARLY_MAX_CHARS = 64 * 1024;
|
|
55
|
+
|
|
56
|
+
export interface MonitorDeps {
|
|
57
|
+
getClient: () => ManagerClient | null;
|
|
58
|
+
sessionEnv: (ctx: ExtensionContext) => Record<string, string>;
|
|
59
|
+
getNotifyCenter: () => NotifyCenter | null;
|
|
60
|
+
trackTask: (taskId: string, meta: { kind: string; command: string; cwd?: string }) => void;
|
|
61
|
+
/** Optional TUI toast for lifecycle notices (exit / timeout / rate-limit). */
|
|
62
|
+
toast?: (message: string, type?: "info" | "warning" | "error") => void;
|
|
63
|
+
clock?: Clock;
|
|
64
|
+
logEvent?: (type: string, fields?: Record<string, unknown>) => void;
|
|
65
|
+
/** A known monitor ended (event, replayed early exit, or reconcile). */
|
|
66
|
+
onExited?: (taskId: string, event: ManagerEvent) => void;
|
|
67
|
+
/**
|
|
68
|
+
* After a timeout or rate-limit stop. The process may already have exited
|
|
69
|
+
* with its exit event lost, in which case no further event will settle it.
|
|
70
|
+
*/
|
|
71
|
+
afterStop?: () => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface MonitorEntry {
|
|
75
|
+
taskId: string;
|
|
76
|
+
description: string;
|
|
77
|
+
startedAt: number;
|
|
78
|
+
batcher: LineBatcher;
|
|
79
|
+
limiter: RateLimiter;
|
|
80
|
+
saturation: SaturationWindow;
|
|
81
|
+
droppedLinesPending: number;
|
|
82
|
+
timeoutTimer: ClockTimer | null;
|
|
83
|
+
stopped: boolean;
|
|
84
|
+
cursor: number;
|
|
85
|
+
recovering: boolean;
|
|
86
|
+
queuedOutput: { chunk: string; cursor?: number }[];
|
|
87
|
+
lastEventAt?: number;
|
|
88
|
+
/** The model asked to stop it (task_stop): what is left is not news. */
|
|
89
|
+
stopRequested?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class MonitorRegistry {
|
|
93
|
+
private readonly deps: MonitorDeps;
|
|
94
|
+
private readonly clock: Clock;
|
|
95
|
+
private readonly entries = new Map<string, MonitorEntry>();
|
|
96
|
+
private readonly changeListeners = new Set<() => void>();
|
|
97
|
+
private readonly early = new Map<string, { chunks: { chunk: string; cursor?: number }[]; chars: number; exit?: ManagerEvent }>();
|
|
98
|
+
|
|
99
|
+
constructor(deps: MonitorDeps) {
|
|
100
|
+
this.deps = deps;
|
|
101
|
+
this.clock = deps.clock ?? realClock;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Subscribe to start/stop transitions (fleet status refresh). */
|
|
105
|
+
onChange(cb: () => void): () => void {
|
|
106
|
+
this.changeListeners.add(cb);
|
|
107
|
+
return () => this.changeListeners.delete(cb);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Active monitors for the fleet status surface. */
|
|
111
|
+
listActive(): { taskId: string; description: string; startedAt: number }[] {
|
|
112
|
+
return [...this.entries.values()]
|
|
113
|
+
.filter((e) => !e.stopped)
|
|
114
|
+
.map((e) => ({ taskId: e.taskId, description: e.description, startedAt: e.startedAt }))
|
|
115
|
+
.sort((a, b) => a.startedAt - b.startedAt);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private emitChange(): void {
|
|
119
|
+
for (const cb of [...this.changeListeners]) {
|
|
120
|
+
try {
|
|
121
|
+
cb();
|
|
122
|
+
} catch {
|
|
123
|
+
// ignore listener errors
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
has(taskId: string): boolean {
|
|
129
|
+
return this.entries.has(taskId);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Start a monitor process and subscribe to its output stream. */
|
|
133
|
+
async start(
|
|
134
|
+
params: { command: string; description: string; timeout_ms?: number; persistent?: boolean },
|
|
135
|
+
ctx: ExtensionContext,
|
|
136
|
+
): Promise<{ taskId: string; timeoutMs: number | null }> {
|
|
137
|
+
const client = this.deps.getClient();
|
|
138
|
+
if (!client || !(await client.ensureAvailable())) {
|
|
139
|
+
throw new Error("pi-famulus is not available in this session; monitor is disabled");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const persistent = params.persistent === true;
|
|
143
|
+
let timeoutMs: number | null = null;
|
|
144
|
+
if (!persistent) {
|
|
145
|
+
const requested = params.timeout_ms ?? DEFAULT_TIMEOUT_MS;
|
|
146
|
+
timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, Math.round(requested)));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { task_id } = await client.start({
|
|
150
|
+
kind: "monitor",
|
|
151
|
+
command: params.command,
|
|
152
|
+
cwd: ctx.cwd,
|
|
153
|
+
env: fullEnv(ctx, this.deps),
|
|
154
|
+
run_in_background: true,
|
|
155
|
+
timeout_ms: null, // timeout is enforced extension-side to control the notice
|
|
156
|
+
origin: { via: "monitor" },
|
|
157
|
+
});
|
|
158
|
+
this.deps.trackTask(task_id, { kind: "monitor", command: params.command, cwd: ctx.cwd });
|
|
159
|
+
|
|
160
|
+
const entry: MonitorEntry = {
|
|
161
|
+
taskId: task_id,
|
|
162
|
+
description: params.description,
|
|
163
|
+
startedAt: this.clock.now(),
|
|
164
|
+
batcher: null as unknown as LineBatcher, // assigned below (self-reference in callback)
|
|
165
|
+
limiter: new RateLimiter({ clock: this.clock }),
|
|
166
|
+
saturation: new SaturationWindow({
|
|
167
|
+
windowMs: SATURATION_WINDOW_MS,
|
|
168
|
+
dropRatio: SATURATION_DROP_RATIO,
|
|
169
|
+
minimumBatches: SATURATION_MIN_BATCHES,
|
|
170
|
+
}),
|
|
171
|
+
droppedLinesPending: 0,
|
|
172
|
+
timeoutTimer: null,
|
|
173
|
+
stopped: false,
|
|
174
|
+
cursor: 0,
|
|
175
|
+
recovering: false,
|
|
176
|
+
queuedOutput: [],
|
|
177
|
+
};
|
|
178
|
+
entry.batcher = new LineBatcher({
|
|
179
|
+
onFlush: (text) => this.onBatch(entry, text),
|
|
180
|
+
clock: this.clock,
|
|
181
|
+
});
|
|
182
|
+
if (timeoutMs !== null) {
|
|
183
|
+
entry.timeoutTimer = this.clock.setTimeout(() => {
|
|
184
|
+
void this.timeout(entry);
|
|
185
|
+
}, timeoutMs);
|
|
186
|
+
this.clock.unref?.(entry.timeoutTimer);
|
|
187
|
+
}
|
|
188
|
+
this.entries.set(task_id, entry);
|
|
189
|
+
this.emitChange();
|
|
190
|
+
// Replay what arrived before we knew the id: lines first, then the exit.
|
|
191
|
+
const early = this.early.get(task_id);
|
|
192
|
+
this.early.delete(task_id);
|
|
193
|
+
for (const output of early?.chunks ?? []) this.acceptOutput(entry, output.chunk, output.cursor);
|
|
194
|
+
if (early?.exit) this.handleExit(task_id, early.exit);
|
|
195
|
+
// Older managers only stream after an explicit watch; newer ones already do.
|
|
196
|
+
else await client.watch(task_id).catch(() => {});
|
|
197
|
+
return { taskId: task_id, timeoutMs };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Handle a watched output event from the manager. */
|
|
201
|
+
handleOutput(taskId: string, chunk: string, nextCursor?: number): void {
|
|
202
|
+
const entry = this.entries.get(taskId);
|
|
203
|
+
if (entry) {
|
|
204
|
+
if (entry.recovering) entry.queuedOutput.push({ chunk, cursor: nextCursor });
|
|
205
|
+
else this.acceptOutput(entry, chunk, nextCursor);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const early = this.earlyFor(taskId);
|
|
209
|
+
if (early.chars + chunk.length > EARLY_MAX_CHARS) return;
|
|
210
|
+
early.chunks.push({ chunk, cursor: nextCursor });
|
|
211
|
+
early.chars += chunk.length;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private acceptOutput(entry: MonitorEntry, chunk: string, nextCursor?: number): void {
|
|
215
|
+
let deliver = Buffer.from(chunk, "utf8");
|
|
216
|
+
if (typeof nextCursor === "number") {
|
|
217
|
+
if (nextCursor <= entry.cursor) return;
|
|
218
|
+
const startCursor = nextCursor - deliver.byteLength;
|
|
219
|
+
if (startCursor < entry.cursor) {
|
|
220
|
+
deliver = deliver.subarray(entry.cursor - startCursor);
|
|
221
|
+
}
|
|
222
|
+
entry.cursor = nextCursor;
|
|
223
|
+
} else {
|
|
224
|
+
entry.cursor += deliver.byteLength;
|
|
225
|
+
}
|
|
226
|
+
if (deliver.byteLength > 0) entry.batcher.push(deliver.toString("utf8"));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* task_stop is about to stop this monitor at the model's request. Returns
|
|
231
|
+
* an undo for a stop that fails: the monitor keeps running and must keep
|
|
232
|
+
* waking the model.
|
|
233
|
+
*/
|
|
234
|
+
noteStopRequested(taskId: string): (() => void) | undefined {
|
|
235
|
+
const entry = this.entries.get(taskId);
|
|
236
|
+
if (!entry) return undefined;
|
|
237
|
+
entry.stopRequested = true;
|
|
238
|
+
return () => {
|
|
239
|
+
entry.stopRequested = false;
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Handle the manager's task_exited event. Returns true when it closed a
|
|
245
|
+
* known monitor; an unknown id is kept briefly in case `start()` is about to
|
|
246
|
+
* register it.
|
|
247
|
+
*/
|
|
248
|
+
handleExit(taskId: string, event: ManagerEvent): boolean {
|
|
249
|
+
const entry = this.entries.get(taskId);
|
|
250
|
+
if (!entry) {
|
|
251
|
+
this.earlyFor(taskId).exit = event;
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
// Drain remaining buffered lines before closing out.
|
|
255
|
+
entry.batcher.flush();
|
|
256
|
+
const alreadyStopped = entry.stopped;
|
|
257
|
+
this.cleanup(entry);
|
|
258
|
+
this.deps.onExited?.(taskId, event);
|
|
259
|
+
if (alreadyStopped) return true; // timeout/saturation notice already sent
|
|
260
|
+
const exitCode = event.exit_code ?? null;
|
|
261
|
+
// A clean exit soon after an event is the command finishing on it
|
|
262
|
+
// (grep -m1), unless someone stopped it (TUI stop: end_reason "tui").
|
|
263
|
+
const causedByEvent =
|
|
264
|
+
exitCode === 0 &&
|
|
265
|
+
!event.signal &&
|
|
266
|
+
(event.end_reason ?? "exited") === "exited" &&
|
|
267
|
+
entry.lastEventAt !== undefined &&
|
|
268
|
+
this.clock.now() - entry.lastEventAt <= EXIT_AFTER_EVENT_MS;
|
|
269
|
+
const duration =
|
|
270
|
+
typeof event.duration_ms === "number" ? `${(event.duration_ms / 1000).toFixed(1)}s` : "unknown duration";
|
|
271
|
+
this.deps.logEvent?.("monitor.stop", { id: entry.taskId, reason: event.end_reason ?? "exited" });
|
|
272
|
+
this.deps.getNotifyCenter()?.notify(
|
|
273
|
+
formatMonitorEvent(
|
|
274
|
+
entry.description,
|
|
275
|
+
entry.taskId,
|
|
276
|
+
`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.` : ""}`,
|
|
277
|
+
"exited",
|
|
278
|
+
{ droppedLines: entry.droppedLinesPending },
|
|
279
|
+
),
|
|
280
|
+
{ passive: causedByEvent || entry.stopRequested === true, quietEvents: entry.stopRequested === true },
|
|
281
|
+
);
|
|
282
|
+
this.deps.toast?.(
|
|
283
|
+
`Monitor "${entry.description}" exited (code ${exitCode === null ? "?" : exitCode})`,
|
|
284
|
+
exitCode === 0 || exitCode === null ? "info" : "warning",
|
|
285
|
+
);
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Close monitors the manager already reports as finished: the safety net for
|
|
291
|
+
* an exit event that never reached us (reconnect, older manager). Returns the
|
|
292
|
+
* ids it closed.
|
|
293
|
+
*/
|
|
294
|
+
reconcile(tasks: readonly TaskRecord[]): string[] {
|
|
295
|
+
const closed: string[] = [];
|
|
296
|
+
for (const task of tasks) {
|
|
297
|
+
if (task.status === "running" || !this.entries.has(task.task_id)) continue;
|
|
298
|
+
this.handleExit(task.task_id, exitEventFromRecord(task));
|
|
299
|
+
closed.push(task.task_id);
|
|
300
|
+
}
|
|
301
|
+
return closed;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private earlyFor(taskId: string): { chunks: { chunk: string; cursor?: number }[]; chars: number; exit?: ManagerEvent } {
|
|
305
|
+
let early = this.early.get(taskId);
|
|
306
|
+
if (!early) {
|
|
307
|
+
early = { chunks: [], chars: 0 };
|
|
308
|
+
this.early.set(taskId, early);
|
|
309
|
+
while (this.early.size > EARLY_MAX_IDS) this.early.delete(this.early.keys().next().value as string);
|
|
310
|
+
}
|
|
311
|
+
return early;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Re-subscribe watches after a manager reconnect, then drop monitors that ended meanwhile. */
|
|
315
|
+
async rewatchAll(): Promise<void> {
|
|
316
|
+
const client = this.deps.getClient();
|
|
317
|
+
if (!client || !client.isAvailable()) return;
|
|
318
|
+
for (const entry of this.entries.values()) {
|
|
319
|
+
entry.recovering = true;
|
|
320
|
+
// Re-hello on an upgraded daemon already replays the exact missed watch
|
|
321
|
+
// range. watch() is idempotent; do not output(cursor)-backfill it again.
|
|
322
|
+
await client.watch(entry.taskId).catch(() => {});
|
|
323
|
+
entry.recovering = false;
|
|
324
|
+
const queued = entry.queuedOutput.splice(0).sort((a, b) => (a.cursor ?? 0) - (b.cursor ?? 0));
|
|
325
|
+
for (const output of queued) this.acceptOutput(entry, output.chunk, output.cursor);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
disposeAll(): void {
|
|
330
|
+
for (const entry of this.entries.values()) {
|
|
331
|
+
this.cleanup(entry);
|
|
332
|
+
}
|
|
333
|
+
this.entries.clear();
|
|
334
|
+
this.early.clear();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private onBatch(entry: MonitorEntry, text: string): void {
|
|
338
|
+
if (entry.stopped) return;
|
|
339
|
+
const now = this.clock.now();
|
|
340
|
+
const accepted = entry.limiter.tryConsume();
|
|
341
|
+
entry.saturation.record(!accepted, now);
|
|
342
|
+
if (!accepted) {
|
|
343
|
+
const droppedLines = text.split("\n").length;
|
|
344
|
+
this.deps.logEvent?.("monitor.drop", { id: entry.taskId, lines: droppedLines });
|
|
345
|
+
entry.droppedLinesPending += droppedLines;
|
|
346
|
+
// Already stopping at the model's request: no rate-limit stop or wake.
|
|
347
|
+
if (entry.saturation.isSaturated(now) && !entry.stopRequested) void this.autoStop(entry);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const droppedLines = entry.droppedLinesPending;
|
|
351
|
+
entry.droppedLinesPending = 0;
|
|
352
|
+
entry.lastEventAt = now;
|
|
353
|
+
// Lines still buffered when the model stopped it: recorded, coalesced
|
|
354
|
+
// with that monitor's other pending output, and never a wake.
|
|
355
|
+
this.deps.getNotifyCenter()?.notifyMonitorEvent(entry.description, entry.taskId, text, droppedLines, {
|
|
356
|
+
quiet: entry.stopRequested === true,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Timeout reached: stop the process and notify (§4.4). */
|
|
361
|
+
private async timeout(entry: MonitorEntry): Promise<void> {
|
|
362
|
+
if (entry.stopped) return;
|
|
363
|
+
entry.stopped = true;
|
|
364
|
+
this.deps.logEvent?.("monitor.stop", { id: entry.taskId, reason: "timeout" });
|
|
365
|
+
const client = this.deps.getClient();
|
|
366
|
+
await client?.stop(entry.taskId, "timeout").catch(() => {});
|
|
367
|
+
this.deps.getNotifyCenter()?.notify(
|
|
368
|
+
formatMonitorEvent(
|
|
369
|
+
entry.description,
|
|
370
|
+
entry.taskId,
|
|
371
|
+
"[Monitor timed out — re-arm if needed.]",
|
|
372
|
+
|
|
373
|
+
"timeout",
|
|
374
|
+
{ droppedLines: entry.droppedLinesPending },
|
|
375
|
+
),
|
|
376
|
+
);
|
|
377
|
+
this.deps.toast?.(`Monitor "${entry.description}" timed out — re-arm if needed.`, "warning");
|
|
378
|
+
this.cleanup(entry);
|
|
379
|
+
this.deps.afterStop?.();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Rate limiter saturated for too long: stop and notify (§4.4). */
|
|
383
|
+
private async autoStop(entry: MonitorEntry): Promise<void> {
|
|
384
|
+
if (entry.stopped) return;
|
|
385
|
+
entry.stopped = true;
|
|
386
|
+
this.deps.logEvent?.("monitor.stop", { id: entry.taskId, reason: "rate-limit" });
|
|
387
|
+
const client = this.deps.getClient();
|
|
388
|
+
await client?.stop(entry.taskId, "rate-limit").catch(() => {});
|
|
389
|
+
this.deps.getNotifyCenter()?.notify(
|
|
390
|
+
formatMonitorEvent(
|
|
391
|
+
entry.description,
|
|
392
|
+
entry.taskId,
|
|
393
|
+
"[Monitor stopped: at least half of output batches were dropped in the last 30s.]",
|
|
394
|
+
|
|
395
|
+
"stopped",
|
|
396
|
+
{ droppedLines: entry.droppedLinesPending },
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
this.deps.toast?.(
|
|
400
|
+
`Monitor "${entry.description}" stopped — too much output (rate limit).`,
|
|
401
|
+
"warning",
|
|
402
|
+
);
|
|
403
|
+
this.cleanup(entry);
|
|
404
|
+
this.deps.afterStop?.();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private cleanup(entry: MonitorEntry): void {
|
|
408
|
+
entry.stopped = true;
|
|
409
|
+
entry.batcher.dispose();
|
|
410
|
+
entry.limiter.dispose();
|
|
411
|
+
if (entry.timeoutTimer !== null) {
|
|
412
|
+
this.clock.clearTimeout(entry.timeoutTimer);
|
|
413
|
+
entry.timeoutTimer = null;
|
|
414
|
+
}
|
|
415
|
+
this.entries.delete(entry.taskId);
|
|
416
|
+
this.emitChange();
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** A task_exited-shaped event rebuilt from a manager record (reconcile). */
|
|
421
|
+
export function exitEventFromRecord(task: TaskRecord): ManagerEvent {
|
|
422
|
+
return {
|
|
423
|
+
event: "task_exited",
|
|
424
|
+
task_id: task.task_id,
|
|
425
|
+
exit_code: task.exit_code,
|
|
426
|
+
signal: task.signal,
|
|
427
|
+
...(task.ended_at !== null ? { duration_ms: task.ended_at - task.started_at } : {}),
|
|
428
|
+
output_path: task.output_path,
|
|
429
|
+
...(task.end_reason ? { end_reason: task.end_reason } : {}),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function fullEnv(ctx: ExtensionContext, deps: MonitorDeps): Record<string, string> {
|
|
434
|
+
const env: Record<string, string> = {};
|
|
435
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
436
|
+
if (value !== undefined) env[key] = value;
|
|
437
|
+
}
|
|
438
|
+
Object.assign(env, deps.sessionEnv(ctx));
|
|
439
|
+
return env;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const monitorParameters = Type.Object({
|
|
443
|
+
command: Type.String({
|
|
444
|
+
description:
|
|
445
|
+
"Command producing one event per line on stdout. Must be line-buffered " +
|
|
446
|
+
"(e.g. use `stdbuf -oL` / `grep --line-buffered` where needed).",
|
|
447
|
+
}),
|
|
448
|
+
description: Type.String({
|
|
449
|
+
description: "Short human-readable description of what is being watched",
|
|
450
|
+
}),
|
|
451
|
+
timeout_ms: Type.Optional(
|
|
452
|
+
Type.Number({
|
|
453
|
+
description: `Stop the monitor after this many milliseconds (default ${DEFAULT_TIMEOUT_MS}, min ${MIN_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS})`,
|
|
454
|
+
}),
|
|
455
|
+
),
|
|
456
|
+
persistent: Type.Optional(
|
|
457
|
+
Type.Boolean({
|
|
458
|
+
description: "Keep the monitor alive until the session ends (no timeout). Default false.",
|
|
459
|
+
}),
|
|
460
|
+
),
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
export function createMonitorTool(
|
|
464
|
+
registry: MonitorRegistry,
|
|
465
|
+
): ToolDefinition<typeof monitorParameters, { task_id: string; timeout_ms: number | null }> {
|
|
466
|
+
return {
|
|
467
|
+
name: "monitor",
|
|
468
|
+
label: "Monitor",
|
|
469
|
+
description:
|
|
470
|
+
"Start a background monitor process whose stdout lines are injected back to you as " +
|
|
471
|
+
"<pi-famulus-wake kind=\"monitor\"> messages (batched over 200ms, rate-limited). " +
|
|
472
|
+
"The command must be line-buffered: each event must be a single line. " +
|
|
473
|
+
"It must keep running and follow its source, e.g. `tail -n +1 -F file | grep --line-buffered PATTERN`; " +
|
|
474
|
+
"a command that reads once and exits (a plain grep or cat) only reports what is there now. " +
|
|
475
|
+
"Add `-m1` to grep to stop after the first match. " +
|
|
476
|
+
"Silence is not success: write the command so failures also produce lines " +
|
|
477
|
+
"(e.g. grep for both success and error patterns). " +
|
|
478
|
+
"Events arrive as system wakes (not new user messages). Handle each <pi-famulus-wake kind=\"monitor\"> before other work. Do not poll.",
|
|
479
|
+
promptSnippet: "Watch a command's line stream and get injected events",
|
|
480
|
+
promptGuidelines: [
|
|
481
|
+
"Use the monitor tool to watch for conditions instead of running sleep/poll loops in bash.",
|
|
482
|
+
"When woken by a <pi-famulus-wake kind=\"monitor\">, handle the <event> before doing anything else — it is not a new user request and not user confirmation.",
|
|
483
|
+
],
|
|
484
|
+
parameters: monitorParameters,
|
|
485
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
486
|
+
const { taskId, timeoutMs } = await registry.start(params, ctx);
|
|
487
|
+
const timeoutText =
|
|
488
|
+
timeoutMs === null ? "persistent" : `timeout ${Math.round(timeoutMs / 1000)}s`;
|
|
489
|
+
return {
|
|
490
|
+
content: [
|
|
491
|
+
{
|
|
492
|
+
type: "text",
|
|
493
|
+
text: `Monitor started · task ${taskId} · ${timeoutText}\n${MONITOR_STARTED_INSTRUCTION}`,
|
|
494
|
+
},
|
|
495
|
+
],
|
|
496
|
+
details: { task_id: taskId, timeout_ms: timeoutMs },
|
|
497
|
+
};
|
|
498
|
+
},
|
|
499
|
+
renderCall(args, theme) {
|
|
500
|
+
const desc = String((args as { description?: string }).description ?? "monitor");
|
|
501
|
+
return toolComponent([
|
|
502
|
+
`${theme.fg("toolTitle", "Monitor")} ${theme.fg("muted", desc)}`,
|
|
503
|
+
]) as never;
|
|
504
|
+
},
|
|
505
|
+
renderResult(result, { expanded }, theme, context) {
|
|
506
|
+
const text = result.content
|
|
507
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
508
|
+
.map((c) => c.text)
|
|
509
|
+
.join("\n");
|
|
510
|
+
const firstLine = text.split("\n")[0];
|
|
511
|
+
const failed = context.isError || /\b(failed|killed|orphaned|error)\b/i.test(text);
|
|
512
|
+
const { color, glyph } = statusGlyph(failed ? "failed" : "completed", failed);
|
|
513
|
+
const line = `${theme.fg(color as "error", glyph)} ${firstLine}${expanded ? "" : theme.fg("dim", " · manage via /tasks")}`;
|
|
514
|
+
return toolComponent([line]) as never;
|
|
515
|
+
},
|
|
516
|
+
};
|
|
517
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface NativeManagerOptions {
|
|
2
|
+
platform?: string;
|
|
3
|
+
arch?: string;
|
|
4
|
+
/** Node package resolver; tests can use createRequire from an isolated consumer. */
|
|
5
|
+
resolve?: (specifier: string) => string;
|
|
6
|
+
}
|
|
7
|
+
export function nativePackageName(platform: string, arch: string): string | null;
|
|
8
|
+
/** Null when unsupported, missing, corrupt, non-executable, or a different version. */
|
|
9
|
+
export function resolveNativeManagerPath(options?: NativeManagerOptions): string | null;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { accessSync, constants, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { getSystemErrorMap } from "node:util";
|
|
4
|
+
|
|
5
|
+
// Filesystem failures mean this optional package is unusable, regardless of the syscall code.
|
|
6
|
+
const systemErrorCodes = new Set([...getSystemErrorMap().values()].map(([code]) => code));
|
|
7
|
+
const resolutionErrorCodes = new Set([
|
|
8
|
+
"MODULE_NOT_FOUND", "ERR_MODULE_NOT_FOUND", "ERR_INVALID_PACKAGE_CONFIG",
|
|
9
|
+
"ERR_INVALID_PACKAGE_TARGET", "ERR_PACKAGE_PATH_NOT_EXPORTED",
|
|
10
|
+
"ERR_PACKAGE_IMPORT_NOT_DEFINED", "ERR_UNSUPPORTED_DIR_IMPORT",
|
|
11
|
+
"ERR_INVALID_MODULE_SPECIFIER", "ERR_UNSUPPORTED_RESOLVE_REQUEST",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const packageVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
15
|
+
const resolveInstalled = createRequire(import.meta.url).resolve;
|
|
16
|
+
|
|
17
|
+
/** The four native optional packages are selected by npm's os/cpu constraints. */
|
|
18
|
+
export function nativePackageName(platform, arch) {
|
|
19
|
+
if (!["linux", "darwin"].includes(platform) || !["x64", "arm64"].includes(arch)) return null;
|
|
20
|
+
return `pi-famulus-${platform}-${arch}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Resolve the exact-version optional package without loading native executable bytes. */
|
|
24
|
+
export function resolveNativeManagerPath({ platform = process.platform, arch = process.arch, resolve = resolveInstalled } = {}) {
|
|
25
|
+
const name = nativePackageName(platform, arch);
|
|
26
|
+
if (!name) return null;
|
|
27
|
+
try {
|
|
28
|
+
const metadata = JSON.parse(readFileSync(resolve(`${name}/package.json`), "utf8"));
|
|
29
|
+
if (!metadata || metadata.name !== name || metadata.version !== packageVersion) return null;
|
|
30
|
+
const binary = resolve(`${name}/bin/pi-famulus`);
|
|
31
|
+
const stat = statSync(binary);
|
|
32
|
+
if (!stat.isFile() || stat.size <= 0) return null;
|
|
33
|
+
accessSync(binary, constants.X_OK);
|
|
34
|
+
return binary;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error instanceof SyntaxError || systemErrorCodes.has(error?.code) || resolutionErrorCodes.has(error?.code)) return null;
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|