@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.
@@ -0,0 +1,382 @@
1
+ // `flow.record.*`: what a person (or an agent through the bridge) did on the device, as steps a flow can replay.
2
+ //
3
+ // Recorder v2 (plan §9.2) records everything a flow can say, not only taps:
4
+ // tap the target the finger touched — its testId IS the id a flow taps — from a capture-phase listener on every
5
+ // registered target (test builds only). One tap across nested targets records the INNERMOST.
6
+ // type a field whose value changed between the tap that focused it and the next step (the adapter's `fieldValue`
7
+ // reads it — no keyboard hook); a secret field records a parameter placeholder, never the text
8
+ // setValue a picker / switch / date set through the bridge (`ui.setValue`)
9
+ // swipe, longPress, selectItem, popupButton, dismiss
10
+ // the bridge's gestures and actions (`ui.swipe`, `ui.longPress`, …) as their flow steps
11
+ // bridge any other command that changes something (stage, design, an app's own drive command) — a setup step
12
+ // and, from the app's own events, SUGGESTED CHECKS: an announcement right after a step («saved», a navigation) becomes an
13
+ // `expectEvent` the reviewer may keep — never added on its own.
14
+ //
15
+ // always on a ring of the last 500 steps; `flow.record.harvest` drains it as candidates split at 60 s of silence
16
+ // a session `flow.record.start` … `flow.record.stop`: up to 5,000 steps, then `truncated`
17
+ //
18
+ // A step is {kind, contractId, targetType, at, durationMs} plus what its kind needs (value, direction, command, args).
19
+ import { onAnswer } from './dispatch.ts';
20
+ import { onEmit } from './events.ts';
21
+ import { commandScope, registerCommand } from './registry.ts';
22
+
23
+ export type RecordedKind = 'tap' | 'type' | 'setValue' | 'swipe' | 'longPress' | 'selectItem' | 'popupButton' | 'dismiss' | 'bridge';
24
+
25
+ export interface RecordedStep {
26
+ readonly kind: RecordedKind;
27
+ /** The testId (the automationId a flow addresses); '' for a step with no target (dismiss, bridge, a screen swipe). */
28
+ readonly contractId: string;
29
+ /** The target's type (Button, TabItem, Entry, …); '' when there is none. */
30
+ readonly targetType: string;
31
+ readonly at: number;
32
+ readonly durationMs: number;
33
+ /** type / setValue: the value; a secret field records `{{secret:<id>}}` instead. */
34
+ readonly value?: string;
35
+ /** swipe: where the finger went. */
36
+ readonly direction?: string;
37
+ /** selectItem: the row (itemId / text); popupButton: the caption. */
38
+ readonly item?: Readonly<Record<string, string>>;
39
+ /** bridge: the command and its arguments. */
40
+ readonly command?: string;
41
+ readonly args?: Readonly<Record<string, unknown>>;
42
+ }
43
+
44
+ /** A check the recording suggests from an event the app raised right after a step. Never applied by itself. */
45
+ export interface SuggestedCheck {
46
+ /** Index of the step it follows (in the returned steps). */
47
+ readonly afterStep: number;
48
+ readonly step: { readonly action: 'expectEvent'; readonly kind: string; readonly match: Readonly<Record<string, unknown>>; readonly within: number };
49
+ /** In words: what the app said. */
50
+ readonly why: string;
51
+ }
52
+
53
+ /** What the recorder can ask the app (the Lynx adapter configures it from the testId registry). */
54
+ export interface RecorderPort {
55
+ /** The value a field shows now (null: no such target, or it has no value). */
56
+ readonly fieldValue?: (contractId: string) => string | null;
57
+ /** A field whose text must never be recorded (a password). */
58
+ readonly isSecret?: (contractId: string, targetType: string) => boolean;
59
+ }
60
+
61
+ export const MAX_STEPS = 5_000;
62
+ export const PASSIVE_CAPACITY = 500;
63
+ export const CANDIDATE_GAP_MS = 60_000;
64
+ /** Taps on nested targets arrive within one event dispatch: closer than this, they are the same tap. */
65
+ export const SAME_TAP_MS = 40;
66
+ /** An event this soon after a step is that step's consequence (a toast after Save). */
67
+ export const SUGGEST_WITHIN_MS = 3_000;
68
+ /** Targets whose value a person types or picks. */
69
+ export const FIELD_TYPES: ReadonlySet<string> = /* @__PURE__ */ new Set(['Entry', 'Editor', 'SearchBar', 'SearchEntry', 'TextField', 'Password']);
70
+
71
+ interface PendingField {
72
+ readonly contractId: string;
73
+ readonly targetType: string;
74
+ readonly before: string | null;
75
+ }
76
+
77
+ export class PilotRecorder {
78
+ private steps: RecordedStep[] = [];
79
+ private startedAt = 0;
80
+ private recording = false;
81
+ private truncated = false;
82
+ private passive: RecordedStep[] = [];
83
+ private passiveDropped = 0;
84
+ private pending: PendingField | null = null;
85
+ private suggestions: SuggestedCheck[] = [];
86
+ private port: RecorderPort = {};
87
+
88
+ private readonly now: () => number;
89
+
90
+ constructor(now: () => number = Date.now) {
91
+ this.now = now;
92
+ }
93
+
94
+ get isRecording(): boolean {
95
+ return this.recording;
96
+ }
97
+
98
+ /** The adapter's answers (field values, secret fields). */
99
+ configure(port: RecorderPort): void {
100
+ this.port = { ...this.port, ...port };
101
+ }
102
+
103
+ /** One target touched (the capture listener). Never throws. */
104
+ tap(contractId: string, targetType: string): void {
105
+ if (!contractId) return;
106
+ this.flushField();
107
+ this.push({ kind: 'tap', contractId, targetType, at: this.now(), durationMs: 0 });
108
+ if (FIELD_TYPES.has(targetType)) this.pending = { contractId, targetType, before: this.read(contractId) };
109
+ }
110
+
111
+ /** A person's own drag that started on a target (the capture listener): a `swipe` step. Never throws. */
112
+ swipe(contractId: string, direction: string): void {
113
+ if (!contractId) return;
114
+ this.flushField();
115
+ this.push({ kind: 'swipe', contractId, targetType: '', at: this.now(), durationMs: 0, direction });
116
+ }
117
+
118
+ /**
119
+ * A controller's ui.swipe (a real drag the capture listener heard a moment ago, maybe on an inner target): the
120
+ * controller's own step — its target and direction — replaces that one instead of following it.
121
+ */
122
+ swipeCommand(contractId: string, direction: string): void {
123
+ const step: RecordedStep = { kind: 'swipe', contractId, targetType: '', at: this.now(), durationMs: 0, direction };
124
+ const replace = (list: RecordedStep[]) => {
125
+ const last = list[list.length - 1];
126
+ return last?.kind === 'swipe' && step.at - last.at < 2_000 ? [...list.slice(0, -1), step] : null;
127
+ };
128
+ this.flushField();
129
+ const passive = replace(this.passive);
130
+ const steps = this.recording ? replace(this.steps) : null;
131
+ if (!passive && !steps) return this.push(step);
132
+ this.passive = passive ?? PilotRecorder.append(this.passive, step, PASSIVE_CAPACITY, () => this.passiveDropped++);
133
+ if (this.recording) this.steps = steps ?? PilotRecorder.append(this.steps, step, Number.POSITIVE_INFINITY, () => {});
134
+ }
135
+
136
+ /** A step that is not a tap (an adapter's gesture, or a bridge command). Never throws. */
137
+ record(step: Omit<RecordedStep, 'at' | 'durationMs'> & { readonly durationMs?: number }): void {
138
+ this.flushField();
139
+ this.push({ ...step, at: this.now(), durationMs: step.durationMs ?? 0 });
140
+ }
141
+
142
+ /** A field set through the bridge (ui.type / ui.setValue): its value, masked when secret, replaces a pending read. */
143
+ fieldSet(kind: 'type' | 'setValue', contractId: string, value: string): void {
144
+ this.pending = null;
145
+ this.push({ kind, contractId, targetType: '', at: this.now(), durationMs: 0, value: this.mask(contractId, '', value) });
146
+ }
147
+
148
+ /** An announcement the app raised (recordEvent filtered out telemetry): right after a step, a suggested check. */
149
+ suggest(kind: string, data: unknown): void {
150
+ if (!this.recording || this.steps.length === 0) return;
151
+ const last = this.steps.length - 1;
152
+ const step = this.steps[last]!;
153
+ // the recorder's own clock, the one its steps carry
154
+ if (this.now() - step.at > SUGGEST_WITHIN_MS) return;
155
+ if (this.suggestions.some((s) => s.afterStep === last && s.step.kind === kind)) return;
156
+ const match: Record<string, unknown> = {};
157
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
158
+ for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
159
+ if (typeof v === 'string' && v.length <= 120) match[k] = v;
160
+ else if (typeof v === 'number' || typeof v === 'boolean') match[k] = v;
161
+ }
162
+ }
163
+ const said = typeof (data as { text?: unknown } | null)?.text === 'string' ? `«${(data as { text: string }).text}»` : kind;
164
+ this.suggestions.push({ afterStep: last, step: { action: 'expectEvent', kind, match, within: 5_000 }, why: `the app said ${said} right after step ${last + 1}` });
165
+ }
166
+
167
+ start(): { recording: true; startedAt: number } {
168
+ this.steps = [];
169
+ this.suggestions = [];
170
+ this.pending = null;
171
+ this.truncated = false;
172
+ this.startedAt = this.now();
173
+ this.recording = true;
174
+ return { recording: true, startedAt: this.startedAt };
175
+ }
176
+
177
+ stop(): { recording: false; startedAt: number; stoppedAt: number; truncated: boolean; stepCount: number; steps: readonly RecordedStep[]; suggestions: readonly SuggestedCheck[] } {
178
+ this.flushField();
179
+ this.recording = false;
180
+ return { recording: false, startedAt: this.startedAt, stoppedAt: this.now(), truncated: this.truncated, stepCount: this.steps.length, steps: this.steps.slice(), suggestions: this.suggestions.slice() };
181
+ }
182
+
183
+ status(): { recording: boolean; startedAt: number; stepCount: number; truncated: boolean; suggestions: number } {
184
+ return { recording: this.recording, startedAt: this.startedAt, stepCount: this.steps.length, truncated: this.truncated, suggestions: this.suggestions.length };
185
+ }
186
+
187
+ /** Drains the always-on ring as candidates split at a minute of silence. */
188
+ harvest(): { candidates: Array<{ startedAt: number; endedAt: number; steps: readonly RecordedStep[] }>; count: number; buffer: { capacity: number; dropped: number } } {
189
+ this.flushField();
190
+ const steps = this.passive;
191
+ const dropped = this.passiveDropped;
192
+ this.passive = [];
193
+ this.passiveDropped = 0;
194
+ const candidates: Array<{ startedAt: number; endedAt: number; steps: readonly RecordedStep[] }> = [];
195
+ let first = 0;
196
+ for (let i = 1; i <= steps.length; i++) {
197
+ if (i < steps.length && steps[i]!.at - steps[i - 1]!.at <= CANDIDATE_GAP_MS) continue;
198
+ const part = steps.slice(first, i);
199
+ if (part.length > 0) candidates.push({ startedAt: part[0]!.at - part[0]!.durationMs, endedAt: part[part.length - 1]!.at, steps: part });
200
+ first = i;
201
+ }
202
+ return { candidates, count: steps.length, buffer: { capacity: PASSIVE_CAPACITY, dropped } };
203
+ }
204
+
205
+ /** The field tapped last: its new value becomes a `type` step before whatever comes next. */
206
+ private flushField(): void {
207
+ const p = this.pending;
208
+ if (!p) return;
209
+ this.pending = null;
210
+ const now = this.read(p.contractId);
211
+ if (now === null || now === p.before) return;
212
+ this.push({ kind: 'type', contractId: p.contractId, targetType: p.targetType, at: this.now(), durationMs: 0, value: this.mask(p.contractId, p.targetType, now) });
213
+ }
214
+
215
+ private read(contractId: string): string | null {
216
+ try {
217
+ return this.port.fieldValue?.(contractId) ?? null;
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ private mask(contractId: string, targetType: string, value: string): string {
224
+ let secret = targetType === 'Password' || /pass(word)?|secret|token|pin\b/i.test(contractId);
225
+ try {
226
+ secret = this.port.isSecret?.(contractId, targetType) ?? secret;
227
+ } catch {
228
+ // keep the name-based guess
229
+ }
230
+ return secret ? `{{secret:${contractId}}}` : value;
231
+ }
232
+
233
+ private push(step: RecordedStep): void {
234
+ this.passive = PilotRecorder.append(this.passive, step, PASSIVE_CAPACITY, () => this.passiveDropped++);
235
+ if (!this.recording) return;
236
+ if (this.steps.length >= MAX_STEPS && !PilotRecorder.sameTap(this.steps[this.steps.length - 1], step)) {
237
+ this.truncated = true;
238
+ this.recording = false;
239
+ return;
240
+ }
241
+ this.steps = PilotRecorder.append(this.steps, step, Number.POSITIVE_INFINITY, () => {});
242
+ }
243
+
244
+ private static sameTap(previous: RecordedStep | undefined, next: RecordedStep): boolean {
245
+ // the same touch heard by nested targets (outermost first): one step, the innermost target's — a tap, or a swipe
246
+ return (
247
+ previous !== undefined &&
248
+ previous.kind === next.kind &&
249
+ (next.kind === 'tap' || (next.kind === 'swipe' && previous.direction === next.direction && previous.targetType === '')) &&
250
+ next.at - previous.at < SAME_TAP_MS
251
+ );
252
+ }
253
+
254
+ /** Appends, replacing the previous step when it is the same tap on an outer target; the oldest goes past [cap]. */
255
+ private static append(list: RecordedStep[], step: RecordedStep, cap: number, onDrop: () => void): RecordedStep[] {
256
+ const previous = list[list.length - 1];
257
+ if (PilotRecorder.sameTap(previous, step)) return [...list.slice(0, -1), step];
258
+ const next = [...list, step];
259
+ if (next.length > cap) {
260
+ onDrop();
261
+ return next.slice(next.length - cap);
262
+ }
263
+ return next;
264
+ }
265
+ }
266
+
267
+ /** The app's recorder (test builds; @g9tm/app-pilot-lynx's testId tap listener feeds it). */
268
+ export const pilotRecorder = /* @__PURE__ */ new PilotRecorder();
269
+
270
+ /**
271
+ * A recorded step as a FlowSpec step (`s1`, `s2`, …): what a reviewer saves after reading it. With the whole recording
272
+ * (`steps.map(flowStepOf)` passes it), a step also keeps `gapMs` — the person's pause before it (≥ 300 ms), which a runner
273
+ * replays with `--timing recorded` (a debounce, an animation the person waited out).
274
+ */
275
+ export function flowStepOf(step: RecordedStep, index: number, all?: readonly RecordedStep[]): Record<string, unknown> {
276
+ const previous = index > 0 ? all?.[index - 1] : undefined;
277
+ const gap = previous ? Math.max(0, Math.round(step.at - previous.at - previous.durationMs)) : 0;
278
+ const out = flowStepBody(step, index);
279
+ return gap >= 300 ? { ...out, gapMs: gap } : out;
280
+ }
281
+
282
+ function flowStepBody(step: RecordedStep, index: number): Record<string, unknown> {
283
+ const id = `s${index + 1}`;
284
+ const target = step.contractId ? { target: { automationId: step.contractId } } : {};
285
+ switch (step.kind) {
286
+ case 'tap':
287
+ return { id, action: 'click', ...target };
288
+ case 'type':
289
+ return { id, action: 'type', ...target, value: step.value ?? '' };
290
+ case 'setValue':
291
+ return { id, action: 'setValue', ...target, value: step.value ?? '' };
292
+ case 'swipe':
293
+ return { id, action: 'swipe', ...target, direction: step.direction ?? '' };
294
+ case 'longPress':
295
+ return { id, action: 'longPress', ...target };
296
+ case 'selectItem':
297
+ return { id, action: 'selectItem', target: { automationId: step.contractId, ...(step.item ?? {}) } };
298
+ case 'popupButton':
299
+ return { id, action: 'popupButton', value: step.item?.text ?? '' };
300
+ case 'dismiss':
301
+ return { id, action: 'dismiss' };
302
+ default:
303
+ return { id, action: 'bridge', command: step.command ?? '', args: step.args ?? {} };
304
+ }
305
+ }
306
+
307
+ /**
308
+ * A command a controller sent, as it left the bridge: recorded when it changed something and a flow can say it.
309
+ * Outside the class on purpose: the command names are the QA bridge's, and a production bundle keeps the class (a
310
+ * minifier does not drop a class declaration) — only this function, used by registerRecorderCommands alone, goes.
311
+ */
312
+ export function recordCommand(recorder: PilotRecorder, command: string, args: Readonly<Record<string, unknown>>, ok: boolean, result: unknown): void {
313
+ // the recorder's own commands, leases and the bridge's health checks are never steps
314
+ if (!ok || command.startsWith('flow.record.') || command.startsWith('lease.') || command.startsWith('qa.')) return;
315
+ const id = typeof args.automationId === 'string' ? args.automationId : '';
316
+ switch (command) {
317
+ case 'ui.tap':
318
+ // a real touch was already recorded by the capture listener; a handler activation was not
319
+ if ((result as { via?: unknown } | null)?.via === 'handler') recorder.record({ kind: 'tap', contractId: id, targetType: '' });
320
+ return;
321
+ case 'ui.swipe':
322
+ recorder.swipeCommand(id, String(args.direction ?? ''));
323
+ return;
324
+ case 'ui.longPress':
325
+ recorder.record({ kind: 'longPress', contractId: id, targetType: '' });
326
+ return;
327
+ case 'ui.type':
328
+ case 'ui.setValue':
329
+ recorder.fieldSet(command === 'ui.type' ? 'type' : 'setValue', id, typeof args.value === 'string' ? args.value : '');
330
+ return;
331
+ case 'ui.selectItem': {
332
+ const item: Record<string, string> = {};
333
+ for (const k of ['itemId', 'idField', 'text']) if (typeof args[k] === 'string') item[k] = args[k] as string;
334
+ recorder.record({ kind: 'selectItem', contractId: id, targetType: '', item });
335
+ return;
336
+ }
337
+ case 'ui.tapPopupButton':
338
+ recorder.record({ kind: 'popupButton', contractId: '', targetType: '', item: { text: String(args.text ?? '') } });
339
+ return;
340
+ case 'ui.dismissOverlay':
341
+ recorder.record({ kind: 'dismiss', contractId: '', targetType: '' });
342
+ return;
343
+ default:
344
+ // another ui.* command is a read or a wait; anything else that is not observe is a setup step
345
+ if (command.startsWith('ui.')) return;
346
+ recorder.record({ kind: 'bridge', contractId: '', targetType: '', command, args: { ...args } });
347
+ }
348
+ }
349
+
350
+ /** Event kinds that are telemetry, never an announcement worth checking. */
351
+ const isTelemetry = (kind: string): boolean =>
352
+ ['log', 'log.line', 'net', 'net.body', 'net.shaped', 'trace', 'audit', 'store.change', 'perf.frame', 'oracle.violation', 'design.changed', 'clock', 'info', 'warn', 'error'].includes(kind);
353
+
354
+ /** An event the app raised, as the recorder sees it: an announcement may become a suggested check. */
355
+ export function recordEvent(recorder: PilotRecorder, kind: string, data: unknown): void {
356
+ if (!isTelemetry(kind)) recorder.suggest(kind, data);
357
+ }
358
+
359
+ /** `flow.record.start | stop | status | harvest` over [recorder], and the feeds: bridge answers and app events. */
360
+ export function registerRecorderCommands(recorder: PilotRecorder = pilotRecorder): () => void {
361
+ const offs = [
362
+ registerCommand('flow.record.start', () => recorder.start(), { scope: 'drive', description: 'Starts recording what a person (or an agent) does: taps, typing, gestures, bridge steps.' }),
363
+ registerCommand(
364
+ 'flow.record.stop',
365
+ () => {
366
+ const r = recorder.stop();
367
+ return { ...r, flowSteps: r.steps.map(flowStepOf) };
368
+ },
369
+ { scope: 'drive', description: 'Stops the recording: its steps, the same as FlowSpec steps, and the checks its events suggest.' },
370
+ ),
371
+ registerCommand('flow.record.status', () => recorder.status(), { scope: 'observe', description: 'Whether a recording runs, how long it is, how many checks it suggests.' }),
372
+ registerCommand('flow.record.harvest', () => recorder.harvest(), { scope: 'observe', description: 'Drains the always-on ring of recent steps as flow candidates.' }),
373
+ onAnswer((request, answer) => {
374
+ // a read changes nothing a replay must repeat
375
+ if (commandScope(request.command) === 'observe') return;
376
+ const args = (request.args ?? {}) as Readonly<Record<string, unknown>>;
377
+ recordCommand(recorder, request.command, args, answer.ok, answer.ok ? (answer as { result?: unknown }).result : null);
378
+ }),
379
+ onEmit((kind, data) => recordEvent(recorder, kind, data)),
380
+ ];
381
+ return () => offs.forEach((off) => off());
382
+ }
@@ -0,0 +1,98 @@
1
+ // The command registry. Any module of an app registers its commands:
2
+ //
3
+ // import { registerCommand, PilotError } from '@g9tm/app-pilot';
4
+ // registerCommand('map.status', async (args, ctx) => ({ present: true }), { scope: 'observe', description: '…' });
5
+ // throw new PilotError('No map is on screen.', { code: 'NOT_FOUND', hint: 'Open the map tab first.' });
6
+ //
7
+ // The manifest (`manifest()`) is what the native side announces as `pilot.capabilities` and what an MCP server turns
8
+ // into tools: name, scope, description (English, and Persian when given), whether it changes something, an example.
9
+ import { defaultScope, type PilotScope } from './scopes.ts';
10
+
11
+ export type PilotArgs = Readonly<Record<string, unknown>>;
12
+
13
+ export interface PilotContext {
14
+ /** The request's timeout (default 30 000, clamped 1 000–120 000). */
15
+ readonly timeoutMs: number;
16
+ /** The lease id the request carried (lease.* commands read it). */
17
+ readonly leaseId: string | null;
18
+ /** The key that signed the request (null: an unsigned request a migration window allowed). */
19
+ readonly kid: string | null;
20
+ /** That key's scopes. */
21
+ readonly scopes: readonly string[];
22
+ }
23
+
24
+ export type PilotHandler = (args: PilotArgs, ctx: PilotContext) => unknown | Promise<unknown>;
25
+
26
+ export interface CommandMeta {
27
+ readonly scope?: PilotScope;
28
+ readonly description?: string;
29
+ readonly descriptionFa?: string;
30
+ /** It changes the app or the world (false: a pure read). Default: scope !== 'observe'. */
31
+ readonly mutating?: boolean;
32
+ /** One correct call, for the manifest, the help and the error hints. */
33
+ readonly example?: Readonly<Record<string, unknown>>;
34
+ /** JSON Schema of the arguments (optional; MCP tools use it). */
35
+ readonly argsSchema?: Readonly<Record<string, unknown>>;
36
+ }
37
+
38
+ export interface ManifestEntry extends CommandMeta {
39
+ readonly name: string;
40
+ readonly scope: PilotScope;
41
+ readonly mutating: boolean;
42
+ }
43
+
44
+ interface Entry {
45
+ readonly handler: PilotHandler;
46
+ readonly meta: CommandMeta;
47
+ }
48
+
49
+ const entries = new Map<string, Entry>();
50
+ const listeners = new Set<() => void>();
51
+
52
+ export function registerCommand(name: string, handler: PilotHandler, meta: CommandMeta = {}): () => void {
53
+ const entry = { handler, meta };
54
+ entries.set(name, entry);
55
+ for (const l of Array.from(listeners)) l();
56
+ return () => {
57
+ if (entries.get(name) === entry) {
58
+ entries.delete(name);
59
+ for (const l of Array.from(listeners)) l();
60
+ }
61
+ };
62
+ }
63
+
64
+ export const commandHandler = (name: string): PilotHandler | undefined => entries.get(name)?.handler;
65
+ export const commandNames = (): string[] => [...entries.keys()].sort();
66
+
67
+ export function commandScope(name: string): PilotScope {
68
+ return entries.get(name)?.meta.scope ?? defaultScope(name);
69
+ }
70
+
71
+ /** A registered command that only reads (scope observe, not declared mutating). */
72
+ export function isPureRead(name: string): boolean {
73
+ const meta = entries.get(name)?.meta;
74
+ if (!meta) return false;
75
+ const scope = meta.scope ?? defaultScope(name);
76
+ return scope === 'observe' && (meta.mutating ?? false) === false;
77
+ }
78
+
79
+ export function manifest(): ManifestEntry[] {
80
+ return commandNames().map((name) => {
81
+ const meta = entries.get(name)!.meta;
82
+ const scope = meta.scope ?? defaultScope(name);
83
+ return { ...meta, name, scope, mutating: meta.mutating ?? scope !== 'observe' };
84
+ });
85
+ }
86
+
87
+ /** The transport re-announces the manifest to the native listener whenever it changes. */
88
+ export function onCommandsChanged(listener: () => void): () => void {
89
+ listeners.add(listener);
90
+ return () => {
91
+ listeners.delete(listener);
92
+ };
93
+ }
94
+
95
+ /** Tests: start from an empty registry. */
96
+ export function resetRegistryForTests(): void {
97
+ entries.clear();
98
+ }
package/src/scopes.ts ADDED
@@ -0,0 +1,30 @@
1
+ // What a key may do (pilot/protocol/PROTOCOL.md «Scopes»). A command declares its scope when it registers; a command
2
+ // registered without one gets the scope its NAME implies (defaultScope) — so an app's existing commands are classified
3
+ // the day the library arrives, and a declaration wins over the guess.
4
+
5
+ export type PilotScope = 'observe' | 'drive' | 'stage' | 'design' | 'write' | 'admin';
6
+ export const SCOPES: readonly PilotScope[] = ['observe', 'drive', 'stage', 'design', 'write', 'admin'];
7
+
8
+ export const scopeAllows = (scopes: readonly string[], scope: PilotScope): boolean => scopes.includes('*') || scopes.includes(scope);
9
+
10
+ const OBSERVE_LEAF =
11
+ /\.(ping|capabilities|selftest|status|state|snapshot|find|list|probe|probes|busy|log|diagnostics|pages|users|subscriptions|perf|rect|frameStats|memory|startupMarks|waitIdle|waitFor|screenshot|recent|flight|model|stores|storesDiff|tokens|query)$/;
12
+ const ADMIN = new Set(['env.overrides', 'admin.open', 'diagnostics.archive', 'app.eval', 'devtool.cdp', 'pilot.config.set']);
13
+ const WRITE = new Set(['report.send', 'tester.send', 'tester.flush', 'live.consent']);
14
+
15
+ export function defaultScope(name: string): PilotScope {
16
+ if (ADMIN.has(name) || name.startsWith('impersonation.') && !OBSERVE_LEAF.test(name)) return 'admin';
17
+ if (WRITE.has(name) || /\.report$/.test(name)) return 'write';
18
+ if (name.startsWith('design.')) return OBSERVE_LEAF.test(name) ? 'observe' : 'design';
19
+ if (OBSERVE_LEAF.test(name) || name.startsWith('perf.') || /^map\.(assert|wait)/.test(name) || name === 'app.state' || name === 'qa.selftest.page') return 'observe';
20
+ if (
21
+ /^(nfc|clock|net|state|stage|flags)\./.test(name) ||
22
+ /^dev\.(theme|language|initFailOnce|initHold|permissions|feature|server|reload)$/.test(name) ||
23
+ /^tester\./.test(name) ||
24
+ /\.offerCode$/.test(name) ||
25
+ ['barcode.inject', 'sync.run', 'sync.unsubscribeSite', 'update.check', 'attachments.remove'].includes(name)
26
+ ) {
27
+ return 'stage';
28
+ }
29
+ return 'drive';
30
+ }