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/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
+ }
@@ -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;