imprint-mcp 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -180,6 +180,8 @@ When an API call gets blocked, Imprint doesn't jump to DOM replay. It escalates
180
180
 
181
181
  The full order is `fetch → fetch-bootstrap → cdp-replay → stealth-fetch → playbook`; `auto` mode walks it and stops at the first backend that works.
182
182
 
183
+ For bot-protected sites, `imprint probe-backends <site> --tool <toolName>` writes a `backends.json` preference cache so cron and MCP start from the known-good backend instead of rediscovering blocked rungs. Use `imprint probe-backends <site> --all` to refresh every tool in a multi-tool site; `imprint mcp status` reports stale or invalid backend caches before they quietly fall back to the default ladder. CDP replay records both cold and warm timings when it succeeds: a timeout-safe cold start may rank by its fast warm runtime, but a cold start above the preferred threshold stays behind cold-safe backends in durable cache order.
184
+
183
185
  Every recording compiles to *both* `workflow.json` and `playbook.yaml`, so the ladder always has a DOM fallback.
184
186
 
185
187
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imprint-mcp",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Teach an AI agent how to use any website. Once. Records a real browser session + narration; generates a deterministic MCP tool plus a DOM-replay playbook fallback.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -63,6 +63,7 @@
63
63
  "playwright-extra": "^4.3.6",
64
64
  "puppeteer-extra-plugin-stealth": "^2.11.2",
65
65
  "redactum": "^1.1.0",
66
+ "semver": "^7.8.4",
66
67
  "yaml": "^2.8.4",
67
68
  "zod": "^3.24.0"
68
69
  },
@@ -72,6 +73,7 @@
72
73
  "@types/chrome-remote-interface": "^0.31.14",
73
74
  "@types/node": "^22.10.0",
74
75
  "@types/node-cron": "^3.0.11",
76
+ "@types/semver": "^7.7.1",
75
77
  "knip": "^5.0.0",
76
78
  "madge": "^8.0.0",
77
79
  "typescript": "^5.7.0"
package/src/cli.ts CHANGED
@@ -346,9 +346,13 @@ export const VERB_HELP: Record<string, VerbHelp> = {
346
346
  },
347
347
  'probe-backends': {
348
348
  summary: 'Try each backend once and cache the working order to backends.json.',
349
- usage: ['imprint probe-backends <site> [--tool <toolName>] [--out <path>] [--param k=v]…'],
349
+ usage: [
350
+ 'imprint probe-backends <site> [--tool <toolName>] [--out <path>] [--param k=v]…',
351
+ 'imprint probe-backends <site> --all [--param k=v]…',
352
+ ],
350
353
  flags: [
351
354
  { name: '--tool <toolName>', description: 'Select a generated tool for multi-tool sites.' },
355
+ { name: '--all', description: 'Probe every generated tool for the site.' },
352
356
  { name: '--out <path>', description: 'Override backends.json output path.' },
353
357
  { name: '--param k=v', description: 'Override a workflow parameter (repeatable).' },
354
358
  ],
@@ -451,6 +455,17 @@ export const VERB_HELP: Record<string, VerbHelp> = {
451
455
  ],
452
456
  example: 'imprint mcp status',
453
457
  },
458
+ update: {
459
+ summary: 'Check for updates and install the latest version of imprint.',
460
+ usage: ['imprint update [--check]'],
461
+ flags: [
462
+ {
463
+ name: '--check',
464
+ description: 'Only check whether an update is available; do not install.',
465
+ },
466
+ ],
467
+ example: 'imprint update',
468
+ },
454
469
  };
455
470
 
