@g9tm/app-pilot 0.0.0-stage → 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 ADDED
@@ -0,0 +1,16 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Iman Kari
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
7
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
8
+ persons to whom the Software is furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
11
+ Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
14
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
15
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@g9tm/app-pilot",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "G9AppPilot core: the command registry and manifest, scopes, the dispatch order, actionable errors, event batching, the busy tracker, the lease, the tap recorder and the black box (logs, requests, stores, database, failure traces) — for full, signed control of an app by agents and test runners. Platform-free.",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "source": "./src/index.ts",
11
+ "types": "./src/index.ts",
12
+ "default": "./src/index.ts"
13
+ },
14
+ "./idle": {
15
+ "source": "./src/idle.ts",
16
+ "types": "./src/idle.ts",
17
+ "default": "./src/idle.ts"
18
+ },
19
+ "./lease": {
20
+ "source": "./src/lease.ts",
21
+ "types": "./src/lease.ts",
22
+ "default": "./src/lease.ts"
23
+ },
24
+ "./recorder": {
25
+ "source": "./src/recorder.ts",
26
+ "types": "./src/recorder.ts",
27
+ "default": "./src/recorder.ts"
28
+ },
29
+ "./blackbox": {
30
+ "source": "./src/blackbox.ts",
31
+ "types": "./src/blackbox.ts",
32
+ "default": "./src/blackbox.ts"
33
+ },
34
+ "./stage": {
35
+ "source": "./src/stage.ts",
36
+ "types": "./src/stage.ts",
37
+ "default": "./src/stage.ts"
38
+ },
39
+ "./design": {
40
+ "source": "./src/design.ts",
41
+ "types": "./src/design.ts",
42
+ "default": "./src/design.ts"
43
+ },
44
+ "./oracles": {
45
+ "source": "./src/oracles.ts",
46
+ "types": "./src/oracles.ts",
47
+ "default": "./src/oracles.ts"
48
+ },
49
+ "./package.json": "./package.json"
50
+ },
51
+ "sideEffects": false,
52
+ "files": [
53
+ "src",
54
+ "README.md"
55
+ ],
56
+ "license": "MIT",
57
+ "engines": {
58
+ "node": ">=22.18"
59
+ }
60
+ }
package/src/args.ts ADDED
@@ -0,0 +1,26 @@
1
+ // Argument readers (strings only from JSON strings, integers only from numbers — the semantics existing flows rely on).
2
+ import { PilotError } from './errors.ts';
3
+ import type { PilotArgs } from './registry.ts';
4
+
5
+ export function requireString(args: PilotArgs, name: string): string {
6
+ const value = args[name];
7
+ if (typeof value !== 'string' || value.length === 0) {
8
+ throw new PilotError(`This command needs a "${name}" argument.`, { code: 'INVALID_ARGS', hint: `Pass "${name}" as a non-empty string.` });
9
+ }
10
+ return value;
11
+ }
12
+
13
+ export function optionalString(args: PilotArgs, name: string): string | null {
14
+ const value = args[name];
15
+ return typeof value === 'string' && value.length > 0 ? value : null;
16
+ }
17
+
18
+ export function optionalInt(args: PilotArgs, name: string, fallback: number): number {
19
+ const value = args[name];
20
+ return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : fallback;
21
+ }
22
+
23
+ export function optionalBool(args: PilotArgs, name: string, fallback: boolean): boolean {
24
+ const value = args[name];
25
+ return typeof value === 'boolean' ? value : fallback;
26
+ }
@@ -0,0 +1,554 @@
1
+ // The black box: what the app did while an agent drove it — the console, the network, the commands it answered, its
2
+ // stores and its database — kept in small rings on the device and read on demand, plus a TRACE for every failed
3
+ // command: the failure with what led to it (the last commands, logs, requests, the busy state and the app's own
4
+ // sections, the screen first), so the agent that sees «not found» also sees why without asking five more questions.
5
+ //
6
+ // installBlackbox({ console: true, fetch: true, autoTrace: true });
7
+ // registerStore('session', () => sessionStore.get(), { subscribe: sessionStore.subscribe }); // store.* / app.stores*
8
+ // registerDbQuery((sql, params) => db.all(sql, params)); // db.query (admin: rows can hold anything)
9
+ // registerTraceSection('screen', () => snapshotTree()); // part of every trace
10
+ // recordNet({ method, url, status, ms }); // an app whose HTTP is native reports it itself
11
+ //
12
+ // Secrets never leave: values under a key that looks like a secret (token, password, authorization, cookie, key…) are
13
+ // replaced, and a text loses its bearer tokens, JWTs and secret query values (pilot/AiGuides/03 «Black box»).
14
+ // Debug builds only — like every pilot module, the app installs it behind its production switch.
15
+ import { onAnswer, type PilotAnswer, type PilotRequest } from './dispatch.ts';
16
+ import { emit, eventChannelOn, setEventChannel } from './events.ts';
17
+ import { busySnapshot } from './idle.ts';
18
+ import { optionalBool, optionalInt, optionalString, requireString } from './args.ts';
19
+ import { PilotError } from './errors.ts';
20
+ import { type PilotArgs, registerCommand } from './registry.ts';
21
+
22
+ // ---- redaction -----------------------------------------------------------------------------------------------------
23
+
24
+ const SECRET_KEY = /pass(word)?|secret|token|authori[sz]ation|cookie|api[-_]?key|private|signature|^sig$|otp|^pin$|credential|session[-_]?id/i;
25
+ const REDACTED = '‹redacted›';
26
+
27
+ /** A text without bearer tokens, JWTs and secret query values. */
28
+ export function redactText(text: string, max = 2_000): string {
29
+ let s = text
30
+ .replace(/(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, `$1 ${REDACTED}`)
31
+ .replace(/eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, '‹jwt›')
32
+ .replace(/([?&;](?:[^=&\s]*(?:token|key|password|secret|sig|signature|code|otp)[^=&\s]*)=)[^&\s#"']+/gi, `$1${REDACTED}`);
33
+ if (s.length > max) s = `${s.slice(0, max)}… (${s.length - max} more characters)`;
34
+ return s;
35
+ }
36
+
37
+ /** A value safe to hand out: secrets replaced, long strings (over `maxString`) and lists cut, deep trees stopped. */
38
+ export function redactValue(value: unknown, depth = 6, maxString = 500, seen: WeakSet<object> = new WeakSet()): unknown {
39
+ if (value === null || value === undefined) return value ?? null;
40
+ if (typeof value === 'string') return redactText(value, maxString);
41
+ if (typeof value === 'number' || typeof value === 'boolean') return value;
42
+ if (typeof value === 'bigint') return String(value);
43
+ if (typeof value === 'function') return '‹function›';
44
+ if (typeof value !== 'object') return String(value);
45
+ if (seen.has(value)) return '‹cycle›';
46
+ if (depth <= 0) return Array.isArray(value) ? `‹${value.length} items›` : '‹…›';
47
+ seen.add(value);
48
+ if (value instanceof Date) return value.toISOString();
49
+ if (value instanceof Map) return redactValue(Object.fromEntries(value), depth, maxString, seen);
50
+ if (value instanceof Set) return redactValue([...value], depth, maxString, seen);
51
+ if (Array.isArray(value)) {
52
+ const out: unknown[] = value.slice(0, 50).map((v) => redactValue(v, depth - 1, maxString, seen));
53
+ if (value.length > 50) out.push(`‹${value.length - 50} more›`);
54
+ return out;
55
+ }
56
+ const out: Record<string, unknown> = {};
57
+ let n = 0;
58
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
59
+ if (++n > 100) {
60
+ out['…'] = `‹${Object.keys(value).length - 100} more keys›`;
61
+ break;
62
+ }
63
+ out[k] = SECRET_KEY.test(k) && v !== null && v !== undefined && v !== '' ? REDACTED : redactValue(v, depth - 1, maxString, seen);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ // ---- rings ---------------------------------------------------------------------------------------------------------
69
+
70
+ /** A fixed-size list numbered from 1: `since(n)` is everything after entry n, so a reader polls without repeats. */
71
+ export class Ring<T extends { readonly seq: number }> {
72
+ readonly capacity: number;
73
+ private items: T[] = [];
74
+ private next = 1;
75
+
76
+ constructor(capacity: number) {
77
+ this.capacity = capacity;
78
+ }
79
+
80
+ push(make: (seq: number) => T): T {
81
+ const item = make(this.next++);
82
+ this.items.push(item);
83
+ if (this.items.length > this.capacity) this.items.splice(0, this.items.length - this.capacity);
84
+ return item;
85
+ }
86
+
87
+ since(seq: number, limit: number, keep: (item: T) => boolean = () => true): T[] {
88
+ const out = this.items.filter((i) => i.seq > seq && keep(i));
89
+ return out.slice(Math.max(0, out.length - limit));
90
+ }
91
+
92
+ last(): number {
93
+ return this.next - 1;
94
+ }
95
+
96
+ clear(): void {
97
+ this.items = [];
98
+ }
99
+ }
100
+
101
+ export type LogLevel = 'debug' | 'log' | 'info' | 'warn' | 'error';
102
+ const LEVELS: readonly LogLevel[] = ['debug', 'log', 'info', 'warn', 'error'];
103
+
104
+ export interface LogEntry {
105
+ readonly seq: number;
106
+ readonly t: number;
107
+ readonly level: LogLevel;
108
+ readonly text: string;
109
+ }
110
+
111
+ export interface NetEntry {
112
+ readonly seq: number;
113
+ readonly t: number;
114
+ readonly method: string;
115
+ readonly url: string;
116
+ readonly status: number | null;
117
+ readonly ms: number;
118
+ readonly bytes?: number | null;
119
+ readonly error?: string | null;
120
+ }
121
+
122
+ export interface TrailEntry {
123
+ readonly seq: number;
124
+ readonly t: number;
125
+ readonly command: string;
126
+ readonly ok: boolean;
127
+ readonly ms: number;
128
+ readonly code?: string;
129
+ readonly error?: string;
130
+ }
131
+
132
+ export interface PilotTrace {
133
+ readonly id: string;
134
+ readonly at: string;
135
+ readonly reason: string;
136
+ readonly command: string | null;
137
+ readonly args: unknown;
138
+ readonly error: string | null;
139
+ readonly code: string | null;
140
+ readonly category: string | null;
141
+ readonly ms: number | null;
142
+ readonly busy: unknown;
143
+ readonly trail: readonly TrailEntry[];
144
+ readonly logs: readonly LogEntry[];
145
+ readonly net: readonly NetEntry[];
146
+ readonly sections: Readonly<Record<string, unknown>>;
147
+ }
148
+
149
+ const logs = new Ring<LogEntry>(400);
150
+ const net = new Ring<NetEntry>(200);
151
+ const trail = new Ring<TrailEntry>(100);
152
+ const traces: PilotTrace[] = [];
153
+ // Traces still being taken (a section takes up to its timeout): trace.list / trace.get wait for them, so an agent that
154
+ // asks right after a failure gets that failure, not «no trace yet» (found by the evaluation suite, 2026-11-04).
155
+ const inFlight = new Set<Promise<unknown>>();
156
+ const settled = async () => {
157
+ // No Promise.allSettled: an app may compile for ES2019 (Lynx does), so each call is made unable to reject instead.
158
+ const quiet = [...inFlight].map((p) => p.then(() => undefined, () => undefined));
159
+ if (quiet.length) await Promise.race([Promise.all(quiet), new Promise((r) => setTimeout(r, 4_000))]);
160
+ };
161
+ const TRACE_KEEP = 5;
162
+ let traceSeq = 0;
163
+
164
+ const stores = new Map<string, () => unknown>();
165
+ // Stores that say when they change: name → the change number of its last change (app.storesDiff {since}).
166
+ const storeChanges = new Map<string, number>();
167
+ let storeSeq = 0;
168
+ const sections = new Map<string, { readonly read: () => unknown; readonly timeoutMs: number }>();
169
+ let dbQuery: ((sql: string, params: readonly unknown[]) => unknown) | null = null;
170
+
171
+ const textOf = (args: readonly unknown[]): string =>
172
+ args
173
+ .map((a) => {
174
+ if (typeof a === 'string') return a;
175
+ if (a instanceof Error) return `${a.name}: ${a.message}`;
176
+ try {
177
+ return JSON.stringify(redactValue(a, 3));
178
+ } catch {
179
+ return String(a);
180
+ }
181
+ })
182
+ .join(' ');
183
+
184
+ const logListeners = new Set<(entry: LogEntry) => void>();
185
+
186
+ /** Every line the box records (the oracles' `log.error` listens here). Returns the remover. */
187
+ export function onLogRecorded(listener: (entry: LogEntry) => void): () => void {
188
+ logListeners.add(listener);
189
+ return () => logListeners.delete(listener);
190
+ }
191
+
192
+ /** One log line into the box (the console capture calls it; an app's own logger may too). */
193
+ export function recordLog(level: LogLevel, ...args: unknown[]): void {
194
+ const entry = logs.push((seq) => ({ seq, t: Date.now(), level, text: redactText(textOf(args)) }));
195
+ for (const l of logListeners) {
196
+ try {
197
+ l(entry);
198
+ } catch {
199
+ // a listener never breaks the log
200
+ }
201
+ }
202
+ if (level === 'warn' || level === 'error') emit('log', { level, text: entry.text.slice(0, 300) });
203
+ // Every line, on the event stream, while someone asked for it (log.stream {on: true}).
204
+ if (eventChannelOn('log.line')) emit('log.line', { seq: entry.seq, level, text: entry.text });
205
+ }
206
+
207
+ /** One request into the box (the fetch capture calls it; an app whose HTTP runs natively reports its own). */
208
+ export function recordNet(entry: Omit<NetEntry, 'seq' | 't'> & { readonly t?: number }): void {
209
+ const e = net.push((seq) => ({
210
+ seq,
211
+ t: entry.t ?? Date.now(),
212
+ method: entry.method.toUpperCase(),
213
+ url: redactText(entry.url, 400),
214
+ status: entry.status,
215
+ ms: Math.round(entry.ms),
216
+ ...(entry.bytes !== undefined ? { bytes: entry.bytes } : {}),
217
+ ...(entry.error ? { error: redactText(entry.error, 300) } : {}),
218
+ }));
219
+ emit('net', { method: e.method, url: e.url, status: e.status, ms: e.ms });
220
+ }
221
+
222
+ /**
223
+ * A store an agent may read (`store.get {name}`, `app.stores`): the getter is called on each read, its value redacted.
224
+ * With `subscribe` (a store's own subscribe) its changes are numbered for `app.storesDiff {since}` and, while
225
+ * `store.watch` is on, emitted as `store.change {name}` events.
226
+ */
227
+ export function registerStore(name: string, read: () => unknown, options: { readonly subscribe?: (listener: () => void) => () => void } = {}): () => void {
228
+ stores.set(name, read);
229
+ const unsubscribe = options.subscribe?.(() => {
230
+ storeChanges.set(name, ++storeSeq);
231
+ emit('store.change', { name, seq: storeSeq });
232
+ });
233
+ if (options.subscribe) storeChanges.set(name, storeChanges.get(name) ?? 0);
234
+ return () => {
235
+ unsubscribe?.();
236
+ if (stores.get(name) === read) {
237
+ stores.delete(name);
238
+ storeChanges.delete(name);
239
+ }
240
+ };
241
+ }
242
+
243
+ /** The app's database, read-only (`db.query`): one SELECT / WITH / PRAGMA / EXPLAIN statement at a time. */
244
+ export function registerDbQuery(run: (sql: string, params: readonly unknown[]) => unknown): () => void {
245
+ dbQuery = run;
246
+ return () => {
247
+ if (dbQuery === run) dbQuery = null;
248
+ };
249
+ }
250
+
251
+ /** Part of every trace (the screen, the route, the sync state…). A section that throws or is late says so. */
252
+ export function registerTraceSection(name: string, read: () => unknown, options: { readonly timeoutMs?: number } = {}): () => void {
253
+ const entry = { read, timeoutMs: options.timeoutMs ?? 1_500 };
254
+ sections.set(name, entry);
255
+ return () => {
256
+ if (sections.get(name) === entry) sections.delete(name);
257
+ };
258
+ }
259
+
260
+ // ---- captures ------------------------------------------------------------------------------------------------------
261
+
262
+ function installConsoleCapture(): () => void {
263
+ const target = (globalThis as unknown as { console?: Record<string, (...a: unknown[]) => void> }).console;
264
+ if (!target) return () => undefined;
265
+ const originals = new Map<LogLevel, (...a: unknown[]) => void>();
266
+ let inside = false;
267
+ for (const level of LEVELS) {
268
+ const original = target[level];
269
+ if (typeof original !== 'function') continue;
270
+ originals.set(level, original);
271
+ target[level] = (...args: unknown[]) => {
272
+ if (!inside) {
273
+ inside = true;
274
+ try {
275
+ recordLog(level, ...args);
276
+ } catch {
277
+ // the box never breaks the console
278
+ } finally {
279
+ inside = false;
280
+ }
281
+ }
282
+ original.apply(target, args);
283
+ };
284
+ }
285
+ return () => {
286
+ for (const [level, original] of originals) target[level] = original;
287
+ };
288
+ }
289
+
290
+ type FetchFn = (input: unknown, init?: { readonly method?: string }) => Promise<{ readonly status: number; readonly headers?: { get(name: string): string | null } }>;
291
+
292
+ function installFetchCapture(): () => void {
293
+ const holder = globalThis as unknown as { fetch?: FetchFn };
294
+ const original = holder.fetch;
295
+ if (typeof original !== 'function') return () => undefined;
296
+ const wrapped: FetchFn = async (input, init) => {
297
+ const started = Date.now();
298
+ const url = typeof input === 'string' ? input : String((input as { url?: string })?.url ?? input);
299
+ const method = init?.method ?? (input as { method?: string })?.method ?? 'GET';
300
+ try {
301
+ const response = await original(input, init);
302
+ const length = Number(response.headers?.get('content-length') ?? NaN);
303
+ recordNet({ method, url, status: response.status, ms: Date.now() - started, bytes: Number.isFinite(length) ? length : null });
304
+ return response;
305
+ } catch (error) {
306
+ recordNet({ method, url, status: null, ms: Date.now() - started, error: error instanceof Error ? error.message : String(error) });
307
+ throw error;
308
+ }
309
+ };
310
+ holder.fetch = wrapped;
311
+ return () => {
312
+ if (holder.fetch === wrapped) holder.fetch = original;
313
+ };
314
+ }
315
+
316
+ // ---- traces --------------------------------------------------------------------------------------------------------
317
+
318
+ const OWN = /^(trace|log|net|store|db)\.|^app\.stores|^pilot\.|^qa\.(ping|capabilities)$/;
319
+ const NOT_A_FAILURE = new Set(['SCOPE', 'LEASE', 'SIG_MISSING', 'UNKNOWN_KID', 'SIG_INVALID', 'REPLAY', 'CLOCK_SKEW']);
320
+ // A key, a lease or a signature refused: the caller's matter, not the app's — no trace.
321
+ const QUIET_CATEGORIES = new Set(['scope', 'lease', 'auth']);
322
+
323
+ async function withTimeout(read: () => unknown, ms: number): Promise<unknown> {
324
+ let timer: ReturnType<typeof setTimeout> | undefined;
325
+ try {
326
+ return await Promise.race([
327
+ Promise.resolve().then(read),
328
+ new Promise((resolve) => {
329
+ timer = setTimeout(() => resolve({ unavailable: `no answer within ${ms} ms` }), ms);
330
+ }),
331
+ ]);
332
+ } catch (error) {
333
+ return { unavailable: error instanceof Error ? error.message : String(error) };
334
+ } finally {
335
+ if (timer) clearTimeout(timer);
336
+ }
337
+ }
338
+
339
+ /** Takes a trace now: what the box holds, the busy state and every registered section. */
340
+ export async function captureTrace(reason: string, failed: { readonly request?: PilotRequest; readonly answer?: PilotAnswer; readonly ms?: number } = {}): Promise<PilotTrace> {
341
+ const answer = failed.answer && !failed.answer.ok ? failed.answer : null;
342
+ // What led to it is taken NOW, before the sections are awaited: a command answered meanwhile is not part of it.
343
+ const id = `T-${++traceSeq}`;
344
+ const before = { busy: redactValue(busySnapshot(), 4), trail: trail.since(0, 30), logs: logs.since(0, 80), net: net.since(0, 40) };
345
+ const read: Record<string, unknown> = {};
346
+ // A section is the app's own account (the screen tree is a long text, a small screenshot a longer one): up to
347
+ // 200 000 characters a string, not 500.
348
+ for (const [name, s] of sections) read[name] = redactValue(await withTimeout(s.read, s.timeoutMs), 8, 200_000);
349
+ const trace: PilotTrace = {
350
+ id,
351
+ at: new Date().toISOString(),
352
+ reason,
353
+ command: failed.request?.command ?? null,
354
+ args: failed.request ? redactValue(failed.request.args ?? {}, 4) : null,
355
+ error: answer ? redactText(answer.error, 1_000) : null,
356
+ code: answer?.code ?? null,
357
+ category: answer?.category ?? null,
358
+ ms: failed.ms ?? null,
359
+ ...before,
360
+ sections: read,
361
+ };
362
+ traces.push(trace);
363
+ if (traces.length > TRACE_KEEP) traces.splice(0, traces.length - TRACE_KEEP);
364
+ emit('trace', { id: trace.id, command: trace.command, error: trace.error?.slice(0, 200) ?? null });
365
+ return trace;
366
+ }
367
+
368
+ // ---- install -------------------------------------------------------------------------------------------------------
369
+
370
+ export interface BlackboxOptions {
371
+ /** Capture console.* (default true). */
372
+ readonly console?: boolean;
373
+ /** Capture the JS `fetch` (default true; native HTTP reports through recordNet). */
374
+ readonly fetch?: boolean;
375
+ /** A trace for every failed command (default true). */
376
+ readonly autoTrace?: boolean;
377
+ }
378
+
379
+ const sinceArg = (args: PilotArgs) => Math.max(0, optionalInt(args, 'since', 0));
380
+
381
+ /** Installs the captures and the commands log.tail, net.log, store.list/get, db.query, trace.list/get/capture. */
382
+ export function installBlackbox(options: BlackboxOptions = {}): () => void {
383
+ const undo: (() => void)[] = [];
384
+ if (options.console !== false) undo.push(installConsoleCapture());
385
+ if (options.fetch !== false) undo.push(installFetchCapture());
386
+ undo.push(
387
+ onAnswer((request, answer, ms) => {
388
+ if (OWN.test(request.command)) return;
389
+ trail.push((seq) => ({
390
+ seq,
391
+ t: Date.now(),
392
+ command: request.command,
393
+ ok: answer.ok,
394
+ ms,
395
+ ...(answer.ok ? {} : { code: answer.code, error: redactText(answer.error, 200) }),
396
+ }));
397
+ if (options.autoTrace !== false && !answer.ok && !NOT_A_FAILURE.has(answer.code) && !QUIET_CATEGORIES.has(answer.category)) {
398
+ const taking = captureTrace('failed command', { request, answer, ms }).catch(() => undefined);
399
+ inFlight.add(taking);
400
+ void taking.finally(() => inFlight.delete(taking));
401
+ }
402
+ }),
403
+ );
404
+
405
+ undo.push(
406
+ registerCommand(
407
+ 'log.tail',
408
+ (args) => {
409
+ const level = optionalString(args, 'level') as LogLevel | null;
410
+ if (level && !LEVELS.includes(level)) throw new PilotError(`Unknown level "${level}".`, { code: 'BAD_ARGS', hint: `One of ${LEVELS.join(', ')}.` });
411
+ const min = level ? LEVELS.indexOf(level) : 0;
412
+ return { entries: logs.since(sinceArg(args), Math.min(500, optionalInt(args, 'limit', 100)), (e) => LEVELS.indexOf(e.level) >= min), last: logs.last() };
413
+ },
414
+ { scope: 'observe', description: 'The app’s console since entry `since` (secrets removed); `level` warn shows warnings and errors only. Poll with the returned `last`.', example: { since: 0, level: 'warn' } },
415
+ ),
416
+ registerCommand(
417
+ 'net.log',
418
+ (args) => {
419
+ const failedOnly = optionalBool(args, 'failedOnly', false);
420
+ const filter = optionalString(args, 'filter')?.toLowerCase() ?? null;
421
+ return {
422
+ entries: net.since(sinceArg(args), Math.min(200, optionalInt(args, 'limit', 50)), (e) => (!failedOnly || e.status === null || e.status >= 400) && (!filter || e.url.toLowerCase().includes(filter))),
423
+ last: net.last(),
424
+ };
425
+ },
426
+ { scope: 'observe', description: 'The app’s requests since entry `since`: method, address (secret values removed), status, time, size; `failedOnly` keeps errors and 4xx/5xx, `filter` a part of the address.', example: { failedOnly: true, filter: '/api/' } },
427
+ ),
428
+ registerCommand('store.list', () => ({ stores: [...stores.keys()].sort() }), { scope: 'observe', description: 'The stores the app lets an agent read.' }),
429
+ registerCommand(
430
+ 'store.get',
431
+ (args) => {
432
+ const name = requireString(args, 'name');
433
+ const read = stores.get(name);
434
+ if (!read) throw new PilotError(`No store named "${name}" (not found).`, { code: 'NOT_FOUND', hint: `Known: ${[...stores.keys()].sort().join(', ') || 'none'}.`, fixExample: { command: 'store.list' } });
435
+ let value = read();
436
+ const path = optionalString(args, 'path');
437
+ for (const part of path ? path.split('.') : []) value = value === null || value === undefined ? undefined : (value as Record<string, unknown>)[part];
438
+ return { name, path, value: redactValue(value, optionalInt(args, 'depth', 6)) };
439
+ },
440
+ { scope: 'observe', description: 'One store’s value now (secrets removed), or a dotted `path` inside it.', example: { name: 'session', path: 'user' } },
441
+ ),
442
+ registerCommand(
443
+ 'app.stores',
444
+ (args) => {
445
+ const only = optionalString(args, 'name');
446
+ const names = only ? [only] : [...stores.keys()].sort();
447
+ const out: Record<string, unknown> = {};
448
+ for (const name of names) {
449
+ const read = stores.get(name);
450
+ if (!read) throw new PilotError(`No store named "${name}" (not found).`, { code: 'NOT_FOUND', hint: `Known: ${[...stores.keys()].sort().join(', ') || 'none'}.` });
451
+ try {
452
+ out[name] = redactValue(read(), optionalInt(args, 'depth', 4));
453
+ } catch (error) {
454
+ out[name] = { unavailable: error instanceof Error ? error.message : String(error) };
455
+ }
456
+ }
457
+ return { stores: out, last: storeSeq };
458
+ },
459
+ { scope: 'observe', description: 'Every store’s value now (or one, `name`), secrets removed; `last` is the change number for app.storesDiff.' },
460
+ ),
461
+ registerCommand(
462
+ 'app.storesDiff',
463
+ (args) => {
464
+ const since = sinceArg(args);
465
+ const changed: Record<string, unknown> = {};
466
+ for (const [name, seq] of storeChanges) if (seq > since) changed[name] = redactValue(stores.get(name)?.(), optionalInt(args, 'depth', 4));
467
+ return { changed, last: storeSeq, untracked: [...stores.keys()].filter((n) => !storeChanges.has(n)).sort() };
468
+ },
469
+ { scope: 'observe', description: 'The stores that changed after change number `since`, with their values now; `untracked` lists stores that do not say when they change.', example: { since: 0 } },
470
+ ),
471
+ registerCommand(
472
+ 'store.watch',
473
+ (args) => {
474
+ const on = optionalBool(args, 'on', true);
475
+ setEventChannel('store.change', on);
476
+ return { watching: on };
477
+ },
478
+ { scope: 'observe', mutating: false, description: 'store.change events on the stream while on (heavy; off by default).' },
479
+ ),
480
+ registerCommand(
481
+ 'log.stream',
482
+ (args) => {
483
+ const on = optionalBool(args, 'on', true);
484
+ setEventChannel('log.line', on);
485
+ return { streaming: on, last: logs.last() };
486
+ },
487
+ { scope: 'observe', mutating: false, description: 'Every log line as a log.line event on the stream while on (GET /pilot/events); warnings and errors are always there as `log`.' },
488
+ ),
489
+ registerCommand(
490
+ 'db.query',
491
+ async (args) => {
492
+ if (!dbQuery) throw new PilotError('This app registered no database (not found).', { code: 'NOT_FOUND', hint: 'The app calls registerDbQuery at start.' });
493
+ const sql = requireString(args, 'sql').trim().replace(/;\s*$/, '');
494
+ if (!/^(select|with|pragma\s+(table_info|table_list|index_list|index_info|foreign_key_list|user_version)\b|explain)\b/i.test(sql) || sql.includes(';')) {
495
+ throw new PilotError('db.query reads only: one SELECT, WITH, EXPLAIN or a schema PRAGMA.', { code: 'BAD_ARGS', category: 'automation', fixExample: { command: 'db.query', args: { sql: 'SELECT name FROM sqlite_master WHERE type = \'table\'' } } });
496
+ }
497
+ if (/^with\b/i.test(sql) && /\b(insert|update|delete|replace|create|drop|alter|attach|vacuum)\b/i.test(sql)) {
498
+ throw new PilotError('db.query reads only: a WITH that writes is refused.', { code: 'BAD_ARGS' });
499
+ }
500
+ const limit = Math.min(1_000, optionalInt(args, 'limit', 200));
501
+ const params = Array.isArray(args.params) ? args.params : [];
502
+ const timeoutMs = Math.min(20_000, Math.max(500, optionalInt(args, 'timeoutMs', 5_000)));
503
+ let timer: ReturnType<typeof setTimeout> | undefined;
504
+ const rows = await Promise.race([
505
+ Promise.resolve(dbQuery(sql, params)),
506
+ new Promise<never>((_, reject) => {
507
+ timer = setTimeout(() => reject(new PilotError(`db.query took longer than ${timeoutMs} ms (timed out); a narrower WHERE or a LIMIT helps.`, { code: 'TIMEOUT', category: 'automation', retryable: true })), timeoutMs);
508
+ }),
509
+ ]).finally(() => timer && clearTimeout(timer));
510
+ const list = Array.isArray(rows) ? rows : [];
511
+ return { rows: redactValue(list.slice(0, limit), 4), count: list.length, truncated: list.length > limit };
512
+ },
513
+ { scope: 'admin', description: 'A read-only SQL query on the app’s database (rows can hold anything the app stores, so an admin key only).', example: { sql: 'SELECT count(*) AS n FROM sqlite_master' } },
514
+ ),
515
+ registerCommand('trace.list', async () => (await settled(), { traces: traces.map((t) => ({ id: t.id, at: t.at, reason: t.reason, command: t.command, error: t.error?.slice(0, 200) ?? null })) }), {
516
+ scope: 'observe',
517
+ description: 'The last failure traces (newest last).',
518
+ }),
519
+ registerCommand(
520
+ 'trace.get',
521
+ async (args) => {
522
+ await settled();
523
+ const id = optionalString(args, 'id');
524
+ const trace = id ? traces.find((t) => t.id === id) : traces[traces.length - 1];
525
+ if (!trace) throw new PilotError(id ? `No trace ${id} (not found) — only the last ${TRACE_KEEP} are kept.` : 'No trace yet (not found): nothing has failed.', { code: 'NOT_FOUND', fixExample: { command: 'trace.list' } });
526
+ return trace;
527
+ },
528
+ { scope: 'observe', description: 'One failure trace (default the newest): the failed command, what led to it (commands, logs, requests), the busy state and the app’s sections — the screen first.', example: {} },
529
+ ),
530
+ registerCommand('trace.capture', async (args) => captureTrace(optionalString(args, 'reason') ?? 'asked'), {
531
+ scope: 'observe',
532
+ mutating: false,
533
+ description: 'A trace now, without a failure (to compare a good state with a bad one).',
534
+ }),
535
+ );
536
+ return () => {
537
+ for (const u of undo.reverse()) u();
538
+ };
539
+ }
540
+
541
+ /** For tests: empty rings, no traces, nothing registered. */
542
+ export function resetBlackboxForTests(): void {
543
+ logs.clear();
544
+ net.clear();
545
+ trail.clear();
546
+ traces.length = 0;
547
+ inFlight.clear();
548
+ traceSeq = 0;
549
+ stores.clear();
550
+ storeChanges.clear();
551
+ storeSeq = 0;
552
+ sections.clear();
553
+ dbQuery = null;
554
+ }