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/README.md +14 -7
- package/bin/pi-famulus.js +2 -2
- package/package.json +8 -8
- package/src/bash-override.ts +34 -26
- package/src/bounded-output.ts +167 -0
- package/src/comms/reply-command.ts +1 -1
- package/src/config.ts +2 -6
- package/src/format.ts +33 -7
- package/src/index.ts +39 -8
- package/src/manager-client.ts +24 -2
- package/src/monitor-batching.ts +84 -36
- package/src/monitor-output.ts +159 -0
- package/src/monitor.ts +148 -37
- package/src/native-manager.d.ts +30 -0
- package/src/native-manager.js +260 -11
- package/src/notify.ts +100 -17
- package/src/prompts.generated.ts +14 -11
- package/src/shell-exec.ts +28 -2
- package/src/shell-start.ts +25 -0
- package/src/subagent/child-bash.ts +2 -1
- package/src/subagent/conversation.ts +57 -12
- package/src/subagent/fleet-widget.ts +8 -8
- package/src/subagent/runner.ts +2 -2
- package/src/subagent/tool.ts +2 -1
- package/src/task-tools.ts +32 -13
- package/src/tui/conversation-view.ts +402 -0
- package/src/tui/message-renderers.ts +16 -11
- package/src/tui/scroll-detail-view.ts +218 -99
- package/src/tui/task-detail-view.ts +417 -0
- package/src/tui/tasks-command.ts +117 -84
- package/src/tui/tool-component.ts +6 -2
- package/src/wake.ts +27 -1
package/src/monitor.ts
CHANGED
|
@@ -8,16 +8,20 @@
|
|
|
8
8
|
* 30-second window.
|
|
9
9
|
*/
|
|
10
10
|
import { Type } from "typebox";
|
|
11
|
+
import { shellStartStatus } from "./shell-start";
|
|
11
12
|
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
12
13
|
import { formatMonitorEvent } from "./format";
|
|
13
14
|
import { MONITOR_IDLE_INSTRUCTION } from "./behavior-guidelines";
|
|
14
15
|
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
15
16
|
|
|
16
|
-
import type { ManagerClient, ManagerEvent, TaskRecord } from "./manager-client";
|
|
17
|
+
import type { ManagerClient, ManagerEvent, MonitorOutputRecord, TaskRecord } from "./manager-client";
|
|
18
|
+
import { DEFAULT_DEDUP_WINDOW_MS, MonitorOutputBuffer, mergeOutputInfo, type FingerprintMode } from "./monitor-output";
|
|
19
|
+
import type { MonitorOutputInfo } from "./wake";
|
|
17
20
|
import { LineBatcher, RateLimiter, SaturationWindow } from "./monitor-batching";
|
|
18
21
|
import type { NotifyCenter } from "./notify";
|
|
19
22
|
import { fill, fillList, PROMPTS } from "./prompts.generated";
|
|
20
23
|
import { statusGlyph, toolComponent } from "./tui/tool-component";
|
|
24
|
+
import type { WorkIndex } from "./work-index";
|
|
21
25
|
|
|
22
26
|
|
|
23
27
|
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
@@ -75,6 +79,10 @@ interface MonitorEntry {
|
|
|
75
79
|
description: string;
|
|
76
80
|
startedAt: number;
|
|
77
81
|
batcher: LineBatcher;
|
|
82
|
+
output: MonitorOutputBuffer;
|
|
83
|
+
legacyOutput: MonitorOutputInfo;
|
|
84
|
+
recordCursor: number;
|
|
85
|
+
lastRecordKind?: "line" | "progress";
|
|
78
86
|
limiter: RateLimiter;
|
|
79
87
|
saturation: SaturationWindow;
|
|
80
88
|
droppedLinesPending: number;
|
|
@@ -82,10 +90,12 @@ interface MonitorEntry {
|
|
|
82
90
|
stopped: boolean;
|
|
83
91
|
cursor: number;
|
|
84
92
|
recovering: boolean;
|
|
85
|
-
queuedOutput: { chunk: string; cursor?: number }[];
|
|
93
|
+
queuedOutput: { chunk: string; cursor?: number; records?: MonitorOutputRecord[] }[];
|
|
86
94
|
lastEventAt?: number;
|
|
87
95
|
/** The model asked to stop it (task_stop): what is left is not news. */
|
|
88
96
|
stopRequested?: boolean;
|
|
97
|
+
/** Prevents autoStop from re-entering while it drains the 200ms batcher. */
|
|
98
|
+
stopping?: boolean;
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
export class MonitorRegistry {
|
|
@@ -93,7 +103,7 @@ export class MonitorRegistry {
|
|
|
93
103
|
private readonly clock: Clock;
|
|
94
104
|
private readonly entries = new Map<string, MonitorEntry>();
|
|
95
105
|
private readonly changeListeners = new Set<() => void>();
|
|
96
|
-
private readonly early = new Map<string, { chunks: { chunk: string; cursor?: number }[]; chars: number; exit?: ManagerEvent }>();
|
|
106
|
+
private readonly early = new Map<string, { chunks: { chunk: string; cursor?: number; records?: MonitorOutputRecord[] }[]; chars: number; exit?: ManagerEvent }>();
|
|
97
107
|
|
|
98
108
|
constructor(deps: MonitorDeps) {
|
|
99
109
|
this.deps = deps;
|
|
@@ -130,7 +140,7 @@ export class MonitorRegistry {
|
|
|
130
140
|
|
|
131
141
|
/** Start a monitor process and subscribe to its output stream. */
|
|
132
142
|
async start(
|
|
133
|
-
params: { command: string; description: string; timeout_ms?: number; persistent?: boolean },
|
|
143
|
+
params: { command: string; description: string; timeout_ms?: number; persistent?: boolean; dedup_window_ms?: number; fingerprint_mode?: FingerprintMode },
|
|
134
144
|
ctx: ExtensionContext,
|
|
135
145
|
): Promise<{ taskId: string; timeoutMs: number | null }> {
|
|
136
146
|
const client = this.deps.getClient();
|
|
@@ -138,6 +148,12 @@ export class MonitorRegistry {
|
|
|
138
148
|
throw new Error("pi-famulus is not available in this session; monitor is disabled");
|
|
139
149
|
}
|
|
140
150
|
|
|
151
|
+
if (params.dedup_window_ms !== undefined && (!Number.isSafeInteger(params.dedup_window_ms) || params.dedup_window_ms < 0 || params.dedup_window_ms > 3_600_000)) {
|
|
152
|
+
throw new RangeError("dedup_window_ms must be an integer between 0 and 3600000");
|
|
153
|
+
}
|
|
154
|
+
if (params.fingerprint_mode !== undefined && params.fingerprint_mode !== "exact" && params.fingerprint_mode !== "ignore-timestamps") {
|
|
155
|
+
throw new RangeError("fingerprint_mode must be exact or ignore-timestamps");
|
|
156
|
+
}
|
|
141
157
|
const persistent = params.persistent === true;
|
|
142
158
|
let timeoutMs: number | null = null;
|
|
143
159
|
if (!persistent) {
|
|
@@ -153,14 +169,19 @@ export class MonitorRegistry {
|
|
|
153
169
|
run_in_background: true,
|
|
154
170
|
timeout_ms: null, // timeout is enforced extension-side to control the notice
|
|
155
171
|
origin: { via: "monitor" },
|
|
172
|
+
label: params.description,
|
|
156
173
|
});
|
|
157
174
|
this.deps.trackTask(task_id, { kind: "monitor", command: params.command, cwd: ctx.cwd });
|
|
175
|
+
this.deps.logEvent?.("monitor.start", { id: task_id, description: params.description });
|
|
158
176
|
|
|
159
177
|
const entry: MonitorEntry = {
|
|
160
178
|
taskId: task_id,
|
|
161
179
|
description: params.description,
|
|
162
180
|
startedAt: this.clock.now(),
|
|
163
181
|
batcher: null as unknown as LineBatcher, // assigned below (self-reference in callback)
|
|
182
|
+
output: null as unknown as MonitorOutputBuffer,
|
|
183
|
+
legacyOutput: {},
|
|
184
|
+
recordCursor: -1,
|
|
164
185
|
limiter: new RateLimiter({ clock: this.clock }),
|
|
165
186
|
saturation: new SaturationWindow({
|
|
166
187
|
windowMs: SATURATION_WINDOW_MS,
|
|
@@ -175,9 +196,20 @@ export class MonitorRegistry {
|
|
|
175
196
|
queuedOutput: [],
|
|
176
197
|
};
|
|
177
198
|
entry.batcher = new LineBatcher({
|
|
178
|
-
onFlush: (text) =>
|
|
199
|
+
onFlush: (text, info) => {
|
|
200
|
+
const output = mergeOutputInfo(entry.legacyOutput, info);
|
|
201
|
+
entry.legacyOutput = {};
|
|
202
|
+
this.onBatch(entry, text, output);
|
|
203
|
+
},
|
|
179
204
|
clock: this.clock,
|
|
180
205
|
});
|
|
206
|
+
entry.output = new MonitorOutputBuffer({
|
|
207
|
+
clock: this.clock,
|
|
208
|
+
windowMs: params.dedup_window_ms ?? DEFAULT_DEDUP_WINDOW_MS,
|
|
209
|
+
mode: params.fingerprint_mode ?? "exact",
|
|
210
|
+
onFlush: ({ text, output }) => this.onBatch(entry, text, output, entry.output.onDelivered),
|
|
211
|
+
onSuppressed: () => this.deps.logEvent?.("monitor.dedupe", { id: entry.taskId }),
|
|
212
|
+
});
|
|
181
213
|
if (timeoutMs !== null) {
|
|
182
214
|
entry.timeoutTimer = this.clock.setTimeout(() => {
|
|
183
215
|
void this.timeout(entry);
|
|
@@ -189,7 +221,7 @@ export class MonitorRegistry {
|
|
|
189
221
|
// Replay what arrived before we knew the id: lines first, then the exit.
|
|
190
222
|
const early = this.early.get(task_id);
|
|
191
223
|
this.early.delete(task_id);
|
|
192
|
-
for (const output of early?.chunks ?? []) this.acceptOutput(entry, output.chunk, output.cursor);
|
|
224
|
+
for (const output of early?.chunks ?? []) this.acceptOutput(entry, output.chunk, output.cursor, output.records);
|
|
193
225
|
if (early?.exit) this.handleExit(task_id, early.exit);
|
|
194
226
|
// Older managers only stream after an explicit watch; newer ones already do.
|
|
195
227
|
else await client.watch(task_id).catch(() => {});
|
|
@@ -197,32 +229,60 @@ export class MonitorRegistry {
|
|
|
197
229
|
}
|
|
198
230
|
|
|
199
231
|
/** Handle a watched output event from the manager. */
|
|
200
|
-
handleOutput(taskId: string, chunk: string, nextCursor?: number): void {
|
|
232
|
+
handleOutput(taskId: string, chunk: string, nextCursor?: number, records?: MonitorOutputRecord[]): void {
|
|
201
233
|
const entry = this.entries.get(taskId);
|
|
202
234
|
if (entry) {
|
|
203
|
-
if (entry.recovering) entry.queuedOutput.push({ chunk, cursor: nextCursor });
|
|
204
|
-
else this.acceptOutput(entry, chunk, nextCursor);
|
|
235
|
+
if (entry.recovering) entry.queuedOutput.push({ chunk, cursor: nextCursor, records });
|
|
236
|
+
else this.acceptOutput(entry, chunk, nextCursor, records);
|
|
205
237
|
return;
|
|
206
238
|
}
|
|
207
239
|
const early = this.earlyFor(taskId);
|
|
208
|
-
|
|
209
|
-
early.
|
|
210
|
-
|
|
240
|
+
const cost = chunk.length + (records?.reduce((n, r) => n + r.preview.length + 256, 0) ?? 0);
|
|
241
|
+
if (early.chars + cost > EARLY_MAX_CHARS) {
|
|
242
|
+
// Metadata expansion (e.g. hundreds of empty lines) must not discard
|
|
243
|
+
// startup bytes which fit the budget. Convert the buffered prefix too:
|
|
244
|
+
// it may contain raw fragments held for a later completed-line record.
|
|
245
|
+
const rawCost = early.chunks.reduce((n, output) => n + output.chunk.length, 0) + chunk.length;
|
|
246
|
+
if (rawCost > EARLY_MAX_CHARS) return;
|
|
247
|
+
for (const output of early.chunks) output.records = undefined;
|
|
248
|
+
early.chunks.push({ chunk, cursor: nextCursor });
|
|
249
|
+
early.chars = rawCost;
|
|
250
|
+
} else {
|
|
251
|
+
early.chunks.push({ chunk, cursor: nextCursor, records });
|
|
252
|
+
early.chars += cost;
|
|
253
|
+
}
|
|
211
254
|
}
|
|
212
255
|
|
|
213
|
-
private acceptOutput(entry: MonitorEntry, chunk: string, nextCursor?: number): void {
|
|
256
|
+
private acceptOutput(entry: MonitorEntry, chunk: string, nextCursor?: number, records?: MonitorOutputRecord[]): void {
|
|
214
257
|
let deliver = Buffer.from(chunk, "utf8");
|
|
258
|
+
const previousCursor = entry.cursor;
|
|
215
259
|
if (typeof nextCursor === "number") {
|
|
216
|
-
if (nextCursor <= entry.cursor)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
deliver = deliver.subarray(entry.cursor - startCursor);
|
|
260
|
+
if (nextCursor <= entry.cursor) deliver = Buffer.alloc(0);
|
|
261
|
+
else {
|
|
262
|
+
const startCursor = nextCursor - deliver.byteLength;
|
|
263
|
+
if (startCursor < entry.cursor) deliver = deliver.subarray(entry.cursor - startCursor);
|
|
264
|
+
entry.cursor = nextCursor;
|
|
220
265
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
266
|
+
} else entry.cursor += deliver.byteLength;
|
|
267
|
+
if (records !== undefined) {
|
|
268
|
+
// Empty metadata arrays deliberately hold raw fragments for Rust framing.
|
|
269
|
+
entry.batcher.flush();
|
|
270
|
+
for (const record of records) {
|
|
271
|
+
if (!validRecord(record, entry.cursor)) continue;
|
|
272
|
+
if (record.cursor_end < entry.recordCursor || (record.cursor_end === entry.recordCursor && !(entry.lastRecordKind === "progress" && record.kind === "line"))) continue;
|
|
273
|
+
entry.recordCursor = record.cursor_end;
|
|
274
|
+
entry.lastRecordKind = record.kind;
|
|
275
|
+
entry.output.push(record);
|
|
276
|
+
}
|
|
277
|
+
} else if (deliver.byteLength > 0) {
|
|
278
|
+
// Legacy manager or unavailable replay metadata: fail open, never hash in JS.
|
|
279
|
+
entry.output.flush();
|
|
280
|
+
entry.legacyOutput = mergeOutputInfo(entry.legacyOutput, {
|
|
281
|
+
firstObservedAt: this.clock.now(), lastObservedAt: this.clock.now(),
|
|
282
|
+
cursorStart: Math.max(previousCursor, entry.cursor - deliver.byteLength), cursorEnd: entry.cursor,
|
|
283
|
+
});
|
|
284
|
+
entry.batcher.push(deliver.toString("utf8"));
|
|
224
285
|
}
|
|
225
|
-
if (deliver.byteLength > 0) entry.batcher.push(deliver.toString("utf8"));
|
|
226
286
|
}
|
|
227
287
|
|
|
228
288
|
/**
|
|
@@ -252,10 +312,12 @@ export class MonitorRegistry {
|
|
|
252
312
|
}
|
|
253
313
|
// Drain remaining buffered lines before closing out.
|
|
254
314
|
entry.batcher.flush();
|
|
315
|
+
entry.output.flush();
|
|
255
316
|
const alreadyStopped = entry.stopped;
|
|
256
317
|
this.cleanup(entry);
|
|
257
318
|
this.deps.onExited?.(taskId, event);
|
|
258
|
-
if (alreadyStopped) return true; // timeout/saturation
|
|
319
|
+
if (alreadyStopped) return true; // timeout/saturation owns the pending summary
|
|
320
|
+
const summary = entry.output.takeSummary();
|
|
259
321
|
const exitCode = event.exit_code ?? null;
|
|
260
322
|
// A clean exit soon after an event is the command finishing on it
|
|
261
323
|
// (grep -m1), unless someone stopped it (TUI stop: end_reason "tui").
|
|
@@ -272,9 +334,9 @@ export class MonitorRegistry {
|
|
|
272
334
|
formatMonitorEvent(
|
|
273
335
|
entry.description,
|
|
274
336
|
entry.taskId,
|
|
275
|
-
`Monitor process exited (exit code ${exitCode === null ? "null" : exitCode}, after ${duration}). No further events will be delivered.${entry.droppedLinesPending > 0 ? ` ${entry.droppedLinesPending} output lines were dropped.` : ""}`,
|
|
337
|
+
`Monitor process exited (exit code ${exitCode === null ? "null" : exitCode}, after ${duration}).${startFailureNote(event.end_reason)} No further events will be delivered.${entry.droppedLinesPending > 0 ? ` ${entry.droppedLinesPending} output lines were dropped.` : ""}`,
|
|
276
338
|
"exited",
|
|
277
|
-
{ droppedLines: entry.droppedLinesPending },
|
|
339
|
+
{ droppedLines: entry.droppedLinesPending, ...(summary.suppressedCount ? { output: summary } : {}) },
|
|
278
340
|
),
|
|
279
341
|
{ passive: causedByEvent || entry.stopRequested === true, quietEvents: entry.stopRequested === true },
|
|
280
342
|
);
|
|
@@ -300,7 +362,7 @@ export class MonitorRegistry {
|
|
|
300
362
|
return closed;
|
|
301
363
|
}
|
|
302
364
|
|
|
303
|
-
private earlyFor(taskId: string): { chunks: { chunk: string; cursor?: number }[]; chars: number; exit?: ManagerEvent } {
|
|
365
|
+
private earlyFor(taskId: string): { chunks: { chunk: string; cursor?: number; records?: MonitorOutputRecord[] }[]; chars: number; exit?: ManagerEvent } {
|
|
304
366
|
let early = this.early.get(taskId);
|
|
305
367
|
if (!early) {
|
|
306
368
|
early = { chunks: [], chars: 0 };
|
|
@@ -321,7 +383,7 @@ export class MonitorRegistry {
|
|
|
321
383
|
await client.watch(entry.taskId).catch(() => {});
|
|
322
384
|
entry.recovering = false;
|
|
323
385
|
const queued = entry.queuedOutput.splice(0).sort((a, b) => (a.cursor ?? 0) - (b.cursor ?? 0));
|
|
324
|
-
for (const output of queued) this.acceptOutput(entry, output.chunk, output.cursor);
|
|
386
|
+
for (const output of queued) this.acceptOutput(entry, output.chunk, output.cursor, output.records);
|
|
325
387
|
}
|
|
326
388
|
}
|
|
327
389
|
|
|
@@ -333,8 +395,10 @@ export class MonitorRegistry {
|
|
|
333
395
|
this.early.clear();
|
|
334
396
|
}
|
|
335
397
|
|
|
336
|
-
private onBatch(entry: MonitorEntry, text: string)
|
|
337
|
-
if (entry.stopped) return;
|
|
398
|
+
private onBatch(entry: MonitorEntry, text: string, output?: MonitorOutputInfo, onDelivered?: () => void): boolean {
|
|
399
|
+
if (entry.stopped) return false;
|
|
400
|
+
const center = this.deps.getNotifyCenter();
|
|
401
|
+
if (!center) return false;
|
|
338
402
|
const now = this.clock.now();
|
|
339
403
|
const accepted = entry.limiter.tryConsume();
|
|
340
404
|
entry.saturation.record(!accepted, now);
|
|
@@ -342,23 +406,30 @@ export class MonitorRegistry {
|
|
|
342
406
|
const droppedLines = text.split("\n").length;
|
|
343
407
|
this.deps.logEvent?.("monitor.drop", { id: entry.taskId, lines: droppedLines });
|
|
344
408
|
entry.droppedLinesPending += droppedLines;
|
|
345
|
-
// Already stopping at the model's request
|
|
346
|
-
|
|
347
|
-
|
|
409
|
+
// Already stopping at the model's request, or draining for autoStop:
|
|
410
|
+
// no nested rate-limit stop or wake.
|
|
411
|
+
if (entry.saturation.isSaturated(now) && !entry.stopRequested && !entry.stopping) void this.autoStop(entry);
|
|
412
|
+
return false;
|
|
348
413
|
}
|
|
349
414
|
const droppedLines = entry.droppedLinesPending;
|
|
350
415
|
entry.droppedLinesPending = 0;
|
|
351
416
|
entry.lastEventAt = now;
|
|
352
417
|
// Lines still buffered when the model stopped it: recorded, coalesced
|
|
353
418
|
// with that monitor's other pending output, and never a wake.
|
|
354
|
-
|
|
419
|
+
center.notifyMonitorEvent(entry.description, entry.taskId, text, droppedLines, {
|
|
355
420
|
quiet: entry.stopRequested === true,
|
|
421
|
+
output,
|
|
422
|
+
onDelivered,
|
|
356
423
|
});
|
|
424
|
+
return true;
|
|
357
425
|
}
|
|
358
426
|
|
|
359
427
|
/** Timeout reached: stop the process and notify (§4.4). */
|
|
360
428
|
private async timeout(entry: MonitorEntry): Promise<void> {
|
|
361
429
|
if (entry.stopped) return;
|
|
430
|
+
entry.batcher.flush();
|
|
431
|
+
entry.output.flush();
|
|
432
|
+
if (entry.stopped) return; // A flush may already have tripped overload protection.
|
|
362
433
|
entry.stopped = true;
|
|
363
434
|
this.deps.logEvent?.("monitor.stop", { id: entry.taskId, reason: "timeout" });
|
|
364
435
|
const client = this.deps.getClient();
|
|
@@ -370,7 +441,7 @@ export class MonitorRegistry {
|
|
|
370
441
|
"[Monitor timed out — re-arm if needed.]",
|
|
371
442
|
|
|
372
443
|
"timeout",
|
|
373
|
-
{ droppedLines: entry.droppedLinesPending },
|
|
444
|
+
{ droppedLines: entry.droppedLinesPending, output: entry.output.takeSummary() },
|
|
374
445
|
),
|
|
375
446
|
);
|
|
376
447
|
this.deps.toast?.(`Monitor "${entry.description}" timed out — re-arm if needed.`, "warning");
|
|
@@ -380,6 +451,10 @@ export class MonitorRegistry {
|
|
|
380
451
|
|
|
381
452
|
/** Rate limiter saturated for too long: stop and notify (§4.4). */
|
|
382
453
|
private async autoStop(entry: MonitorEntry): Promise<void> {
|
|
454
|
+
if (entry.stopped || entry.stopping) return;
|
|
455
|
+
entry.stopping = true;
|
|
456
|
+
entry.batcher.flush();
|
|
457
|
+
entry.output.flush();
|
|
383
458
|
if (entry.stopped) return;
|
|
384
459
|
entry.stopped = true;
|
|
385
460
|
this.deps.logEvent?.("monitor.stop", { id: entry.taskId, reason: "rate-limit" });
|
|
@@ -392,7 +467,7 @@ export class MonitorRegistry {
|
|
|
392
467
|
"[Monitor stopped: at least half of output batches were dropped in the last 30s.]",
|
|
393
468
|
|
|
394
469
|
"stopped",
|
|
395
|
-
{ droppedLines: entry.droppedLinesPending },
|
|
470
|
+
{ droppedLines: entry.droppedLinesPending, output: entry.output.takeSummary() },
|
|
396
471
|
),
|
|
397
472
|
);
|
|
398
473
|
this.deps.toast?.(
|
|
@@ -406,6 +481,7 @@ export class MonitorRegistry {
|
|
|
406
481
|
private cleanup(entry: MonitorEntry): void {
|
|
407
482
|
entry.stopped = true;
|
|
408
483
|
entry.batcher.dispose();
|
|
484
|
+
entry.output.dispose();
|
|
409
485
|
entry.limiter.dispose();
|
|
410
486
|
if (entry.timeoutTimer !== null) {
|
|
411
487
|
this.clock.clearTimeout(entry.timeoutTimer);
|
|
@@ -416,6 +492,14 @@ export class MonitorRegistry {
|
|
|
416
492
|
}
|
|
417
493
|
}
|
|
418
494
|
|
|
495
|
+
function validRecord(record: MonitorOutputRecord, cursor: number): boolean {
|
|
496
|
+
return record?.version === 1 && (record.kind === "line" || record.kind === "progress") &&
|
|
497
|
+
typeof record.preview === "string" && record.preview.length <= 2000 &&
|
|
498
|
+
Number.isSafeInteger(record.cursor_start) && record.cursor_start >= 0 &&
|
|
499
|
+
Number.isSafeInteger(record.cursor_end) && record.cursor_end >= record.cursor_start && record.cursor_end <= cursor &&
|
|
500
|
+
Number.isFinite(record.observed_at) && record.observed_at >= 0;
|
|
501
|
+
}
|
|
502
|
+
|
|
419
503
|
/** A task_exited-shaped event rebuilt from a manager record (reconcile). */
|
|
420
504
|
export function exitEventFromRecord(task: TaskRecord): ManagerEvent {
|
|
421
505
|
return {
|
|
@@ -451,10 +535,13 @@ const monitorParameters = Type.Object({
|
|
|
451
535
|
}),
|
|
452
536
|
),
|
|
453
537
|
persistent: Type.Optional(Type.Boolean({ description: PROMPTS["tools.monitor.param.persistent"] })),
|
|
538
|
+
dedup_window_ms: Type.Optional(Type.Integer({ minimum: 0, maximum: 3_600_000, description: PROMPTS["tools.monitor.param.dedup_window_ms"] })),
|
|
539
|
+
fingerprint_mode: Type.Optional(Type.Union([Type.Literal("exact"), Type.Literal("ignore-timestamps")], { description: PROMPTS["tools.monitor.param.fingerprint_mode"] })),
|
|
454
540
|
});
|
|
455
541
|
|
|
456
542
|
export function createMonitorTool(
|
|
457
543
|
registry: MonitorRegistry,
|
|
544
|
+
getIndex?: () => WorkIndex | null,
|
|
458
545
|
): ToolDefinition<typeof monitorParameters, { task_id: string; timeout_ms: number | null }> {
|
|
459
546
|
return {
|
|
460
547
|
name: "monitor",
|
|
@@ -489,10 +576,34 @@ export function createMonitorTool(
|
|
|
489
576
|
.map((c) => c.text)
|
|
490
577
|
.join("\n");
|
|
491
578
|
const firstLine = text.split("\n")[0];
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const
|
|
579
|
+
// Live status from the work index (a started monitor is running, not
|
|
580
|
+
// done); the tool's own error flag when the start itself failed.
|
|
581
|
+
const taskId = (result.details as { task_id?: string } | undefined)?.task_id;
|
|
582
|
+
const index = getIndex?.() ?? null;
|
|
583
|
+
const item = taskId ? index?.get(taskId) : undefined;
|
|
584
|
+
const state = context.state as { unsubscribe?: () => void } | undefined;
|
|
585
|
+
if (index && taskId && state && !state.unsubscribe && (item?.status === "running" || item?.status === "pending")) {
|
|
586
|
+
state.unsubscribe = index.onChange(() => {
|
|
587
|
+
const now = index.get(taskId)?.status;
|
|
588
|
+
if (now !== "running" && now !== "pending") {
|
|
589
|
+
state.unsubscribe?.();
|
|
590
|
+
state.unsubscribe = () => {};
|
|
591
|
+
}
|
|
592
|
+
context.invalidate?.();
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
// Live monitors are indexed before this renders, so a missing row is a
|
|
596
|
+
// finished one that was pruned or predates a reload.
|
|
597
|
+
const status = context.isError ? "failed" : item?.status ?? (taskId && !index ? "running" : "completed");
|
|
598
|
+
const { color, glyph } = statusGlyph(status, context.isError);
|
|
599
|
+
const line = `${theme.fg(color as "error", glyph)} ${firstLine}${expanded ? "" : theme.fg("dim", " · /tasks")}`;
|
|
495
600
|
return toolComponent([line]) as never;
|
|
496
601
|
},
|
|
497
602
|
};
|
|
498
603
|
}
|
|
604
|
+
|
|
605
|
+
/** " <why>" when the monitor's Git Bash never started; "" otherwise. */
|
|
606
|
+
function startFailureNote(endReason: string | undefined): string {
|
|
607
|
+
const status = shellStartStatus(endReason);
|
|
608
|
+
return status ? ` ${status}` : "";
|
|
609
|
+
}
|
package/src/native-manager.d.ts
CHANGED
|
@@ -3,7 +3,37 @@ export interface NativeManagerOptions {
|
|
|
3
3
|
arch?: string;
|
|
4
4
|
/** Node package resolver; tests can use createRequire from an isolated consumer. */
|
|
5
5
|
resolve?: (specifier: string) => string;
|
|
6
|
+
/** Walk node_modules from this file instead of this module. */
|
|
7
|
+
from?: string;
|
|
8
|
+
/** Do not walk above this directory. */
|
|
9
|
+
stopAt?: string;
|
|
10
|
+
/** Versioned platform cache. `false` disables the cache lookup. */
|
|
11
|
+
cacheDir?: string | false;
|
|
12
|
+
}
|
|
13
|
+
export interface NativeCacheOptions {
|
|
14
|
+
version?: string;
|
|
15
|
+
platform?: string;
|
|
16
|
+
arch?: string;
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
home?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface EnsureNativeOptions extends NativeManagerOptions {
|
|
21
|
+
env?: NodeJS.ProcessEnv;
|
|
22
|
+
npmCommand?: string;
|
|
23
|
+
spawn?: (
|
|
24
|
+
command: string,
|
|
25
|
+
args: string[],
|
|
26
|
+
options?: { timeout?: number; windowsHide?: boolean; encoding?: "utf8" },
|
|
27
|
+
) => { status: number | null; error?: Error };
|
|
6
28
|
}
|
|
7
29
|
export function nativePackageName(platform: string, arch: string): string | null;
|
|
30
|
+
export function nativeCacheDir(options?: NativeCacheOptions): string;
|
|
8
31
|
/** Null when unsupported, missing, corrupt, non-executable, or a different version. */
|
|
9
32
|
export function resolveNativeManagerPath(options?: NativeManagerOptions): string | null;
|
|
33
|
+
/** `npm <package> <path>` or why the exact platform package was not usable. */
|
|
34
|
+
export function describeNativeLookup(options?: NativeManagerOptions): string;
|
|
35
|
+
/**
|
|
36
|
+
* Install the exact platform package into the versioned cache when it is missing.
|
|
37
|
+
* Returns the binary path, or null when it is still unusable.
|
|
38
|
+
*/
|
|
39
|
+
export function ensureNativeManagerInstalled(options?: EnsureNativeOptions): string | null;
|