456
471
  function printVerbHelp(verb: string): void {
@@ -621,7 +636,7 @@ async function main(argv: string[]): Promise<number> {
621
636
 
622
637
  case 'doctor': {
623
638
  const { doctor, reportDoctor } = await import('./imprint/doctor.ts');
624
- const report = reportDoctor(doctor());
639
+ const report = reportDoctor(await doctor());
625
640
  for (const line of report.lines) console.log(line);
626
641
  return report.ok ? 0 : 1;
627
642
  }
@@ -1201,6 +1216,7 @@ async function main(argv: string[]): Promise<number> {
1201
1216
  const { values } = parseArgs({
1202
1217
  args: argv.slice(2),
1203
1218
  options: {
1219
+ all: { type: 'boolean' },
1204
1220
  out: { type: 'string' },
1205
1221
  tool: { type: 'string' },
1206
1222
  param: { type: 'string', multiple: true },
@@ -1209,7 +1225,30 @@ async function main(argv: string[]): Promise<number> {
1209
1225
  });
1210
1226
  const overrides = tryParseParamKV(values.param);
1211
1227
  if (overrides === null) return 2;
1212
- const { probeBackends } = await import('./imprint/probe-backends.ts');
1228
+ if (values.all && values.tool) {
1229
+ console.error('error: --all cannot be combined with --tool');
1230
+ return 2;
1231
+ }
1232
+ if (values.all && values.out) {
1233
+ console.error('error: --all cannot be combined with --out');
1234
+ return 2;
1235
+ }
1236
+ const { probeAllBackends, probeBackends } = await import('./imprint/probe-backends.ts');
1237
+ if (values.all) {
1238
+ const results = await probeAllBackends({
1239
+ site,
1240
+ paramOverrides: Object.keys(overrides).length > 0 ? overrides : undefined,
1241
+ });
1242
+ for (const result of results) {
1243
+ console.log(`[imprint] probed → ${result.outPath}`);
1244
+ console.log(`[imprint] preferred order: ${result.cache.preferredOrder.join(' → ')}`);
1245
+ }
1246
+ console.log('');
1247
+ console.log(
1248
+ '[imprint] cron + mcp-server now skip futile rungs at startup using these caches.',
1249
+ );
1250
+ return 0;
1251
+ }
1213
1252
  const result = await probeBackends({
1214
1253
  site,
1215
1254
  outPath: values.out,
@@ -1482,6 +1521,38 @@ async function main(argv: string[]): Promise<number> {
1482
1521
  return 0;
1483
1522
  }
1484
1523
 
1524
+ case 'update': {
1525
+ const { checkForUpdate, performUpdate } = await import('./imprint/update.ts');
1526
+ const checkOnly = argv.slice(1).includes('--check');
1527
+ if (checkOnly) {
1528
+ const result = await checkForUpdate();
1529
+ if (!result) {
1530
+ console.error('Could not reach npm registry.');
1531
+ return 1;
1532
+ }
1533
+ console.log(`Current: v${result.current}`);
1534
+ console.log(`Latest: v${result.latest}`);
1535
+ if (result.updateAvailable) {
1536
+ console.log('\nUpdate available — run `imprint update` to install.');
1537
+ } else {
1538
+ console.log('\nAlready up to date.');
1539
+ }
1540
+ return 0;
1541
+ }
1542
+ console.log('Checking for updates...');
1543
+ const result = await performUpdate();
1544
+ if (result.from === result.to && result.ok) {
1545
+ console.log(`imprint v${result.from} is already the latest version.`);
1546
+ return 0;
1547
+ }
1548
+ if (result.ok) {
1549
+ console.log(`Updated imprint: v${result.from} → v${result.to}`);
1550
+ return 0;
1551
+ }
1552
+ console.error(`Update failed: ${result.error}`);
1553
+ return 1;
1554
+ }
1555
+
1485
1556
  default: {
1486
1557
  const suggestion = closestVerb(verb);
1487
1558
  const tail = suggestion ? `did you mean \`imprint ${suggestion}\`?` : 'run `imprint --help`';
@@ -75,6 +75,8 @@ interface LadderResult {
75
75
  const log = createLog('backend');
76
76
 
77
77
  const DEFAULT_LADDER: ConcreteBackend[] = ['fetch', 'stealth-fetch', 'playbook'];
78
+ const DEFAULT_PLAYBOOK_BACKEND_TIMEOUT_MS = 75_000;
79
+ const DEFAULT_PLAYBOOK_BACKEND_STEP_TIMEOUT_MS = 20_000;
78
80
 
79
81
  /** Process-scoped memo of the backend that last succeeded for a site on the
80
82
  * compile/test path (`runWorkflowWithLadder`). Lets the param-coverage suite
@@ -182,6 +184,22 @@ function sleepMs(ms: number): Promise<void> {
182
184
  return new Promise((r) => setTimeout(r, ms));
183
185
  }
184
186
 
187
+ function playbookBackendTimeoutMs(): number {
188
+ return positiveEnvMs('IMPRINT_PLAYBOOK_BACKEND_TIMEOUT_MS', DEFAULT_PLAYBOOK_BACKEND_TIMEOUT_MS);
189
+ }
190
+
191
+ function playbookBackendStepTimeoutMs(): number {
192
+ return positiveEnvMs(
193
+ 'IMPRINT_PLAYBOOK_BACKEND_STEP_TIMEOUT_MS',
194
+ DEFAULT_PLAYBOOK_BACKEND_STEP_TIMEOUT_MS,
195
+ );
196
+ }
197
+
198
+ function positiveEnvMs(name: string, fallback: number): number {
199
+ const raw = Number(process.env[name] ?? fallback);
200
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : fallback;
201
+ }
202
+
185
203
  function withWorkflowDefaults(
186
204
  workflow: Workflow,
187
205
  params: Record<string, string | number | boolean>,
@@ -317,7 +335,8 @@ export async function runWithLadder(
317
335
  result = await runCdpReplay(tool, params, options?.cdpPool);
318
336
  break;
319
337
  case 'stealth-fetch': {
320
- const sf = ensureStealthFetch(tool, stealthCache);
338
+ const paramsWithDefaults = withWorkflowDefaults(tool.workflow, params);
339
+ const sf = await ensureStealthFetch(tool, stealthCache, paramsWithDefaults);
321
340
  // When the workflow declares a bootstrap block, mint its declared
322
341
  // session-token state (CSRF cookies etc.) from the SAME stealth
323
342
  // session that provides the transport cookies. Without this, a
@@ -327,7 +346,7 @@ export async function runWithLadder(
327
346
  const initialState = tool.workflow.bootstrap
328
347
  ? await stealthBootstrapState(sf, tool.workflow.bootstrap)
329
348
  : undefined;
330
- result = await tool.toolFn(params, { fetchImpl: sf.fetchImpl, initialState });
349
+ result = await tool.toolFn(paramsWithDefaults, { fetchImpl: sf.fetchImpl, initialState });
331
350
  break;
332
351
  }
333
352
  case 'playbook': {
@@ -339,6 +358,8 @@ export async function runWithLadder(
339
358
  playbook: playbookPath(assetRoot, tool.site, tool.dir),
340
359
  params: paramsWithDefaults,
341
360
  site: tool.site,
361
+ stepTimeoutMs: playbookBackendStepTimeoutMs(),
362
+ maxDurationMs: playbookBackendTimeoutMs(),
342
363
  });
343
364
  break;
344
365
  }
@@ -1213,8 +1234,21 @@ async function stealthBootstrapState(
1213
1234
  return state;
1214
1235
  }
1215
1236
 
1216
- function ensureStealthFetch(tool: ResolvedTool, cache: Map<string, StealthFetch>): StealthFetch {
1217
- const cached = cache.get(tool.site);
1237
+ async function ensureStealthFetch(
1238
+ tool: ResolvedTool,
1239
+ cache: Map<string, StealthFetch>,
1240
+ params: Record<string, string | number | boolean>,
1241
+ ): Promise<StealthFetch> {
1242
+ const credentials = (await loadCredentialStore(tool.site)) ?? {
1243
+ site: tool.site,
1244
+ cookies: [],
1245
+ values: {},
1246
+ };
1247
+ const bootstrapUrl = tool.workflow.bootstrap?.url
1248
+ ? substituteString(tool.workflow.bootstrap.url, params, credentials, [], 'url')
1249
+ : undefined;
1250
+ const cacheKey = bootstrapUrl ? `${tool.site}:${bootstrapUrl}` : tool.site;
1251
+ const cached = cache.get(cacheKey);
1218
1252
  if (cached) return cached;
1219
1253
  const sf = createStealthFetch({
1220
1254
  baseUrl: pickBaseUrl(tool),
@@ -1223,9 +1257,9 @@ function ensureStealthFetch(tool: ResolvedTool, cache: Map<string, StealthFetch>
1223
1257
  // minted in the same session as the anti-bot cookies. Otherwise the
1224
1258
  // stealth rung can't satisfy a `${state.X}` the workflow bootstrap was
1225
1259
  // supposed to provide, and escalation from fetch-bootstrap dead-ends.
1226
- bootstrapUrl: tool.workflow.bootstrap?.url,
1260
+ bootstrapUrl,
1227
1261
  });
1228
- cache.set(tool.site, sf);
1262
+ cache.set(cacheKey, sf);
1229
1263
  return sf;
1230
1264
  }
1231
1265
 
@@ -1452,7 +1486,7 @@ export async function runWorkflowWithLadder(opts: {
1452
1486
  // A backend that finishes AFTER the probe returned (it lost the race but
1453
1487
  // is still cold-starting Chrome) pools its browser late — arm the idle
1454
1488
  // close so it's torn down rather than left lingering.
1455
- void inner.finally(() => armCompileCdpIdleClose());
1489
+ void inner.finally(() => armCompileCdpIdleClose()).catch(() => {});
1456
1490
  const r = await Promise.race([
1457
1491
  inner,
1458
1492
  sleepMs(PROBE_TIMEOUT_MS).then(
@@ -16,7 +16,7 @@ import { loadJsonFile } from './load-json.ts';
16
16
  import { createLog, isDebug } from './log.ts';
17
17
  import { evaluateNotifyWhen, notify } from './notify.ts';
18
18
  import { imprintHomeDir } from './paths.ts';
19
- import { loadBackendsCache } from './probe-backends.ts';
19
+ import { loadBackendsCache, persistRuntimeBackendsCache } from './probe-backends.ts';
20
20
  import { checkSiteCredentialsReady } from './runtime.ts';
21
21
  import { availableSitesHint } from './sites.ts';
22
22
  import type { StealthFetch } from './stealth-fetch.ts';
@@ -100,6 +100,19 @@ async function runOnce(
100
100
  }
101
101
 
102
102
  if (result.ok) {
103
+ try {
104
+ const cache = persistRuntimeBackendsCache({
105
+ tool,
106
+ assetRoot,
107
+ usedBackend,
108
+ attempts,
109
+ });
110
+ if (cache) log(` learned backend order: ${cache.preferredOrder.join(' → ')}`);
111
+ } catch (err) {
112
+ log(
113
+ ` warning: could not persist backend order: ${err instanceof Error ? err.message : String(err)}`,
114
+ );
115
+ }
103
116
  const data = typeof result.data === 'string' ? result.data : JSON.stringify(result.data);
104
117
  // Cap the inline preview at ~500 chars; full payload available via
105
118
  // IMPRINT_DEBUG=1. Long-running daemons flood stderr otherwise.
@@ -8,6 +8,7 @@ import { join as pathJoin } from 'node:path';
8
8
  import { findChromium } from './chromium.ts';
9
9
  import { defaultHermesConfigPath } from './install.ts';
10
10
  import { getProviderStatuses } from './llm.ts';
11
+ import { checkForUpdate } from './update.ts';
11
12
  import { VERSION } from './version.ts';
12
13
 
13
14
  export interface CheckResult {
@@ -17,9 +18,10 @@ export interface CheckResult {
17
18
  fix?: string;
18
19
  }
19
20
 
20
- export function doctor(): CheckResult[] {
21
+ export async function doctor(): Promise<CheckResult[]> {
21
22
  return [
22
23
  checkBun(),
24
+ await checkLatestVersion(),
23
25
  checkChromium(),
24
26
  checkPlaywrightChromium(),
25
27
  checkVirtualDisplay(),
@@ -31,6 +33,22 @@ export function doctor(): CheckResult[] {
31
33
  ];
32
34
  }
33
35
 
36
+ async function checkLatestVersion(): Promise<CheckResult> {
37
+ const result = await checkForUpdate();
38
+ if (!result) {
39
+ return { name: 'Latest version', ok: true, detail: `v${VERSION} (could not reach registry)` };
40
+ }
41
+ if (!result.updateAvailable) {
42
+ return { name: 'Latest version', ok: true, detail: `v${VERSION} (up to date)` };
43
+ }
44
+ return {
45
+ name: 'Latest version',
46
+ ok: true,
47
+ detail: `v${result.current} → v${result.latest} available`,
48
+ fix: 'run: imprint update',
49
+ };
50
+ }
51
+
34
52
  function checkBun(): CheckResult {
35
53
  const v = process.versions.bun;
36
54
  if (!v) {
@@ -27,6 +27,7 @@ import {
27
27
  import * as p from '@clack/prompts';
28
28
  import YAML from 'yaml';
29
29
  import { imprintHomeDir, localSiteDir } from './paths.ts';
30
+ import { type BackendsCacheStatus, loadBackendsCacheStatus } from './probe-backends.ts';
30
31
  import {
31
32
  type WorkflowState,
32
33
  loadTeachState,
@@ -38,7 +39,7 @@ import {
38
39
 
39
40
  type McpClient = 'claude-code' | 'codex' | 'claude-desktop' | 'openclaw' | 'hermes';
40
41
  type LocalDeleteMode = 'none' | 'tool' | 'site';
41
- type IssueKind = 'missing-session' | 'stale-registration';
42
+ type IssueKind = 'missing-session' | 'stale-registration' | 'stale-backends' | 'invalid-backends';
42
43
 
43
44
  const CLIENTS: McpClient[] = ['claude-code', 'codex', 'claude-desktop', 'openclaw', 'hermes'];
44
45
  const DISABLED_STORE_VERSION = 1;
@@ -81,6 +82,15 @@ interface LocalToolStatus {
81
82
  hasPlaybook: boolean;
82
83
  hasBackends: boolean;
83
84
  hasCron: boolean;
85
+ backendCache: PublicBackendsCacheStatus;
86
+ }
87
+
88
+ interface PublicBackendsCacheStatus {
89
+ status: BackendsCacheStatus['status'];
90
+ path: string | null;
91
+ preferredOrder?: string[];
92
+ reason?: string;
93
+ remediation?: string;
84
94
  }
85
95
 
86
96
  interface LocalWorkflowStatus {
@@ -475,7 +485,13 @@ function cmdStatus(argv: string[]): number {
475
485
  const status = scanMcpStatus({ site });
476
486
  if (flags.json === true) console.log(JSON.stringify(status, null, 2));
477
487
  else console.log(formatMcpStatus(status));
478
- return status.issues.some((i) => i.kind === 'stale-registration' || i.kind === 'missing-session')
488
+ return status.issues.some(
489
+ (i) =>
490
+ i.kind === 'stale-registration' ||
491
+ i.kind === 'missing-session' ||
492
+ i.kind === 'stale-backends' ||
493
+ i.kind === 'invalid-backends',
494
+ )
479
495
  ? 1
480
496
  : 0;
481
497
  }
@@ -691,6 +707,11 @@ function issueFixHint(issue: McpIssue): string | null {
691
707
  return `choose "Fix an issue" or run: imprint mcp delete ${issue.name ?? `imprint-${issue.site}`} --client ${issue.client ?? 'all'} --yes`;
692
708
  case 'missing-session':
693
709
  return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --missing-session --yes`;
710
+ case 'stale-backends':
711
+ case 'invalid-backends':
712
+ return issue.path
713
+ ? `run: imprint probe-backends ${issue.site}${issue.workflow ? ` --tool ${issue.workflow}` : ''}`
714
+ : `run: imprint probe-backends ${issue.site}${issue.workflow ? ` --tool ${issue.workflow}` : ''}`;
694
715
  }
695
716
  return null;
696
717
  }
@@ -712,26 +733,32 @@ function scanLocalSites(ctx: MaintenanceContext): LocalSiteStatus[] {
712
733
  if (entry === 'node_modules' || entry.startsWith('.')) continue;
713
734
  const dir = pathJoin(ctx.imprintHome, entry);
714
735
  if (!safeIsDir(dir)) continue;
715
- sites.push(scanLocalSite(entry, dir));
736
+ sites.push(scanLocalSite(entry, dir, ctx.imprintHome));
716
737
  }
717
738
  return sites;
718
739
  }
719
740
 
720
- function scanLocalSite(site: string, dir: string): LocalSiteStatus {
741
+ function scanLocalSite(site: string, dir: string, imprintHome: string): LocalSiteStatus {
721
742
  const tools: LocalToolStatus[] = [];
722
743
  for (const entry of readdirSync(dir).sort()) {
723
744
  if (entry === 'sessions' || entry === '_shared' || entry.startsWith('.')) continue;
724
745
  const toolDir = pathJoin(dir, entry);
725
746
  if (!safeIsDir(toolDir)) continue;
747
+ const toolName = workflowJsonToolName(toolDir) ?? entry;
748
+ const cacheStatus = loadBackendsCacheStatus(site, imprintHome, toolDir, {
749
+ warn: false,
750
+ toolName,
751
+ });
726
752
  tools.push({
727
753
  site,
728
- toolName: entry,
754
+ toolName,
729
755
  dir: toolDir,
730
756
  complete: existsSync(pathJoin(toolDir, 'index.ts')),
731
757
  hasWorkflow: existsSync(pathJoin(toolDir, 'workflow.json')),
732
758
  hasPlaybook: existsSync(pathJoin(toolDir, 'playbook.yaml')),
733
759
  hasBackends: existsSync(pathJoin(toolDir, 'backends.json')),
734
760
  hasCron: existsSync(pathJoin(toolDir, 'cron.json')),
761
+ backendCache: publicBackendsCacheStatus(cacheStatus),
735
762
  });
736
763
  }
737
764
 
@@ -746,6 +773,29 @@ function scanLocalSite(site: string, dir: string): LocalSiteStatus {
746
773
  return { site, dir, tools, workflows };
747
774
  }
748
775
 
776
+ function publicBackendsCacheStatus(status: BackendsCacheStatus): PublicBackendsCacheStatus {
777
+ if (status.status === 'ok') {
778
+ return {
779
+ status: status.status,
780
+ path: status.path,
781
+ preferredOrder: status.cache.preferredOrder,
782
+ };
783
+ }
784
+ if (status.status === 'missing') {
785
+ return {
786
+ status: status.status,
787
+ path: status.path,
788
+ remediation: status.remediation,
789
+ };
790
+ }
791
+ return {
792
+ status: status.status,
793
+ path: status.path,
794
+ reason: status.reason,
795
+ remediation: status.remediation,
796
+ };
797
+ }
798
+
749
799
  function workflowStatus(
750
800
  site: string,
751
801
  name: string,
@@ -795,6 +845,21 @@ function collectIssues(opts: {
795
845
  const sitesByName = new Map(opts.sites.map((s) => [s.site, s]));
796
846
 
797
847
  for (const site of opts.sites) {
848
+ for (const tool of site.tools) {
849
+ if (tool.backendCache.status === 'stale' || tool.backendCache.status === 'invalid') {
850
+ issues.push({
851
+ kind: tool.backendCache.status === 'stale' ? 'stale-backends' : 'invalid-backends',
852
+ site: site.site,
853
+ workflow: tool.toolName,
854
+ path: tool.backendCache.path ?? undefined,
855
+ message:
856
+ tool.backendCache.status === 'stale'
857
+ ? `${site.site}/${tool.toolName} has a stale backends.json; runtime will fall back to the default ladder until reprobed`
858
+ : `${site.site}/${tool.toolName} has an invalid backends.json; runtime will fall back to the default ladder until reprobed`,
859
+ });
860
+ }
861
+ }
862
+
798
863
  for (const wf of site.workflows) {
799
864
  if (wf.missingSession) {
800
865
  issues.push({
@@ -1091,7 +1156,7 @@ function pruneTeachState(
1091
1156
  for (const site of sites) {
1092
1157
  const statePath = teachStatePath(site);
1093
1158
  if (!existsSync(statePath)) continue;
1094
- const status = scanLocalSite(site, localSiteDir(site));
1159
+ const status = scanLocalSite(site, localSiteDir(site), ctx.imprintHome);
1095
1160
  const remove = new Set(
1096
1161
  status.workflows
1097
1162
  .filter(
@@ -20,7 +20,7 @@ import { resolveLadder, runWithLadder } from './backend-ladder.ts';
20
20
  import type { CdpBrowserFetch } from './cdp-browser-fetch.ts';
21
21
  import { createLog } from './log.ts';
22
22
  import { imprintHomeDir } from './paths.ts';
23
- import { loadBackendsCache } from './probe-backends.ts';
23
+ import { loadBackendsCacheStatus, persistRuntimeBackendsCache } from './probe-backends.ts';
24
24
  import { checkSiteCredentialsReady } from './runtime.ts';
25
25
  import { availableSitesHint } from './sites.ts';
26
26
  import type { StealthFetch } from './stealth-fetch.ts';
@@ -173,7 +173,7 @@ function buildServer(
173
173
 
174
174
  try {
175
175
  const ladder = resolveLadder('auto', tool.preferredOrder);
176
- const { result, usedBackend } = await runWithLadder(
176
+ const { result, usedBackend, attempts } = await runWithLadder(
177
177
  ladder,
178
178
  tool,
179
179
  args,
@@ -209,6 +209,24 @@ function buildServer(
209
209
  content: [{ type: 'text', text: `${text}\n(backend: ${usedBackend})` }],
210
210
  };
211
211
  }
212
+ try {
213
+ const cache = persistRuntimeBackendsCache({
214
+ tool,
215
+ assetRoot,
216
+ usedBackend,
217
+ attempts,
218
+ });
219
+ if (cache) {
220
+ tool.preferredOrder = cache.preferredOrder;
221
+ log(
222
+ ` learned backend order for ${tool.workflow.toolName}: ${cache.preferredOrder.join(' → ')}`,
223
+ );
224
+ }
225
+ } catch (err) {
226
+ log(
227
+ ` warning: could not persist backend order for ${tool.workflow.toolName}: ${err instanceof Error ? err.message : String(err)}`,
228
+ );
229
+ }
212
230
  const text =
213
231
  typeof result.data === 'string' ? result.data : JSON.stringify(result.data, null, 2);
214
232
  return { content: [{ type: 'text', text: `${text}\n\n(backend: ${usedBackend})` }] };
@@ -250,12 +268,19 @@ export async function runMcpServer(opts: RunMcpServerOptions): Promise<void> {
250
268
  const discovered = await discoverTools(assetRoot, opts.site, '[imprint mcp]');
251
269
  const tools: ResolvedTool[] = discovered.map((t) => {
252
270
  const playbookPath = pathResolve(t.dir, 'playbook.yaml');
253
- const cache = loadBackendsCache(t.site, assetRoot, t.dir);
271
+ const cacheStatus = loadBackendsCacheStatus(t.site, assetRoot, t.dir, {
272
+ toolName: t.workflow.toolName,
273
+ });
274
+ if (cacheStatus.status === 'stale' || cacheStatus.status === 'invalid') {
275
+ log(
276
+ ` ${t.workflow.toolName}: ${cacheStatus.status} backends.json (${cacheStatus.reason}); run \`${cacheStatus.remediation}\``,
277
+ );
278
+ }
254
279
  return {
255
280
  ...t,
256
281
  inputSchema: buildJsonSchema(t.workflow.parameters),
257
282
  playbookPath: existsSync(playbookPath) ? playbookPath : undefined,
258
- preferredOrder: cache?.preferredOrder,
283
+ preferredOrder: cacheStatus.status === 'ok' ? cacheStatus.cache.preferredOrder : undefined,
259
284
  };
260
285
  });
261
286
  if (tools.length === 0) {
@@ -30,6 +30,10 @@ interface RunPlaybookOptions {
30
30
  headed?: boolean;
31
31
  /** Per-step timeout in ms. Default 30000. */
32
32
  stepTimeoutMs?: number;
33
+ /** Whole-playbook timeout in ms. Default unbounded for direct playbook runs. */
34
+ maxDurationMs?: number;
35
+ /** Timeout for diagnostic screenshots in ms. Default 5000. */
36
+ screenshotTimeoutMs?: number;
33
37
  /** Screenshot after every step (not just on failure). */
34
38
  trace?: boolean;
35
39
  /** Inject a Playwright Page for tests. */
@@ -44,6 +48,8 @@ interface RunPlaybookOptions {
44
48
  }
45
49
 
46
50
  const log = createLog('playbook');
51
+ const DEFAULT_STEP_TIMEOUT_MS = 30000;
52
+ const DEFAULT_SCREENSHOT_TIMEOUT_MS = 5000;
47
53
 
48
54
  export async function runPlaybook(opts: RunPlaybookOptions): Promise<ToolResult> {
49
55
  let playbook: Playbook;
@@ -57,7 +63,10 @@ export async function runPlaybook(opts: RunPlaybookOptions): Promise<ToolResult>
57
63
  // Generous default — Akamai sensor JS, A/B loaders, lazy bundles all
58
64
  // need real time to settle. Tight timeouts make broken sites look
59
65
  // worse than they are.
60
- const stepTimeoutMs = opts.stepTimeoutMs ?? 30000;
66
+ const stepTimeoutMs = positiveMs(opts.stepTimeoutMs, DEFAULT_STEP_TIMEOUT_MS);
67
+ const screenshotTimeoutMs = positiveMs(opts.screenshotTimeoutMs, DEFAULT_SCREENSHOT_TIMEOUT_MS);
68
+ const deadlineAt =
69
+ opts.maxDurationMs !== undefined ? Date.now() + positiveMs(opts.maxDurationMs, 1) : null;
61
70
 
62
71
  let browser: Browser | undefined;
63
72
  let context: BrowserContext | undefined;
@@ -137,19 +146,42 @@ export async function runPlaybook(opts: RunPlaybookOptions): Promise<ToolResult>
137
146
 
138
147
  for (const [i, step] of playbook.steps.entries()) {
139
148
  lastStep = i + 1;
149
+ const budgetMs = budgetedTimeoutMs(
150
+ stepTimeoutMs,
151
+ deadlineAt,
152
+ `Playbook exceeded max duration before step ${lastStep}`,
153
+ );
140
154
  log(`step ${i + 1}/${playbook.steps.length}: ${step.action}`);
141
- await executeStep(page, step, params, stepTimeoutMs);
155
+ await withTimeout(
156
+ executeStep(page, step, params, budgetMs),
157
+ budgetMs,
158
+ `Playbook step ${lastStep}/${playbook.steps.length} (${step.action})`,
159
+ );
142
160
  if (opts.trace) {
143
- const traceShot = await screenshot(page, `${playbook.toolName}-trace`, lastStep);
161
+ const traceShot = await screenshot(
162
+ page,
163
+ `${playbook.toolName}-trace`,
164
+ lastStep,
165
+ screenshotTimeoutMs,
166
+ );
144
167
  log(` url=${page.url()}`);
145
168
  if (traceShot) log(` trace screenshot: ${traceShot}`);
146
169
  }
147
170
  }
148
- await Promise.allSettled(pendingBodyReads);
171
+ const bodyReadBudgetMs = budgetedTimeoutMs(
172
+ stepTimeoutMs,
173
+ deadlineAt,
174
+ 'Playbook exceeded max duration while reading captured responses',
175
+ );
176
+ await withTimeout(
177
+ Promise.allSettled(pendingBodyReads),
178
+ bodyReadBudgetMs,
179
+ 'Playbook captured-response drain',
180
+ );
149
181
  const data = await extractResult(page, playbook.result, captured);
150
182
  return { ok: true, data };
151
183
  } catch (err) {
152
- const screenshotPath = await screenshot(page, playbook.toolName, lastStep);
184
+ const screenshotPath = await screenshot(page, playbook.toolName, lastStep, screenshotTimeoutMs);
153
185
  const suffix = screenshotPath ? `\nscreenshot: ${screenshotPath}` : '';
154
186
  const errStr = errMsg(err);
155
187
  // Classify the failure mode honestly: a missing locator, a step
@@ -161,9 +193,10 @@ export async function runPlaybook(opts: RunPlaybookOptions): Promise<ToolResult>
161
193
  // bug, which over-attributes drift to defects. Map known
162
194
  // transient-shape errors to NETWORK so they count as `infra`
163
195
  // (re-runnable) rather than `tool_broken` (permanent defect).
164
- const isTransient = /No locator matched|Timeout \d+ms exceeded|forResponse|waiting for/i.test(
165
- errStr,
166
- );
196
+ const isTransient =
197
+ /No locator matched|Timeout \d+ms exceeded|timed out after|exceeded max duration|forResponse|waiting for/i.test(
198
+ errStr,
199
+ );
167
200
  return {
168
201
  ok: false,
169
202
  error: isTransient ? 'NETWORK' : 'BAD_RESPONSE',
@@ -177,19 +210,58 @@ export async function runPlaybook(opts: RunPlaybookOptions): Promise<ToolResult>
177
210
  }
178
211
  }
179
212
 
180
- async function screenshot(page: Page, toolName: string, stepNum: number): Promise<string | null> {
213
+ async function screenshot(
214
+ page: Page,
215
+ toolName: string,
216
+ stepNum: number,
217
+ timeoutMs: number,
218
+ ): Promise<string | null> {
181
219
  try {
182
220
  const { tmpdir } = await import('node:os');
183
221
  const { join } = await import('node:path');
184
222
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
185
223
  const path = join(tmpdir(), `imprint-playbook-${toolName}-step${stepNum}-${ts}.png`);
186
- await page.screenshot({ path, fullPage: true });
224
+ await withTimeout(page.screenshot({ path, fullPage: true }), timeoutMs, 'Playbook screenshot');
187
225
  return path;
188
226
  } catch {
189
227
  return null;
190
228
  }
191
229
  }
192
230
 
231
+ function positiveMs(value: number | undefined, fallback: number): number {
232
+ if (value === undefined) return fallback;
233
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
234
+ }
235
+
236
+ function budgetedTimeoutMs(
237
+ configuredMs: number,
238
+ deadlineAt: number | null,
239
+ errorMessage: string,
240
+ ): number {
241
+ if (deadlineAt === null) return configuredMs;
242
+ const remainingMs = deadlineAt - Date.now();
243
+ if (remainingMs <= 0) throw new Error(errorMessage);
244
+ return Math.max(1, Math.min(configuredMs, Math.floor(remainingMs)));
245
+ }
246
+
247
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
248
+ const boundedMs = positiveMs(timeoutMs, 1);
249
+ let timer: ReturnType<typeof setTimeout> | undefined;
250
+ try {
251
+ return await Promise.race([
252
+ promise,
253
+ new Promise<never>((_resolve, reject) => {
254
+ timer = setTimeout(
255
+ () => reject(new Error(`${label} timed out after ${boundedMs}ms`)),
256
+ boundedMs,
257
+ );
258
+ }),
259
+ ]);
260
+ } finally {
261
+ if (timer) clearTimeout(timer);
262
+ }
263
+ }
264
+
193
265
  async function loadPlaybook(input: string | Playbook): Promise<Playbook> {
194
266
  if (typeof input !== 'string') return input;
195
267
  if (!existsSync(input)) {
@@ -7,8 +7,9 @@
7
7
 
8
8
  import { createHash } from 'node:crypto';
9
9
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
10
- import { resolve as pathResolve } from 'node:path';
10
+ import { basename, resolve as pathResolve } from 'node:path';
11
11
  import { runWithLadder } from './backend-ladder.ts';
12
+ import type { CdpBrowserFetch } from './cdp-browser-fetch.ts';
12
13
  import { createLog } from './log.ts';
13
14
  import { imprintHomeDir } from './paths.ts';
14
15
  import { availableSitesHint } from './sites.ts';
@@ -42,6 +43,41 @@ interface ProbeBackendsResult {
42
43
  }
43
44
 
44
45
  const log = createLog('probe');
46
+ const DEFAULT_PREFERRED_MAX_MS = 90_000;
47
+
48
+ type BackendProbeCandidate = {
49
+ backend: ConcreteBackend;
50
+ durationMs: number;
51
+ rankingDurationMs?: number;
52
+ coldDurationMs?: number;
53
+ warmDurationMs?: number;
54
+ tooSlow: boolean;
55
+ };
56
+
57
+ type BackendRuntimeAttempt = {
58
+ backend: ConcreteBackend;
59
+ outcome: 'ok' | 'escalate' | 'failed' | 'unavailable';
60
+ detail: string;
61
+ durationMs: number;
62
+ };
63
+
64
+ export type BackendsCacheStatus =
65
+ | {
66
+ status: 'missing';
67
+ path: string | null;
68
+ remediation: string;
69
+ }
70
+ | {
71
+ status: 'ok';
72
+ path: string;
73
+ cache: BackendsCache;
74
+ }
75
+ | {
76
+ status: 'stale' | 'invalid';
77
+ path: string;
78
+ reason: string;
79
+ remediation: string;
80
+ };
45
81
 
46
82
  export async function probeBackends(opts: ProbeBackendsOptions): Promise<ProbeBackendsResult> {
47
83
  const assetRoot = opts.assetRoot ?? imprintHomeDir();
@@ -59,7 +95,36 @@ export async function probeBackends(opts: ProbeBackendsOptions): Promise<ProbeBa
59
95
  `No generated tool found for site "${opts.site}".\n${availableSitesHint(assetRoot, opts.site)}\n→ run \`imprint teach ${opts.site}\` or \`imprint emit ~/.imprint/${opts.site}/<toolName>/workflow.json\` first.`,
60
96
  );
61
97
  }
62
- const outPath = opts.outPath ?? pathResolve(tool.dir, 'backends.json');
98
+ return await probeResolvedTool(opts, assetRoot, tool, opts.outPath);
99
+ }
100
+
101
+ export async function probeAllBackends(
102
+ opts: Omit<ProbeBackendsOptions, 'outPath' | 'toolName'>,
103
+ ): Promise<ProbeBackendsResult[]> {
104
+ const assetRoot = opts.assetRoot ?? imprintHomeDir();
105
+ const discovered = await discoverTools(assetRoot, opts.site, '[imprint probe]');
106
+ if (discovered.length === 0) {
107
+ throw new Error(
108
+ `No generated tools found for site "${opts.site}".\n${availableSitesHint(assetRoot, opts.site)}\n→ run \`imprint teach ${opts.site}\` or \`imprint emit ~/.imprint/${opts.site}/<toolName>/workflow.json\` first.`,
109
+ );
110
+ }
111
+
112
+ const results: ProbeBackendsResult[] = [];
113
+ for (const tool of [...discovered].sort((a, b) =>
114
+ a.workflow.toolName.localeCompare(b.workflow.toolName),
115
+ )) {
116
+ results.push(await probeResolvedTool(opts, assetRoot, tool));
117
+ }
118
+ return results;
119
+ }
120
+
121
+ async function probeResolvedTool(
122
+ opts: Pick<ProbeBackendsOptions, 'site' | 'paramOverrides'>,
123
+ assetRoot: string,
124
+ tool: ResolvedTool,
125
+ explicitOutPath?: string,
126
+ ): Promise<ProbeBackendsResult> {
127
+ const outPath = explicitOutPath ?? pathResolve(tool.dir, 'backends.json');
63
128
 
64
129
  const params = resolveParams(tool, opts.paramOverrides);
65
130
 
@@ -72,59 +137,96 @@ export async function probeBackends(opts: ProbeBackendsOptions): Promise<ProbeBa
72
137
  // falls through fetch-bootstrap (~30-60s) before reaching the spliced-in
73
138
  // cdp-replay rung, wasting time on every call.
74
139
  const stealthCache = new Map<string, StealthFetch>();
140
+ const cdpPool = new Map<string, CdpBrowserFetch>();
75
141
  const allBackends: ConcreteBackend[] = workflowNeedsBootstrap(tool.workflow)
76
142
  ? ['fetch', 'fetch-bootstrap', 'cdp-replay', 'stealth-fetch', 'playbook']
77
143
  : ['fetch', 'stealth-fetch', 'playbook'];
78
144
  const results: BackendsCache['results'] = {};
79
- const working: ConcreteBackend[] = [];
80
-
81
- for (const backend of allBackends) {
82
- log(`probing ${backend}…`);
83
- const t0 = Date.now();
84
- const { result, attempts } = await runWithLadder(
85
- [backend],
86
- tool,
87
- params,
88
- assetRoot,
89
- stealthCache,
90
- );
91
- const durationMs = Date.now() - t0;
92
- const attempt = attempts[0];
145
+ const working: BackendProbeCandidate[] = [];
146
+ const preferredMaxMs = preferredBackendMaxMs();
93
147
 
94
- if (!attempt) {
95
- results[backend] = { outcome: 'skipped', detail: 'no attempt recorded' };
96
- continue;
97
- }
148
+ try {
149
+ for (const backend of allBackends) {
150
+ log(`probing ${backend}…`);
151
+ const t0 = Date.now();
152
+ const { result, attempts } = await runWithLadder(
153
+ [backend],
154
+ tool,
155
+ params,
156
+ assetRoot,
157
+ stealthCache,
158
+ backend === 'cdp-replay' ? { cdpPool, skipBootstrapSplice: true } : undefined,
159
+ );
160
+ const durationMs = Date.now() - t0;
161
+ const attempt = attempts[0];
98
162
 
99
- if (attempt.outcome === 'unavailable') {
100
- results[backend] = { outcome: 'unavailable', detail: attempt.detail };
101
- log(` ${backend}: unavailable (${attempt.detail})`);
102
- continue;
103
- }
163
+ if (!attempt) {
164
+ results[backend] = { outcome: 'skipped', detail: 'no attempt recorded' };
165
+ continue;
166
+ }
104
167
 
105
- if (result.ok) {
106
- results[backend] = { outcome: 'ok', durationMs };
107
- working.push(backend);
108
- log(` ${backend}: OK in ${durationMs}ms`);
109
- continue;
110
- }
168
+ if (attempt.outcome === 'unavailable') {
169
+ results[backend] = { outcome: 'unavailable', detail: attempt.detail };
170
+ log(` ${backend}: unavailable (${attempt.detail})`);
171
+ continue;
172
+ }
111
173
 
112
- if (result.error === 'FORBIDDEN') {
113
- results[backend] = {
114
- outcome: 'forbidden',
115
- durationMs,
116
- detail: result.message.slice(0, 200),
117
- };
118
- log(` ${backend}: FORBIDDEN`);
119
- } else {
120
- results[backend] = {
121
- outcome: 'failed',
122
- durationMs,
123
- error: result.error,
124
- detail: result.message.slice(0, 200),
125
- };
126
- log(` ${backend}: ${result.error} — ${result.message.slice(0, 100)}`);
174
+ if (result.ok) {
175
+ const warm =
176
+ backend === 'cdp-replay'
177
+ ? await probeWarmCdpReplay(tool, params, assetRoot, stealthCache, cdpPool)
178
+ : null;
179
+ const tooSlow = durationMs > preferredMaxMs;
180
+ const rankingDurationMs = warm?.ok ? warm.durationMs : durationMs;
181
+ const detailParts: string[] = [];
182
+ if (tooSlow)
183
+ detailParts.push(`cold start exceeded preferred backend threshold ${preferredMaxMs}ms`);
184
+ if (warm?.ok) detailParts.push(`warm cdp-replay succeeded in ${warm.durationMs}ms`);
185
+ else if (warm) detailParts.push(`warm cdp-replay failed: ${warm.detail}`);
186
+ results[backend] = {
187
+ outcome: 'ok',
188
+ durationMs,
189
+ ...(backend === 'cdp-replay'
190
+ ? {
191
+ coldDurationMs: durationMs,
192
+ ...(warm?.ok ? { warmDurationMs: warm.durationMs, rankingDurationMs } : {}),
193
+ }
194
+ : {}),
195
+ ...(tooSlow ? { tooSlow: true } : {}),
196
+ ...(detailParts.length ? { detail: detailParts.join('; ') } : {}),
197
+ };
198
+ working.push({
199
+ backend,
200
+ durationMs,
201
+ ...(backend === 'cdp-replay' ? { coldDurationMs: durationMs } : {}),
202
+ ...(warm?.ok ? { warmDurationMs: warm.durationMs, rankingDurationMs } : {}),
203
+ tooSlow,
204
+ });
205
+ log(
206
+ ` ${backend}: OK in ${durationMs}ms${warm?.ok ? ` (warm ${warm.durationMs}ms)` : ''}${tooSlow ? ' (cold slow)' : ''}`,
207
+ );
208
+ continue;
209
+ }
210
+
211
+ if (result.error === 'FORBIDDEN') {
212
+ results[backend] = {
213
+ outcome: 'forbidden',
214
+ durationMs,
215
+ detail: result.message.slice(0, 200),
216
+ };
217
+ log(` ${backend}: FORBIDDEN`);
218
+ } else {
219
+ results[backend] = {
220
+ outcome: 'failed',
221
+ durationMs,
222
+ error: result.error,
223
+ detail: result.message.slice(0, 200),
224
+ };
225
+ log(` ${backend}: ${result.error} — ${result.message.slice(0, 100)}`);
226
+ }
127
227
  }
228
+ } finally {
229
+ await closeProbeCdpPool(cdpPool);
128
230
  }
129
231
 
130
232
  if (working.length === 0) {
@@ -135,23 +237,92 @@ export async function probeBackends(opts: ProbeBackendsOptions): Promise<ProbeBa
135
237
  );
136
238
  }
137
239
 
240
+ const preferredOrder = rankSuccessfulBackends(working);
138
241
  const cache: BackendsCache = {
139
242
  probedAt: new Date().toISOString(),
140
243
  imprintVersion: VERSION,
141
244
  schemaVersion: 2,
142
245
  workflowHash: workflowHash(tool.workflow),
143
246
  capabilityHash: capabilityHash(tool.workflow),
144
- preferredOrder: working,
247
+ preferredOrder,
145
248
  results,
146
249
  };
147
250
  BackendsCacheSchema.parse(cache); // catch schema drift early
148
251
 
149
252
  writeFileSync(outPath, `${JSON.stringify(cache, null, 2)}\n`);
150
- log(`wrote ${outPath} — preferred: ${working.join(' → ')}`);
253
+ log(`wrote ${outPath} — preferred: ${preferredOrder.join(' → ')}`);
151
254
 
152
255
  return { cache, outPath };
153
256
  }
154
257
 
258
+ export function rankSuccessfulBackends(candidates: BackendProbeCandidate[]): ConcreteBackend[] {
259
+ return [...candidates]
260
+ .sort((a, b) => {
261
+ if (a.tooSlow !== b.tooSlow) return a.tooSlow ? 1 : -1;
262
+ return effectiveRankingDuration(a) - effectiveRankingDuration(b);
263
+ })
264
+ .map((c) => c.backend);
265
+ }
266
+
267
+ function effectiveRankingDuration(candidate: BackendProbeCandidate): number {
268
+ return candidate.rankingDurationMs ?? candidate.warmDurationMs ?? candidate.durationMs;
269
+ }
270
+
271
+ function backendResultTooSlow(result: BackendsCache['results'][string] | undefined): boolean {
272
+ return result?.outcome === 'ok' && result.tooSlow === true;
273
+ }
274
+
275
+ function invalidPreferredOrderReason(cache: BackendsCache): string | null {
276
+ for (const backend of cache.preferredOrder) {
277
+ const result = cache.results[backend];
278
+ if (backend === 'playbook' && result?.outcome !== 'ok') {
279
+ return 'preferredOrder includes playbook without a successful playbook result';
280
+ }
281
+ if (result && result.outcome !== 'ok') {
282
+ return `preferredOrder includes ${backend} with ${result.outcome} result`;
283
+ }
284
+ }
285
+ return null;
286
+ }
287
+
288
+ function existingBackendUsable(
289
+ backend: ConcreteBackend,
290
+ result: BackendsCache['results'][string] | undefined,
291
+ ): boolean {
292
+ if (!result) return backend !== 'playbook';
293
+ return result.outcome === 'ok';
294
+ }
295
+
296
+ async function probeWarmCdpReplay(
297
+ tool: ResolvedTool,
298
+ params: Record<string, string | number | boolean>,
299
+ assetRoot: string,
300
+ stealthCache: Map<string, StealthFetch>,
301
+ cdpPool: Map<string, CdpBrowserFetch>,
302
+ ): Promise<{ ok: true; durationMs: number } | { ok: false; detail: string } | null> {
303
+ if (!cdpPool.has(tool.site)) return null;
304
+ log('probing cdp-replay warm reuse…');
305
+ const t0 = Date.now();
306
+ const { result } = await runWithLadder(['cdp-replay'], tool, params, assetRoot, stealthCache, {
307
+ cdpPool,
308
+ skipBootstrapSplice: true,
309
+ });
310
+ const durationMs = Date.now() - t0;
311
+ if (result.ok) return { ok: true, durationMs };
312
+ return { ok: false, detail: `${result.error}: ${result.message.slice(0, 160)}` };
313
+ }
314
+
315
+ async function closeProbeCdpPool(cdpPool: Map<string, CdpBrowserFetch>): Promise<void> {
316
+ const sessions = [...cdpPool.values()];
317
+ cdpPool.clear();
318
+ await Promise.allSettled(sessions.map((session) => session.close()));
319
+ }
320
+
321
+ function preferredBackendMaxMs(): number {
322
+ const raw = Number(process.env.IMPRINT_BACKEND_PREFERRED_MAX_MS ?? DEFAULT_PREFERRED_MAX_MS);
323
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_PREFERRED_MAX_MS;
324
+ }
325
+
155
326
  function workflowNeedsBootstrap(workflow: ResolvedTool['workflow']): boolean {
156
327
  if (workflow.bootstrap) return true;
157
328
  return workflow.requests.some((r) =>
@@ -177,16 +348,19 @@ function capabilityHash(workflow: ResolvedTool['workflow']): string {
177
348
  return createHash('sha256').update(JSON.stringify(caps)).digest('hex');
178
349
  }
179
350
 
180
- /** Read backends.json. Returns null on missing/malformed runtime
181
- * falls back to the default ladder; a stale cache must never break cron. */
182
- export function loadBackendsCache(
351
+ /** Read backends.json with status information. Runtime can still fall back to
352
+ * the default ladder, while status commands can explain why a cache was not
353
+ * usable. */
354
+ export function loadBackendsCacheStatus(
183
355
  site: string,
184
356
  _assetRoot: string,
185
357
  toolDir?: string,
186
- ): BackendsCache | null {
187
- if (!toolDir) return null;
358
+ opts: { warn?: boolean; toolName?: string } = {},
359
+ ): BackendsCacheStatus {
360
+ const remediation = backendsCacheRemediation(site, opts.toolName ?? toolDirName(toolDir));
361
+ if (!toolDir) return { status: 'missing', path: null, remediation };
188
362
  const path = pathResolve(toolDir, 'backends.json');
189
- if (!existsSync(path)) return null;
363
+ if (!existsSync(path)) return { status: 'missing', path, remediation };
190
364
  try {
191
365
  const raw = JSON.parse(readFileSync(path, 'utf8'));
192
366
  const parsed = BackendsCacheSchema.parse(raw);
@@ -195,20 +369,131 @@ export function loadBackendsCache(
195
369
  if (existsSync(workflowPath)) {
196
370
  const currentHash = workflowHashSync(readFileSync(workflowPath, 'utf8'));
197
371
  if (currentHash !== parsed.workflowHash) {
198
- process.stderr.write(
199
- `[imprint] backends.json at ${path} is stale for current workflow — ignoring (run \`imprint probe-backends ${site}\` to regenerate)\n`,
200
- );
201
- return null;
372
+ const reason = 'workflow hash changed';
373
+ if (opts.warn !== false) {
374
+ process.stderr.write(
375
+ `[imprint] backends.json at ${path} is stale for current workflow — ignoring (run \`${remediation}\` to regenerate)\n`,
376
+ );
377
+ }
378
+ return { status: 'stale', path, reason, remediation };
202
379
  }
203
380
  }
204
381
  }
205
- return parsed;
382
+ const invalidPreferredReason = invalidPreferredOrderReason(parsed);
383
+ if (invalidPreferredReason) {
384
+ if (opts.warn !== false) {
385
+ process.stderr.write(
386
+ `[imprint] backends.json at ${path} has unsafe preferred backends — ignoring (run \`${remediation}\` to regenerate): ${invalidPreferredReason}\n`,
387
+ );
388
+ }
389
+ return { status: 'invalid', path, reason: invalidPreferredReason, remediation };
390
+ }
391
+ return { status: 'ok', path, cache: parsed };
206
392
  } catch (err) {
207
- process.stderr.write(
208
- `[imprint] backends.json at ${path} failed to parse — ignoring (run \`imprint probe-backends ${site}\` to regenerate): ${err instanceof Error ? err.message : String(err)}\n`,
209
- );
210
- return null;
393
+ const reason = err instanceof Error ? err.message : String(err);
394
+ if (opts.warn !== false) {
395
+ process.stderr.write(
396
+ `[imprint] backends.json at ${path} failed to parse — ignoring (run \`${remediation}\` to regenerate): ${reason}\n`,
397
+ );
398
+ }
399
+ return { status: 'invalid', path, reason, remediation };
400
+ }
401
+ }
402
+
403
+ /** Read backends.json. Returns null on missing/malformed — runtime
404
+ * falls back to the default ladder; a stale cache must never break cron. */
405
+ export function loadBackendsCache(
406
+ site: string,
407
+ _assetRoot: string,
408
+ toolDir?: string,
409
+ ): BackendsCache | null {
410
+ const status = loadBackendsCacheStatus(site, _assetRoot, toolDir);
411
+ return status.status === 'ok' ? status.cache : null;
412
+ }
413
+
414
+ export function persistRuntimeBackendsCache(opts: {
415
+ tool: ResolvedTool;
416
+ assetRoot: string;
417
+ usedBackend: ConcreteBackend;
418
+ attempts: BackendRuntimeAttempt[];
419
+ }): BackendsCache | null {
420
+ const status = loadBackendsCacheStatus(opts.tool.site, opts.assetRoot, opts.tool.dir, {
421
+ warn: false,
422
+ toolName: opts.tool.workflow.toolName,
423
+ });
424
+ const results: BackendsCache['results'] =
425
+ status.status === 'ok' ? { ...status.cache.results } : {};
426
+
427
+ for (const attempt of opts.attempts) {
428
+ if (attempt.outcome === 'ok') {
429
+ const tooSlow = attempt.durationMs > preferredBackendMaxMs();
430
+ results[attempt.backend] = {
431
+ outcome: 'ok',
432
+ durationMs: attempt.durationMs,
433
+ ...(tooSlow
434
+ ? {
435
+ tooSlow: true,
436
+ detail: `exceeded preferred backend threshold ${preferredBackendMaxMs()}ms`,
437
+ }
438
+ : {}),
439
+ };
440
+ } else if (attempt.outcome === 'unavailable') {
441
+ results[attempt.backend] = { outcome: 'unavailable', detail: attempt.detail };
442
+ } else if (attempt.detail.startsWith('FORBIDDEN:')) {
443
+ results[attempt.backend] = {
444
+ outcome: 'forbidden',
445
+ durationMs: attempt.durationMs,
446
+ detail: attempt.detail.slice(0, 200),
447
+ };
448
+ } else {
449
+ const error = attempt.detail.split(':')[0] || 'UNKNOWN';
450
+ results[attempt.backend] = {
451
+ outcome: 'failed',
452
+ durationMs: attempt.durationMs,
453
+ error,
454
+ detail: attempt.detail.slice(0, 200),
455
+ };
456
+ }
211
457
  }
458
+
459
+ const existingPreferred = status.status === 'ok' ? status.cache.preferredOrder : [];
460
+ const observedOkAttempts = opts.attempts
461
+ .filter((a) => a.outcome === 'ok')
462
+ .sort((a, b) => a.durationMs - b.durationMs);
463
+ const observedOk = observedOkAttempts.map((a) => a.backend);
464
+ const slowObservedOk = observedOkAttempts
465
+ .filter((a) => a.durationMs > preferredBackendMaxMs())
466
+ .map((a) => a.backend);
467
+ const fastObservedOk = observedOk.filter((backend) => !slowObservedOk.includes(backend));
468
+ const usedOkAttempt = observedOkAttempts.find((a) => a.backend === opts.usedBackend);
469
+ const usedBackendTooSlow =
470
+ usedOkAttempt !== undefined && usedOkAttempt.durationMs > preferredBackendMaxMs();
471
+ const existingUsable = existingPreferred.filter((backend) =>
472
+ existingBackendUsable(backend, results[backend]),
473
+ );
474
+ const existingFast = existingUsable.filter((backend) => !backendResultTooSlow(results[backend]));
475
+ const existingSlow = existingUsable.filter((backend) => backendResultTooSlow(results[backend]));
476
+ const preferredOrder = uniqueBackends([
477
+ ...(usedOkAttempt && !usedBackendTooSlow ? [opts.usedBackend] : []),
478
+ ...existingFast,
479
+ ...fastObservedOk,
480
+ ...existingSlow,
481
+ ...slowObservedOk,
482
+ ...(usedOkAttempt && usedBackendTooSlow ? [opts.usedBackend] : []),
483
+ ]);
484
+ const cache: BackendsCache = {
485
+ probedAt: new Date().toISOString(),
486
+ imprintVersion: VERSION,
487
+ schemaVersion: 2,
488
+ workflowHash: workflowHash(opts.tool.workflow),
489
+ capabilityHash: capabilityHash(opts.tool.workflow),
490
+ preferredOrder,
491
+ results,
492
+ };
493
+
494
+ BackendsCacheSchema.parse(cache);
495
+ writeFileSync(pathResolve(opts.tool.dir, 'backends.json'), `${JSON.stringify(cache, null, 2)}\n`);
496
+ return cache;
212
497
  }
213
498
 
214
499
  function workflowHashSync(workflowJson: string): string {
@@ -217,6 +502,27 @@ function workflowHashSync(workflowJson: string): string {
217
502
  .digest('hex');
218
503
  }
219
504
 
505
+ function backendsCacheRemediation(site: string, toolName?: string): string {
506
+ return toolName
507
+ ? `imprint probe-backends ${site} --tool ${toolName}`
508
+ : `imprint probe-backends ${site}`;
509
+ }
510
+
511
+ function toolDirName(toolDir?: string): string | undefined {
512
+ return toolDir ? basename(toolDir) : undefined;
513
+ }
514
+
515
+ function uniqueBackends(backends: ConcreteBackend[]): ConcreteBackend[] {
516
+ const seen = new Set<ConcreteBackend>();
517
+ const out: ConcreteBackend[] = [];
518
+ for (const backend of backends) {
519
+ if (seen.has(backend)) continue;
520
+ seen.add(backend);
521
+ out.push(backend);
522
+ }
523
+ return out;
524
+ }
525
+
220
526
  /** Param priority: caller overrides → cron.json → workflow defaults. */
221
527
  function resolveParams(
222
528
  tool: ResolvedTool,
@@ -380,6 +380,18 @@ const BackendProbeResultSchema = z.discriminatedUnion('outcome', [
380
380
  z.object({
381
381
  outcome: z.literal('ok'),
382
382
  durationMs: z.number(),
383
+ /** Optional cdp-replay cold-start measurement. `durationMs` remains the
384
+ * first-call duration for backward compatibility. */
385
+ coldDurationMs: z.number().optional(),
386
+ /** Optional cdp-replay warm-pool measurement from a second call against the
387
+ * same pooled Chrome. Used to explain why CDP may outrank stealth when its
388
+ * cold start is still under the operator timeout. */
389
+ warmDurationMs: z.number().optional(),
390
+ /** Effective duration used for preference ranking when it differs from the
391
+ * first-call duration, e.g. warm cdp-replay. */
392
+ rankingDurationMs: z.number().optional(),
393
+ tooSlow: z.boolean().optional(),
394
+ detail: z.string().optional(),
383
395
  }),
384
396
  z.object({
385
397
  outcome: z.literal('forbidden'),
@@ -0,0 +1,73 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import semver from 'semver';
3
+ import { VERSION } from './version.ts';
4
+
5
+ const PACKAGE_NAME = 'imprint-mcp';
6
+ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
7
+
8
+ interface UpdateCheckResult {
9
+ current: string;
10
+ latest: string;
11
+ updateAvailable: boolean;
12
+ }
13
+
14
+ interface UpdateResult {
15
+ ok: boolean;
16
+ from: string;
17
+ to: string;
18
+ error?: string;
19
+ }
20
+
21
+ export async function checkForUpdate(): Promise<UpdateCheckResult | null> {
22
+ try {
23
+ const res = await fetch(REGISTRY_URL, {
24
+ headers: { accept: 'application/json' },
25
+ signal: AbortSignal.timeout(5_000),
26
+ });
27
+ if (!res.ok) return null;
28
+ const data = (await res.json()) as { version?: string };
29
+ const latest = data.version;
30
+ if (!latest) return null;
31
+ return { current: VERSION, latest, updateAvailable: semver.gt(latest, VERSION) };
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ const IS_COMPILED = typeof (globalThis as Record<string, unknown>).__IMPRINT_VERSION__ === 'string';
38
+
39
+ export async function performUpdate(): Promise<UpdateResult> {
40
+ const check = await checkForUpdate();
41
+ if (!check) {
42
+ return { ok: false, from: VERSION, to: VERSION, error: 'could not reach npm registry' };
43
+ }
44
+ if (!check.updateAvailable) {
45
+ return { ok: true, from: VERSION, to: VERSION };
46
+ }
47
+
48
+ const result = IS_COMPILED
49
+ ? spawnSync(
50
+ 'bash',
51
+ [
52
+ '-c',
53
+ 'curl -fsSL https://raw.githubusercontent.com/ashaychangwani/imprint/main/scripts/install.sh | bash',
54
+ ],
55
+ { stdio: 'pipe', timeout: 60_000 },
56
+ )
57
+ : spawnSync('bun', ['install', '-g', `${PACKAGE_NAME}@latest`], {
58
+ stdio: 'pipe',
59
+ timeout: 60_000,
60
+ });
61
+
62
+ if (result.status !== 0) {
63
+ const stderr = result.stderr?.toString().trim();
64
+ return {
65
+ ok: false,
66
+ from: check.current,
67
+ to: check.latest,
68
+ error: stderr || result.error?.message || `install exited with code ${result.status}`,
69
+ };
70
+ }
71
+
72
+ return { ok: true, from: check.current, to: check.latest };
73
+ }