imprint-mcp 0.7.0 → 0.7.2

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.
Files changed (58) hide show
  1. package/README.md +66 -2
  2. package/package.json +1 -1
  3. package/prompts/auth-compile-agent.md +173 -22
  4. package/prompts/compile-agent.md +45 -8
  5. package/prompts/component-recovery.md +40 -0
  6. package/prompts/master-teach-decision.md +28 -3
  7. package/prompts/master-teach-focused-planner.md +18 -5
  8. package/src/cli.ts +26 -18
  9. package/src/imprint/agent-tool.ts +15 -0
  10. package/src/imprint/agent.ts +2 -11
  11. package/src/imprint/audit.ts +35 -13
  12. package/src/imprint/auth-browser-discovery.ts +276 -0
  13. package/src/imprint/auth-compile-agent.ts +513 -224
  14. package/src/imprint/auth-compile-tools.ts +213 -25
  15. package/src/imprint/auth-discovery-evidence.ts +81 -0
  16. package/src/imprint/auth-plan.ts +28 -0
  17. package/src/imprint/auth-verifier.ts +234 -20
  18. package/src/imprint/authentication-interaction.ts +137 -0
  19. package/src/imprint/backend-ladder.ts +271 -43
  20. package/src/imprint/build-plan.ts +2 -22
  21. package/src/imprint/cdp-browser-fetch.ts +242 -60
  22. package/src/imprint/chromium.ts +93 -29
  23. package/src/imprint/claude-cli-compile.ts +2 -0
  24. package/src/imprint/codex-cli-compile.ts +99 -46
  25. package/src/imprint/compile-agent-types.ts +9 -5
  26. package/src/imprint/compile-agent.ts +33 -0
  27. package/src/imprint/compile-tool-executor.ts +783 -0
  28. package/src/imprint/compile-tools.ts +42 -9
  29. package/src/imprint/compile-verification.ts +2 -0
  30. package/src/imprint/compile.ts +3 -0
  31. package/src/imprint/compiler-log.ts +16 -0
  32. package/src/imprint/component-recovery.ts +120 -0
  33. package/src/imprint/cookie-jar.ts +117 -7
  34. package/src/imprint/credential-extract.ts +9 -2
  35. package/src/imprint/credential-lock.ts +147 -0
  36. package/src/imprint/credential-observation.ts +261 -0
  37. package/src/imprint/credential-store.ts +284 -44
  38. package/src/imprint/live-component-recovery.ts +73 -0
  39. package/src/imprint/llm.ts +4 -3
  40. package/src/imprint/log.ts +16 -1
  41. package/src/imprint/login.ts +9 -0
  42. package/src/imprint/master-teach-agent-contracts.ts +40 -3
  43. package/src/imprint/master-teach-agents.ts +229 -14
  44. package/src/imprint/master-teach-checks.ts +8 -1
  45. package/src/imprint/master-teach-controller.ts +681 -138
  46. package/src/imprint/master-teach-prompt-projections.ts +2 -0
  47. package/src/imprint/mcp-cdp-sessions.ts +7 -2
  48. package/src/imprint/mcp-compile-server.ts +62 -850
  49. package/src/imprint/mcp-server.ts +667 -91
  50. package/src/imprint/mcp-session-recovery.ts +167 -0
  51. package/src/imprint/native-teach-agents.ts +232 -108
  52. package/src/imprint/runtime.ts +416 -47
  53. package/src/imprint/stealth-chromium.ts +67 -7
  54. package/src/imprint/teach-browser-resources.ts +88 -0
  55. package/src/imprint/teach-stage-progress.ts +38 -0
  56. package/src/imprint/terminal-user-prompt.ts +42 -0
  57. package/src/imprint/types.ts +51 -18
  58. package/src/imprint/user-interaction.ts +836 -0
package/src/cli.ts CHANGED
@@ -6,7 +6,7 @@ import { basename, dirname, resolve } from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { IS_COMPILED_BINARY } from './imprint/is-compiled.ts';
8
8
  import type { ProviderName } from './imprint/llm.ts';
9
- import { isDebug } from './imprint/log.ts';
9
+ import { createLog, isDebug } from './imprint/log.ts';
10
10
  import type { FreshTeachTerminalResult } from './imprint/master-teach-controller.ts';
11
11
  import { shutdownTracing, traced } from './imprint/tracing.ts';
12
12
  import { VERSION } from './imprint/version.ts';
@@ -142,7 +142,7 @@ export const VERB_HELP: Record<string, VerbHelp> = {
142
142
  {
143
143
  name: '--from-session <path>',
144
144
  description:
145
- 'Use one exact recording. Repeat the flag to explicitly combine selected recordings. Without it, the newest raw recording is used.',
145
+ 'Use one exact recording. Repeat the flag to explicitly combine selected recordings. Existing login references use stored credentials. Without it, the newest raw recording is used.',
146
146
  },
147
147
  {
148
148
  name: '--from-candidates <run-id>',
@@ -157,7 +157,8 @@ export const VERB_HELP: Record<string, VerbHelp> = {
157
157
  { name: '--persist-profile', description: 'Reuse a stable Chrome profile for this site.' },
158
158
  {
159
159
  name: '--no-interactive',
160
- description: 'Run without prompts. Every tool in the master plan is still built.',
160
+ description:
161
+ 'Disable terminal questions, including MFA/OTP input. Missing user input is reported as unavailable; the complete tool scope remains planned.',
161
162
  },
162
163
  {
163
164
  name: '--agent codex',
@@ -172,7 +173,7 @@ export const VERB_HELP: Record<string, VerbHelp> = {
172
173
  {
173
174
  name: '--model <name>',
174
175
  description:
175
- 'Override the teaching-agent model (e.g. claude-sonnet-4-6). Otherwise the provider default is used.',
176
+ 'Override the teaching-agent model (e.g. claude-sonnet-4-6). Codex defaults to gpt-6.1-sol; otherwise the provider default is used.',
176
177
  },
177
178
  {
178
179
  name: '--timeout <duration>',
@@ -458,7 +459,7 @@ export const VERB_HELP: Record<string, VerbHelp> = {
458
459
  },
459
460
  audit: {
460
461
  summary:
461
- "Drive a headless agent against a site's MCP tools, exercise each one, and compute a deterministic accuracy score. Verdicts come from the agent; the score is computed by imprint.",
462
+ "Drive a headless agent against a site's MCP tools, exercise each one, and compute a deterministic accuracy score. Verdicts come from the agent; the score is computed by imprint. Browser tools inherit explicitly configured Chrome, headed-mode, proxy, display, and session-cache settings.",
462
463
  usage: [
463
464
  'imprint audit <site> [--strict] [--min-score <n>] [--out <path>] [--model <name>] [--timeout <duration>] [--json]',
464
465
  ],
@@ -851,22 +852,29 @@ async function main(argv: string[]): Promise<number> {
851
852
  // piped, backgrounded) get visibility instead of silence.
852
853
  let lastActivity = '';
853
854
  const compileStart = Date.now();
855
+ const compileLog = createLog('compile');
854
856
  const onDeadlineReached = process.stdin.isTTY
855
857
  ? async (): Promise<number | null> => {
856
- const { createInterface } = await import('node:readline');
858
+ const { requestAgentInteraction } = await import('./imprint/user-interaction.ts');
857
859
  const elapsed = Math.round((Date.now() - compileStart) / 60000);
858
- const rl = createInterface({ input: process.stdin, output: process.stderr });
859
- const answer = await new Promise<string>((resolve) => {
860
- rl.question(
861
- `[imprint compile] Timeout reached after ${elapsed} minutes. Give it 10 more minutes? [Y/n] `,
862
- resolve,
863
- );
860
+ const answer = await requestAgentInteraction({
861
+ action: 'prompt_user',
862
+ prompt: {
863
+ message: `Timeout reached after ${elapsed} minutes. Give the compiler 10 more minutes?`,
864
+ options: ['Continue for 10 minutes', 'Stop'],
865
+ sensitive: false,
866
+ },
864
867
  });
865
- rl.close();
866
- return answer.trim().toLowerCase().startsWith('n') ? null : 10 * 60 * 1000;
868
+ return answer.status === 'answered' &&
869
+ 'value' in answer &&
870
+ answer.value === 'Continue for 10 minutes'
871
+ ? 10 * 60 * 1000
872
+ : null;
867
873
  }
868
874
  : undefined;
875
+ const { terminalUserPrompt } = await import('./imprint/terminal-user-prompt.ts');
869
876
  const result = await generate({
877
+ onPrompt: terminalUserPrompt,
870
878
  sessionPath,
871
879
  outPath: values.out,
872
880
  maxDurationMs,
@@ -878,9 +886,7 @@ async function main(argv: string[]): Promise<number> {
878
886
  if (activity === lastActivity) return;
879
887
  lastActivity = activity;
880
888
  const retry = p.verificationCycle > 1 ? ` (retry ${p.verificationCycle - 1})` : '';
881
- process.stderr.write(
882
- `[imprint compile] ${formatElapsed(p.elapsedMs)} — ${activity}${retry}\n`,
883
- );
889
+ compileLog(`${formatElapsed(p.elapsedMs)} — ${activity}${retry}`);
884
890
  },
885
891
  });
886
892
  console.log('');
@@ -1564,6 +1570,7 @@ async function main(argv: string[]): Promise<number> {
1564
1570
 
1565
1571
  try {
1566
1572
  const { teach } = await import('./imprint/teach.ts');
1573
+ const { terminalUserPrompt } = await import('./imprint/terminal-user-prompt.ts');
1567
1574
  const result = await traced(
1568
1575
  'cli.teach',
1569
1576
  'AGENT',
@@ -1591,13 +1598,14 @@ async function main(argv: string[]): Promise<number> {
1591
1598
  persistProfile: values['persist-profile'],
1592
1599
  signal: ctrl.signal,
1593
1600
  noInteractive: values['no-interactive'],
1601
+ onPrompt: values['no-interactive'] ? undefined : terminalUserPrompt,
1594
1602
  provider: values.provider as ProviderName | undefined,
1595
1603
  model: values.model,
1596
1604
  maxDurationMs: teachTimeoutMs,
1597
1605
  unbounded: values.unbounded,
1598
1606
  keepTest: values['keep-test'] || process.env.IMPRINT_KEEP_TEST === '1',
1599
1607
  agent: values.agent === 'codex' ? 'codex' : undefined,
1600
- onProgress: (message) => console.error(`[imprint teach] ${message}`),
1608
+ onProgress: createLog('teach'),
1601
1609
  }),
1602
1610
  );
1603
1611
  const summary = formatFreshTeachSummary(result);
@@ -0,0 +1,15 @@
1
+ /** Provider-neutral execution tool contract. */
2
+ export interface AgentTool {
3
+ name: string;
4
+ description: string;
5
+ input_schema: {
6
+ type: 'object';
7
+ properties?: Record<string, unknown>;
8
+ required?: string[];
9
+ [k: string]: unknown;
10
+ };
11
+ handler: (
12
+ input: unknown,
13
+ request?: { signal?: AbortSignal },
14
+ ) => Promise<{ result: string; isError?: boolean }>;
15
+ }
@@ -20,17 +20,8 @@ import {
20
20
  } from './provider-retry.ts';
21
21
  import { setSpanAttributes, traceToolIoEnabled, traced } from './tracing.ts';
22
22
 
23
- export interface AgentTool {
24
- name: string;
25
- description: string;
26
- input_schema: {
27
- type: 'object';
28
- properties?: Record<string, unknown>;
29
- required?: string[];
30
- [k: string]: unknown;
31
- };
32
- handler: (input: unknown) => Promise<{ result: string; isError?: boolean }>;
33
- }
23
+ import type { AgentTool } from './agent-tool.ts';
24
+ export type { AgentTool } from './agent-tool.ts';
34
25
 
35
26
  export interface AgentProgress {
36
27
  /** 1-based turn number (turn 1 is the first LLM call). */
@@ -28,6 +28,7 @@ import { imprintHomeDir } from './paths.ts';
28
28
  import { type TeachState, loadTeachState } from './teach-state.ts';
29
29
  import { type ResolvedTool, discoverTools } from './tool-loader.ts';
30
30
  import { recordLlmUsageSpan, setSpanAttributes, totalPromptTokens, traced } from './tracing.ts';
31
+ import { authActionOutcomes } from './types.ts';
31
32
 
32
33
  const log = createLog('audit');
33
34
 
@@ -214,7 +215,11 @@ export function partitionAuditTools(tools: readonly ResolvedTool[]): AuditToolPa
214
215
  continue;
215
216
  }
216
217
  const pauses = auth
217
- ? Object.entries(auth.actions).filter(([, action]) => action.outcome.type === 'pause')
218
+ ? Object.entries(auth.actions).flatMap(([name, action]) =>
219
+ authActionOutcomes(action).flatMap((outcome) =>
220
+ outcome.type === 'pause' ? [`${name}: ${outcome.message}`] : [],
221
+ ),
222
+ )
218
223
  : [];
219
224
  if (pauses.length === 0) {
220
225
  eligible.push(tool);
@@ -223,11 +228,7 @@ export function partitionAuditTools(tools: readonly ResolvedTool[]): AuditToolPa
223
228
  }
224
229
 
225
230
  const receipt = readAuthVerificationReceiptStatus(tool.dir, tool.workflow);
226
- const pauseSummary = pauses
227
- .map(([name, action]) =>
228
- action.outcome.type === 'pause' ? `${name}: ${action.outcome.message}` : name,
229
- )
230
- .join('; ');
231
+ const pauseSummary = pauses.join('; ');
231
232
  skippedInteractiveAuth.push({
232
233
  name: tool.workflow.toolName,
233
234
  reason: `requires human continuation (${pauseSummary})`,
@@ -549,8 +550,23 @@ interface RunAuditOptions {
549
550
  json?: boolean;
550
551
  }
551
552
 
552
- export function buildAuditMcpEnvironment(assetRoot = imprintHomeDir()): Record<string, string> {
553
+ export function buildAuditMcpEnvironment(
554
+ assetRoot = imprintHomeDir(),
555
+ parentEnv: NodeJS.ProcessEnv = process.env,
556
+ ): Record<string, string> {
557
+ const browserEnv: Record<string, string> = {};
558
+ for (const name of [
559
+ 'CHROMIUM_PATH',
560
+ 'IMPRINT_CDP_HEADED',
561
+ 'IMPRINT_JAR_MAX_AGE_SECONDS',
562
+ 'IMPRINT_PROXY',
563
+ 'DISPLAY',
564
+ ]) {
565
+ const value = parentEnv[name];
566
+ if (value !== undefined) browserEnv[name] = value;
567
+ }
553
568
  return {
569
+ ...browserEnv,
554
570
  // Codex MCP server `env` entries are an explicit environment, so relying on
555
571
  // the audit driver's inherited IMPRINT_HOME can make an isolated audit
556
572
  // silently fall back to ~/.imprint and expose stale tools.
@@ -560,6 +576,17 @@ export function buildAuditMcpEnvironment(assetRoot = imprintHomeDir()): Record<s
560
576
  };
561
577
  }
562
578
 
579
+ /** Keep Codex's explicit MCP environment identical to Claude's JSON config. */
580
+ export function buildAuditCodexMcpEnvArgs(
581
+ serverName: string,
582
+ env: Record<string, string>,
583
+ ): string[] {
584
+ return Object.entries(env).flatMap(([name, value]) => [
585
+ '-c',
586
+ `mcp_servers.${serverName}.env.${name}=${JSON.stringify(value)}`,
587
+ ]);
588
+ }
589
+
563
590
  export function buildAuditMcpCommandArgs(site: string, toolNames: readonly string[]): string[] {
564
591
  return ['run', CLI_PATH, 'mcp-server', site, '--include-tools', toolNames.join(',')];
565
592
  }
@@ -1109,12 +1136,7 @@ ${buildAuditInitialPrompt(opts, unverifiedNote)}`;
1109
1136
  `mcp_servers.${serverName}.default_tools_approval_mode=${JSON.stringify('approve')}`,
1110
1137
  '-c',
1111
1138
  `mcp_servers.${serverName}.tool_timeout_sec=300`,
1112
- '-c',
1113
- `mcp_servers.${serverName}.env.IMPRINT_HOME=${JSON.stringify(mcpEnv.IMPRINT_HOME)}`,
1114
- '-c',
1115
- `mcp_servers.${serverName}.env.IMPRINT_AUDIT_PACING_MS=${JSON.stringify(mcpEnv.IMPRINT_AUDIT_PACING_MS)}`,
1116
- '-c',
1117
- `mcp_servers.${serverName}.env.IMPRINT_AUDIT_TOOL_DEADLINE_MS=${JSON.stringify(mcpEnv.IMPRINT_AUDIT_TOOL_DEADLINE_MS)}`,
1139
+ ...buildAuditCodexMcpEnvArgs(serverName, mcpEnv),
1118
1140
  '-c',
1119
1141
  'shell_environment_policy.inherit=all',
1120
1142
  '-',
@@ -0,0 +1,276 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type CDP from 'chrome-remote-interface';
3
+ import { z } from 'zod';
4
+ import type { CapturedRequest } from './types.ts';
5
+
6
+ export const AUTH_DISCOVERY_POOL_KEY = 'auth-browser-discovery';
7
+
8
+ /** Browser mechanics only; the auth agent chooses the page, controls and factor. */
9
+ export const AuthBrowserActionSchema = z.discriminatedUnion('action', [
10
+ z.object({ action: z.literal('inspect') }),
11
+ z.object({ action: z.literal('navigate'), url: z.string().url() }),
12
+ z.object({ action: z.literal('click'), selector: z.string().min(1) }),
13
+ z.object({ action: z.literal('fill'), selector: z.string().min(1), value: z.string() }),
14
+ z.object({ action: z.literal('select'), selector: z.string().min(1), value: z.string() }),
15
+ z.object({ action: z.literal('press'), key: z.string().min(1) }),
16
+ z.object({ action: z.literal('wait'), milliseconds: z.number().int().min(0).max(30_000) }),
17
+ ]);
18
+ export type AuthBrowserAction = z.infer<typeof AuthBrowserActionSchema>;
19
+
20
+ export interface AuthBrowserObservation {
21
+ sourceId: string;
22
+ page: {
23
+ url: string;
24
+ title: string;
25
+ bodyText: string;
26
+ controls: Array<{
27
+ selector: string;
28
+ tag: string;
29
+ type: string;
30
+ name: string;
31
+ text: string;
32
+ disabled: boolean;
33
+ }>;
34
+ controlsTruncated: boolean;
35
+ };
36
+ requests: CapturedRequest[];
37
+ captureErrors: Array<{ seq: number; message: string }>;
38
+ }
39
+
40
+ type Client = CDP.Client;
41
+
42
+ /** Kept attached between checkpoints, including while the human handles an OTP.
43
+ * Request identity is allocated on send, never on response completion. */
44
+ export class AuthBrowserDiscovery {
45
+ private readonly sourceId = randomUUID();
46
+ private readonly started = Date.now();
47
+ private sequence = 0;
48
+ private readonly inflight = new Map<string, CapturedRequest>();
49
+ private readonly changed = new Map<number, CapturedRequest>();
50
+ private readonly bodyReads = new Set<Promise<void>>();
51
+ private errors: AuthBrowserObservation['captureErrors'] = [];
52
+ private closed = false;
53
+
54
+ constructor(
55
+ private readonly client: Client,
56
+ private readonly timeoutMs: number,
57
+ ) {
58
+ client.Network.requestWillBeSent((event) => {
59
+ if (this.closed) return;
60
+ const previous = this.inflight.get(event.requestId);
61
+ if (previous && event.redirectResponse) {
62
+ previous.response = {
63
+ status: event.redirectResponse.status,
64
+ headers: headers(event.redirectResponse.headers),
65
+ mimeType: event.redirectResponse.mimeType,
66
+ };
67
+ this.changed.set(previous.seq, previous);
68
+ }
69
+ if (!/^https?:/.test(event.request.url)) return;
70
+ const request: CapturedRequest = {
71
+ seq: this.sequence++,
72
+ timestamp: Date.now() - this.started,
73
+ method: event.request.method,
74
+ url: event.request.url,
75
+ headers: headers(event.request.headers),
76
+ body: event.request.postData,
77
+ resourceType: event.type ?? 'Other',
78
+ };
79
+ this.inflight.set(event.requestId, request);
80
+ this.changed.set(request.seq, request);
81
+ if (event.request.hasPostData && event.request.postData === undefined) {
82
+ this.track(async () => {
83
+ try {
84
+ const body = await this.command(
85
+ client.Network.getRequestPostData({ requestId: event.requestId }),
86
+ );
87
+ request.body = body.postData;
88
+ } catch {
89
+ this.errors.push({ seq: request.seq, message: 'Request body unavailable from CDP.' });
90
+ }
91
+ this.changed.set(request.seq, request);
92
+ });
93
+ }
94
+ });
95
+ client.Network.responseReceived((event) => {
96
+ if (this.closed) return;
97
+ const request = this.inflight.get(event.requestId);
98
+ if (!request) return;
99
+ request.response = {
100
+ status: event.response.status,
101
+ headers: headers(event.response.headers),
102
+ mimeType: event.response.mimeType,
103
+ };
104
+ this.changed.set(request.seq, request);
105
+ });
106
+ client.Network.loadingFinished((event) => {
107
+ if (this.closed) return;
108
+ const request = this.inflight.get(event.requestId);
109
+ if (!request) return;
110
+ this.inflight.delete(event.requestId);
111
+ this.track(async () => {
112
+ try {
113
+ const body = await this.command(
114
+ client.Network.getResponseBody({ requestId: event.requestId }),
115
+ );
116
+ if (request.response) {
117
+ request.response.body = body.base64Encoded
118
+ ? Buffer.from(body.body, 'base64').toString('utf8')
119
+ : body.body;
120
+ }
121
+ } catch {
122
+ this.errors.push({ seq: request.seq, message: 'Response body unavailable from CDP.' });
123
+ }
124
+ this.changed.set(request.seq, request);
125
+ });
126
+ });
127
+ client.Network.loadingFailed((event) => {
128
+ if (this.closed) return;
129
+ const request = this.inflight.get(event.requestId);
130
+ if (!request) return;
131
+ this.inflight.delete(event.requestId);
132
+ this.errors.push({ seq: request.seq, message: event.errorText });
133
+ this.changed.set(request.seq, request);
134
+ });
135
+ }
136
+
137
+ close(): void {
138
+ this.closed = true;
139
+ this.inflight.clear();
140
+ this.changed.clear();
141
+ this.errors = [];
142
+ }
143
+
144
+ async interact(
145
+ action: AuthBrowserAction,
146
+ clickExpression: (selector: string) => string,
147
+ ): Promise<AuthBrowserObservation> {
148
+ const { Input, Page } = this.client;
149
+ if (action.action === 'navigate') {
150
+ if (!/^https?:/.test(action.url)) throw new Error('Navigation requires an HTTP(S) URL.');
151
+ const result = await this.command(Page.navigate({ url: action.url }));
152
+ if (result.errorText) throw new Error(result.errorText);
153
+ } else if (action.action === 'click') {
154
+ const point = (await this.evaluate(clickExpression(action.selector))) as {
155
+ x?: number;
156
+ y?: number;
157
+ diagnostic?: unknown;
158
+ };
159
+ if (point.x === undefined || point.y === undefined)
160
+ throw new Error(`Click target unavailable: ${JSON.stringify(point.diagnostic)}`);
161
+ await this.command(
162
+ Input.dispatchMouseEvent({
163
+ type: 'mousePressed',
164
+ x: point.x,
165
+ y: point.y,
166
+ button: 'left',
167
+ clickCount: 1,
168
+ }),
169
+ );
170
+ await this.command(
171
+ Input.dispatchMouseEvent({
172
+ type: 'mouseReleased',
173
+ x: point.x,
174
+ y: point.y,
175
+ button: 'left',
176
+ clickCount: 1,
177
+ }),
178
+ );
179
+ } else if (action.action === 'fill' || action.action === 'select') {
180
+ await this.evaluate(`(() => {
181
+ const matches = document.querySelectorAll(${JSON.stringify(action.selector)});
182
+ if (matches.length !== 1) throw new Error('Expected exactly one input control');
183
+ const element = matches[0];
184
+ if (element.matches(':disabled')) throw new Error('Control is disabled');
185
+ element.focus();
186
+ if (${JSON.stringify(action.action)} === 'select') {
187
+ if (!(element instanceof HTMLSelectElement)) throw new Error('Control is not a select');
188
+ element.value = ${JSON.stringify(action.value)};
189
+ element.dispatchEvent(new Event('input', { bubbles: true }));
190
+ element.dispatchEvent(new Event('change', { bubbles: true }));
191
+ } else {
192
+ if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) throw new Error('Control is not a text input');
193
+ element.select();
194
+ }
195
+ })()`);
196
+ if (action.action === 'fill') await this.command(Input.insertText({ text: action.value }));
197
+ } else if (action.action === 'press') {
198
+ const code = { Enter: 13, Tab: 9, Escape: 27, Backspace: 8 }[action.key];
199
+ await this.command(
200
+ Input.dispatchKeyEvent({
201
+ type: 'keyDown',
202
+ key: action.key,
203
+ windowsVirtualKeyCode: code,
204
+ ...(action.key === 'Enter' ? { text: '\r' } : {}),
205
+ }),
206
+ );
207
+ await this.command(
208
+ Input.dispatchKeyEvent({ type: 'keyUp', key: action.key, windowsVirtualKeyCode: code }),
209
+ );
210
+ }
211
+ // This is an observation interval, not a claim that the page has settled.
212
+ // The agent can inspect/wait again for slow responses or external actions.
213
+ await new Promise((resolve) =>
214
+ setTimeout(resolve, action.action === 'wait' ? action.milliseconds : 250),
215
+ );
216
+ const page = (await this.evaluate(`(() => {
217
+ const selector = (element) => {
218
+ if (element.id && document.querySelectorAll('#' + CSS.escape(element.id)).length === 1) return '#' + CSS.escape(element.id);
219
+ const parts = [];
220
+ for (let node = element; node && node.nodeType === 1; node = node.parentElement) {
221
+ const peers = node.parentElement ? [...node.parentElement.children].filter(p => p.tagName === node.tagName) : [node];
222
+ parts.unshift(node.tagName.toLowerCase() + ':nth-of-type(' + (peers.indexOf(node) + 1) + ')');
223
+ }
224
+ return parts.join(' > ');
225
+ };
226
+ const controls = [...document.querySelectorAll('input:not([type=hidden]),button,select,textarea,a[href],[role=button],[role=radio]')].filter(e => e.getClientRects().length);
227
+ return {
228
+ url: location.href, title: document.title, bodyText: (document.body?.innerText ?? '').slice(0, 20000),
229
+ controls: controls.slice(0, 100).map(e => ({ selector: selector(e), tag: e.tagName.toLowerCase(), type: e.getAttribute('type') || '', name: e.getAttribute('name') || '', text: (e.getAttribute('aria-label') || e.labels?.[0]?.innerText || e.innerText || '').slice(0, 300), disabled: e.matches(':disabled') })),
230
+ controlsTruncated: controls.length > 100
231
+ };
232
+ })()`)) as AuthBrowserObservation['page'];
233
+ await Promise.all([...this.bodyReads]);
234
+ const requests = structuredClone([...this.changed.values()]);
235
+ this.changed.clear();
236
+ const captureErrors = this.errors;
237
+ this.errors = [];
238
+ return { sourceId: this.sourceId, page, requests, captureErrors };
239
+ }
240
+
241
+ private track(work: () => Promise<void>): void {
242
+ const promise = work();
243
+ this.bodyReads.add(promise);
244
+ void promise.finally(() => this.bodyReads.delete(promise));
245
+ }
246
+
247
+ private async evaluate(expression: string): Promise<unknown> {
248
+ const result = await this.command(
249
+ this.client.Runtime.evaluate({ expression, returnByValue: true }),
250
+ );
251
+ if (result.exceptionDetails)
252
+ throw new Error(result.exceptionDetails.text || 'Browser interaction failed');
253
+ return result.result.value;
254
+ }
255
+
256
+ private async command<T>(promise: Promise<T>): Promise<T> {
257
+ let timer: ReturnType<typeof setTimeout> | undefined;
258
+ try {
259
+ return await Promise.race([
260
+ promise,
261
+ new Promise<never>((_, reject) => {
262
+ timer = setTimeout(
263
+ () => reject(new Error('Browser discovery CDP command timed out')),
264
+ this.timeoutMs,
265
+ );
266
+ }),
267
+ ]);
268
+ } finally {
269
+ clearTimeout(timer);
270
+ }
271
+ }
272
+ }
273
+
274
+ function headers(values: Record<string, unknown>): Record<string, string> {
275
+ return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, String(value)]));
276
+ }