imprint-mcp 0.2.1 → 0.3.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.
Files changed (126) hide show
  1. package/README.md +165 -201
  2. package/examples/discoverandgo/README.md +1 -1
  3. package/examples/echo/README.md +1 -1
  4. package/examples/google-flights/README.md +28 -0
  5. package/examples/google-flights/_shared/batchexecute.ts +63 -0
  6. package/examples/google-flights/_shared/flights_request.ts +95 -0
  7. package/examples/google-flights/_shared/package.json +9 -0
  8. package/examples/google-flights/get_flight_booking_details/index.ts +159 -0
  9. package/examples/google-flights/get_flight_booking_details/package.json +9 -0
  10. package/examples/google-flights/get_flight_booking_details/parser.ts +182 -0
  11. package/examples/google-flights/get_flight_booking_details/playbook.yaml +138 -0
  12. package/examples/google-flights/get_flight_booking_details/request-transform.ts +86 -0
  13. package/examples/google-flights/get_flight_booking_details/workflow.json +98 -0
  14. package/examples/google-flights/get_flight_calendar_prices/index.ts +131 -0
  15. package/examples/google-flights/get_flight_calendar_prices/package.json +9 -0
  16. package/examples/google-flights/get_flight_calendar_prices/parser.ts +86 -0
  17. package/examples/google-flights/get_flight_calendar_prices/playbook.yaml +97 -0
  18. package/examples/google-flights/get_flight_calendar_prices/request-transform.ts +31 -0
  19. package/examples/google-flights/get_flight_calendar_prices/workflow.json +76 -0
  20. package/examples/google-flights/lookup_airport/index.ts +101 -0
  21. package/examples/google-flights/lookup_airport/package.json +9 -0
  22. package/examples/google-flights/lookup_airport/parser.ts +66 -0
  23. package/examples/google-flights/lookup_airport/playbook.yaml +47 -0
  24. package/examples/google-flights/lookup_airport/request-transform.ts +20 -0
  25. package/examples/google-flights/lookup_airport/workflow.json +57 -0
  26. package/examples/google-flights/search_flights/index.ts +219 -0
  27. package/examples/google-flights/search_flights/package.json +9 -0
  28. package/examples/google-flights/search_flights/parser.ts +169 -0
  29. package/examples/google-flights/search_flights/playbook.yaml +184 -0
  30. package/examples/google-flights/search_flights/request-transform.ts +119 -0
  31. package/examples/google-flights/search_flights/workflow.json +143 -0
  32. package/examples/google-hotels/README.md +29 -0
  33. package/examples/google-hotels/_shared/batchexecute.ts +73 -0
  34. package/examples/google-hotels/_shared/freq.ts +158 -0
  35. package/examples/google-hotels/_shared/package.json +9 -0
  36. package/examples/google-hotels/autocomplete_hotel_location/index.ts +80 -0
  37. package/examples/google-hotels/autocomplete_hotel_location/package.json +9 -0
  38. package/examples/google-hotels/autocomplete_hotel_location/parser.ts +71 -0
  39. package/examples/google-hotels/autocomplete_hotel_location/playbook.yaml +36 -0
  40. package/examples/google-hotels/autocomplete_hotel_location/request-transform.ts +37 -0
  41. package/examples/google-hotels/autocomplete_hotel_location/workflow.json +36 -0
  42. package/examples/google-hotels/get_hotel_booking_options/index.ts +143 -0
  43. package/examples/google-hotels/get_hotel_booking_options/package.json +9 -0
  44. package/examples/google-hotels/get_hotel_booking_options/parser.ts +271 -0
  45. package/examples/google-hotels/get_hotel_booking_options/playbook.yaml +154 -0
  46. package/examples/google-hotels/get_hotel_booking_options/request-transform.ts +154 -0
  47. package/examples/google-hotels/get_hotel_booking_options/workflow.json +84 -0
  48. package/examples/google-hotels/get_hotel_reviews/index.ts +81 -0
  49. package/examples/google-hotels/get_hotel_reviews/package.json +9 -0
  50. package/examples/google-hotels/get_hotel_reviews/parser.ts +128 -0
  51. package/examples/google-hotels/get_hotel_reviews/playbook.yaml +64 -0
  52. package/examples/google-hotels/get_hotel_reviews/request-transform.ts +42 -0
  53. package/examples/google-hotels/get_hotel_reviews/workflow.json +37 -0
  54. package/examples/google-hotels/search_hotels/index.ts +207 -0
  55. package/examples/google-hotels/search_hotels/package.json +9 -0
  56. package/examples/google-hotels/search_hotels/parser.ts +260 -0
  57. package/examples/google-hotels/search_hotels/playbook.yaml +87 -0
  58. package/examples/google-hotels/search_hotels/request-transform.ts +197 -0
  59. package/examples/google-hotels/search_hotels/workflow.json +127 -0
  60. package/package.json +3 -2
  61. package/prompts/audit-agent.md +71 -0
  62. package/prompts/build-planning.md +74 -0
  63. package/prompts/compile-agent.md +131 -27
  64. package/prompts/prereq-builder.md +64 -0
  65. package/prompts/prereq-planner.md +34 -0
  66. package/prompts/tool-planning.md +39 -0
  67. package/src/cli.ts +109 -2
  68. package/src/imprint/agent.ts +5 -0
  69. package/src/imprint/audit.ts +996 -0
  70. package/src/imprint/backend-ladder.ts +1214 -184
  71. package/src/imprint/build-plan.ts +1051 -0
  72. package/src/imprint/cdp-browser-fetch.ts +589 -0
  73. package/src/imprint/cdp-jar-cache.ts +320 -0
  74. package/src/imprint/chromium.ts +135 -0
  75. package/src/imprint/claude-cli-compile.ts +125 -25
  76. package/src/imprint/codex-cli-compile.ts +26 -23
  77. package/src/imprint/compile-agent-types.ts +38 -0
  78. package/src/imprint/compile-agent.ts +63 -25
  79. package/src/imprint/compile-tools.ts +1656 -64
  80. package/src/imprint/compile.ts +13 -1
  81. package/src/imprint/concurrency.ts +87 -0
  82. package/src/imprint/cron.ts +1 -0
  83. package/src/imprint/doctor.ts +39 -0
  84. package/src/imprint/freeform-redact.ts +5 -4
  85. package/src/imprint/integrations.ts +2 -2
  86. package/src/imprint/llm.ts +56 -8
  87. package/src/imprint/mcp-compile-server.ts +43 -10
  88. package/src/imprint/mcp-maintenance.ts +9 -101
  89. package/src/imprint/mcp-server.ts +73 -7
  90. package/src/imprint/multi-progress.ts +7 -2
  91. package/src/imprint/param-grounding.ts +367 -0
  92. package/src/imprint/paths.ts +29 -0
  93. package/src/imprint/playbook-runner.ts +101 -40
  94. package/src/imprint/prereq-builder.ts +651 -0
  95. package/src/imprint/probe-backends.ts +6 -3
  96. package/src/imprint/record.ts +10 -1
  97. package/src/imprint/redact.ts +30 -2
  98. package/src/imprint/replay-capture.ts +19 -18
  99. package/src/imprint/runtime.ts +19 -10
  100. package/src/imprint/session-diff.ts +79 -2
  101. package/src/imprint/session-merge.ts +9 -5
  102. package/src/imprint/stealth-chromium.ts +81 -0
  103. package/src/imprint/stealth-fetch.ts +309 -29
  104. package/src/imprint/stealth-token-cache.ts +88 -0
  105. package/src/imprint/teach-plan.ts +251 -0
  106. package/src/imprint/teach-state.ts +10 -0
  107. package/src/imprint/teach.ts +456 -142
  108. package/src/imprint/tool-candidates.ts +72 -14
  109. package/src/imprint/tool-plan.ts +313 -0
  110. package/src/imprint/tracing.ts +135 -6
  111. package/src/imprint/types.ts +61 -3
  112. package/examples/google-flights/search_google_flights/index.ts +0 -101
  113. package/examples/google-flights/search_google_flights/parser.test.ts +0 -140
  114. package/examples/google-flights/search_google_flights/parser.ts +0 -189
  115. package/examples/google-flights/search_google_flights/playbook.yaml +0 -130
  116. package/examples/google-flights/search_google_flights/workflow.json +0 -48
  117. package/examples/google-hotels/search_google_hotels/index.ts +0 -194
  118. package/examples/google-hotels/search_google_hotels/parser.test.ts +0 -168
  119. package/examples/google-hotels/search_google_hotels/parser.ts +0 -330
  120. package/examples/google-hotels/search_google_hotels/playbook.yaml +0 -125
  121. package/examples/google-hotels/search_google_hotels/workflow.json +0 -111
  122. package/examples/namecheap-domains/search_namecheap_domains/index.ts +0 -144
  123. package/examples/namecheap-domains/search_namecheap_domains/parser.ts +0 -380
  124. package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +0 -50
  125. package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +0 -136
  126. package/examples/namecheap-domains/search_namecheap_domains/workflow.json +0 -97
@@ -28,7 +28,6 @@ import * as p from '@clack/prompts';
28
28
  import YAML from 'yaml';
29
29
  import { imprintHomeDir, localSiteDir } from './paths.ts';
30
30
  import {
31
- type TeachState,
32
31
  type WorkflowState,
33
32
  loadTeachState,
34
33
  resolveTeachStatePath,
@@ -38,7 +37,7 @@ import {
38
37
 
39
38
  type McpClient = 'claude-code' | 'codex' | 'claude-desktop' | 'openclaw' | 'hermes';
40
39
  type LocalDeleteMode = 'none' | 'tool' | 'site';
41
- type IssueKind = 'incomplete' | 'missing-session' | 'orphan-session' | 'stale-registration';
40
+ type IssueKind = 'incomplete' | 'missing-session' | 'stale-registration';
42
41
 
43
42
  const CLIENTS: McpClient[] = ['claude-code', 'codex', 'claude-desktop', 'openclaw', 'hermes'];
44
43
  const DISABLED_STORE_VERSION = 1;
@@ -100,7 +99,6 @@ interface LocalSiteStatus {
100
99
  dir: string;
101
100
  tools: LocalToolStatus[];
102
101
  workflows: LocalWorkflowStatus[];
103
- orphanSessions: string[];
104
102
  }
105
103
 
106
104
  interface McpIssue {
@@ -393,22 +391,6 @@ async function runInteractiveIssueFix(status: McpStatus, ctx: MaintenanceContext
393
391
  return;
394
392
  }
395
393
 
396
- const orphanIssues = indices
397
- .map((i) => status.issues[i])
398
- .filter(
399
- (issue): issue is McpIssue => !!issue && issue.kind === 'orphan-session' && !!issue.path,
400
- );
401
-
402
- let deleteOrphans = true;
403
- if (orphanIssues.length > 0) {
404
- const confirm = await p.confirm({
405
- message: `Delete ${orphanIssues.length} orphan session file${orphanIssues.length === 1 ? '' : 's'}?`,
406
- initialValue: false,
407
- });
408
- if (p.isCancel(confirm)) return;
409
- deleteOrphans = confirm === true;
410
- }
411
-
412
394
  const aggregate: MutationResult = { changed: [], skipped: [] };
413
395
  for (const index of indices) {
414
396
  const issue = status.issues[index];
@@ -416,17 +398,12 @@ async function runInteractiveIssueFix(status: McpStatus, ctx: MaintenanceContext
416
398
  appendMutation(aggregate, { changed: [], skipped: ['selection disappeared'] });
417
399
  continue;
418
400
  }
419
- appendMutation(aggregate, fixIssue(issue, status, ctx, { deleteOrphans }));
401
+ appendMutation(aggregate, fixIssue(issue, status, ctx));
420
402
  }
421
403
  reportMutation(aggregate);
422
404
  }
423
405
 
424
- function fixIssue(
425
- issue: McpIssue,
426
- status: McpStatus,
427
- ctx: MaintenanceContext,
428
- opts: { deleteOrphans: boolean },
429
- ): MutationResult {
406
+ function fixIssue(issue: McpIssue, status: McpStatus, ctx: MaintenanceContext): MutationResult {
430
407
  if (issue.kind === 'stale-registration') {
431
408
  const reg = status.registrations.find(
432
409
  (r) =>
@@ -446,13 +423,6 @@ function fixIssue(
446
423
  return pruneSingleTeachWorkflow(issue.site, issue.workflow);
447
424
  }
448
425
 
449
- if (issue.kind === 'orphan-session' && issue.path) {
450
- if (!opts.deleteOrphans) {
451
- return { changed: [], skipped: [`kept orphan session ${issue.path}`] };
452
- }
453
- return deleteOrphanSessionFile(issue.path);
454
- }
455
-
456
426
  return { changed: [], skipped: [`no automatic fix for ${issue.kind}`] };
457
427
  }
458
428
 
@@ -463,7 +433,7 @@ async function runInteractiveLocalDelete(status: McpStatus): Promise<void> {
463
433
  const complete = s.tools.filter((t) => t.complete).length;
464
434
  return {
465
435
  value: s.site,
466
- label: `${s.site} (${complete} complete tool${complete === 1 ? '' : 's'}, ${s.orphanSessions.length} orphan session${s.orphanSessions.length === 1 ? '' : 's'})`,
436
+ label: `${s.site} (${complete} complete tool${complete === 1 ? '' : 's'})`,
467
437
  };
468
438
  }),
469
439
  });
@@ -686,7 +656,7 @@ function formatMcpStatus(status: McpStatus): string {
686
656
  const incomplete = s.workflows.filter((w) => w.incomplete).length;
687
657
  const missing = s.workflows.filter((w) => w.missingSession).length;
688
658
  lines.push(
689
- ` ${s.site}: ${complete} complete tool${complete === 1 ? '' : 's'}, ${incomplete} incomplete workflow${incomplete === 1 ? '' : 's'}, ${missing} missing-session issue${missing === 1 ? '' : 's'}, ${s.orphanSessions.length} orphan session${s.orphanSessions.length === 1 ? '' : 's'}`,
659
+ ` ${s.site}: ${complete} complete tool${complete === 1 ? '' : 's'}, ${incomplete} incomplete workflow${incomplete === 1 ? '' : 's'}, ${missing} missing-session issue${missing === 1 ? '' : 's'}`,
690
660
  );
691
661
  }
692
662
  }
@@ -714,8 +684,6 @@ function issueFixHint(issue: McpIssue): string | null {
714
684
  return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --incomplete --yes`;
715
685
  case 'missing-session':
716
686
  return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --missing-session --yes`;
717
- case 'orphan-session':
718
- return 'choose "Fix an issue" to delete this recording, or keep it if you still need it';
719
687
  }
720
688
  return null;
721
689
  }
@@ -737,12 +705,12 @@ function scanLocalSites(ctx: MaintenanceContext): LocalSiteStatus[] {
737
705
  if (entry === 'node_modules' || entry.startsWith('.')) continue;
738
706
  const dir = pathJoin(ctx.imprintHome, entry);
739
707
  if (!safeIsDir(dir)) continue;
740
- sites.push(scanLocalSite(ctx, entry, dir));
708
+ sites.push(scanLocalSite(entry, dir));
741
709
  }
742
710
  return sites;
743
711
  }
744
712
 
745
- function scanLocalSite(ctx: MaintenanceContext, site: string, dir: string): LocalSiteStatus {
713
+ function scanLocalSite(site: string, dir: string): LocalSiteStatus {
746
714
  const tools: LocalToolStatus[] = [];
747
715
  for (const entry of readdirSync(dir).sort()) {
748
716
  if (entry === 'sessions' || entry === '_shared' || entry.startsWith('.')) continue;
@@ -764,12 +732,8 @@ function scanLocalSite(ctx: MaintenanceContext, site: string, dir: string): Loca
764
732
  const workflows = Object.entries(state.workflows)
765
733
  .map(([name, ws]) => workflowStatus(site, name, ws, tools))
766
734
  .sort((a, b) => a.name.localeCompare(b.name));
767
- const referenced = referencedSessionPaths(site, state);
768
- const orphanSessions = discoverSessionFiles(pathJoin(dir, 'sessions')).filter(
769
- (session) => !isReferencedSessionFile(site, session, ctx, referenced),
770
- );
771
735
 
772
- return { site, dir, tools, workflows, orphanSessions };
736
+ return { site, dir, tools, workflows };
773
737
  }
774
738
 
775
739
  function workflowStatus(
@@ -813,47 +777,6 @@ function workflowJsonToolName(toolDir: string): string | null {
813
777
  }
814
778
  }
815
779
 
816
- function referencedSessionPaths(site: string, state: TeachState): Set<string> {
817
- const out = new Set<string>();
818
- for (const ws of Object.values(state.workflows)) {
819
- for (const stored of [ws.sessionPath, ws.redactedPath, ws.triagedPath]) {
820
- if (!stored) continue;
821
- out.add(stored);
822
- const resolved = resolveTeachStatePath(site, stored);
823
- if (resolved) out.add(resolved);
824
- }
825
- }
826
- return out;
827
- }
828
-
829
- function discoverSessionFiles(sessionDir: string): string[] {
830
- if (!existsSync(sessionDir)) return [];
831
- return readdirSync(sessionDir)
832
- .filter((f) => (f.endsWith('.json') || f.endsWith('.jsonl')) && !f.includes('.triaged'))
833
- .map((f) => pathJoin(sessionDir, f))
834
- .sort();
835
- }
836
-
837
- function isReferencedSessionFile(
838
- site: string,
839
- absolutePath: string,
840
- ctx: MaintenanceContext,
841
- referenced: Set<string>,
842
- ): boolean {
843
- const candidates = [absolutePath, relativeToSite(site, absolutePath, ctx)];
844
- if (absolutePath.endsWith('.jsonl')) {
845
- const jsonPath = absolutePath.replace(/\.jsonl$/, '.json');
846
- candidates.push(jsonPath, relativeToSite(site, jsonPath, ctx));
847
- }
848
- return candidates.some((candidate) => referenced.has(candidate));
849
- }
850
-
851
- function relativeToSite(site: string, absolutePath: string, ctx: MaintenanceContext): string {
852
- const siteDir = pathJoin(ctx.imprintHome, site);
853
- const prefix = `${siteDir}/`;
854
- return absolutePath.startsWith(prefix) ? absolutePath.slice(prefix.length) : absolutePath;
855
- }
856
-
857
780
  function collectIssues(opts: {
858
781
  registrations: McpRegistration[];
859
782
  sites: LocalSiteStatus[];
@@ -881,14 +804,6 @@ function collectIssues(opts: {
881
804
  });
882
805
  }
883
806
  }
884
- for (const session of site.orphanSessions) {
885
- issues.push({
886
- kind: 'orphan-session',
887
- site: site.site,
888
- message: `${site.site} has an untracked session ${session}`,
889
- path: session,
890
- });
891
- }
892
807
  }
893
808
 
894
809
  for (const r of opts.registrations) {
@@ -1174,7 +1089,7 @@ function pruneTeachState(
1174
1089
  for (const site of sites) {
1175
1090
  const statePath = teachStatePath(site);
1176
1091
  if (!existsSync(statePath)) continue;
1177
- const status = scanLocalSite(ctx, site, localSiteDir(site));
1092
+ const status = scanLocalSite(site, localSiteDir(site));
1178
1093
  const remove = new Set(
1179
1094
  status.workflows
1180
1095
  .filter(
@@ -1208,13 +1123,6 @@ function pruneSingleTeachWorkflow(site: string, workflow: string): MutationResul
1208
1123
  return { changed: [`pruned teach-state entry ${site}/${workflow}`], skipped: [] };
1209
1124
  }
1210
1125
 
1211
- function deleteOrphanSessionFile(path: string): MutationResult {
1212
- if (!existsSync(path))
1213
- return { changed: [], skipped: [`orphan session ${path} no longer exists`] };
1214
- rmSync(path, { force: true });
1215
- return { changed: [`deleted orphan session ${path}`], skipped: [] };
1216
- }
1217
-
1218
1126
  function matchesTarget(
1219
1127
  reg: McpRegistration,
1220
1128
  target: string,
@@ -17,6 +17,7 @@ import {
17
17
  type Tool,
18
18
  } from '@modelcontextprotocol/sdk/types.js';
19
19
  import { resolveLadder, runWithLadder } from './backend-ladder.ts';
20
+ import type { CdpBrowserFetch } from './cdp-browser-fetch.ts';
20
21
  import { createLog } from './log.ts';
21
22
  import { imprintHomeDir } from './paths.ts';
22
23
  import { loadBackendsCache } from './probe-backends.ts';
@@ -66,11 +67,17 @@ function buildToolDescription(w: ResolvedTool['workflow']): string {
66
67
 
67
68
  /** MCP advertises tool input as JSON Schema; build it directly from
68
69
  * workflow parameters rather than going through Zod. */
69
- function buildJsonSchema(parameters: WorkflowParameter[]): Tool['inputSchema'] {
70
+ export function buildJsonSchema(parameters: WorkflowParameter[]): Tool['inputSchema'] {
70
71
  const properties: Record<string, { type: string; description: string }> = {};
71
72
  const required: string[] = [];
72
73
  for (const p of parameters) {
73
- properties[p.name] = { type: p.type, description: p.description };
74
+ // Producer-sourced token params: tell the orchestrating LLM where to mint the
75
+ // value so it calls the producer once and reuses it, rather than fabricating
76
+ // an opaque token (which the tool would reject).
77
+ const description = p.sourcedFrom
78
+ ? `${p.description} Obtain this value from the \`${p.sourcedFrom.tool}\` tool's \`${p.sourcedFrom.field}\` output — call \`${p.sourcedFrom.tool}\` first and reuse the value across calls (no need to re-fetch each time).`
79
+ : p.description;
80
+ properties[p.name] = { type: p.type, description };
74
81
  if (p.default === undefined) required.push(p.name);
75
82
  }
76
83
  return {
@@ -88,13 +95,13 @@ function buildServer(
88
95
  version: string,
89
96
  tools: ResolvedTool[],
90
97
  assetRoot: string,
91
- ): Server {
98
+ ): { server: Server; closeCdpPool: () => Promise<void> } {
92
99
  const server = new Server(
93
100
  { name, version },
94
101
  {
95
102
  capabilities: { tools: {} },
96
103
  instructions:
97
- 'Imprint runs deterministic workflows captured from real browser sessions. Tools prefer fetch API replay, may use gated fetch-bootstrap only for declared browser-minted state, then stealth-fetch for bot-defense state, and playbook only for full DOM interaction. Error codes: AUTH_EXPIRED (401, run `imprint login <site>`); STATE_MISSING (required cookie/state was unavailable or ambiguous); FORBIDDEN (403); RATE_LIMITED (429, back off); BAD_RESPONSE (other 4xx/5xx); NETWORK (fetch failed); UNKNOWN (everything else).',
104
+ 'Imprint runs deterministic workflows captured from real browser sessions. Tools prefer fetch API replay, may use gated fetch-bootstrap only for declared browser-minted state, then cdp-replay (API requests run inside a live trusted Chrome so a protected POST refreshes its anti-bot token between calls) for multi-step state-changing flows, then stealth-fetch for bot-defense state, and playbook only for full DOM interaction. Error codes: AUTH_EXPIRED (401, run `imprint login <site>`); STATE_MISSING (required cookie/state was unavailable or ambiguous); FORBIDDEN (403); RATE_LIMITED (429, back off); BAD_RESPONSE (other 4xx/5xx); NETWORK (fetch failed); UNKNOWN (everything else).',
98
105
  },
99
106
  );
100
107
 
@@ -105,6 +112,21 @@ function buildServer(
105
112
  // Per-site stealth-fetch cache so the ~12s bootstrap runs once per site.
106
113
  const stealthCache = new Map<string, StealthFetch>();
107
114
 
115
+ // Per-site CDP browser pool: cdp-replay stores its live Chrome here after
116
+ // the first successful call so subsequent calls reuse it (~2-5s vs ~33s).
117
+ const cdpPool = new Map<string, CdpBrowserFetch>();
118
+ const cdpIdleTimers = new Map<string, ReturnType<typeof setTimeout>>();
119
+ const CDP_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
120
+
121
+ // Per-tool memo of the winning backend for THIS server session. After the
122
+ // first call discovers the right rung, later calls skip the doomed early ones
123
+ // (e.g. southwest's ~80s fetch-bootstrap FORBIDDEN before cdp-replay wins). Its
124
+ // lifetime is tied to `cdpPool`: the memoized cdp-replay is only cheap while
125
+ // its Chrome is pooled, so a site's memo is evicted when that pool entry is
126
+ // idle-closed (below) — otherwise the next call would start at a now-cold
127
+ // cdp-replay and re-pay the ~33s relaunch.
128
+ const winnerCache = new Map<string, ConcreteBackend>();
129
+
108
130
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
109
131
  tools: tools.map((t) => ({
110
132
  name: t.workflow.toolName,
@@ -140,6 +162,15 @@ function buildServer(
140
162
  string | number | boolean
141
163
  >;
142
164
 
165
+ // Audit-only pacing: when the audit harness sets IMPRINT_AUDIT_PACING_MS,
166
+ // sleep before each tool call so the auditor's per-parameter differential
167
+ // probing of bot-defended idempotent reads stays steady enough not to trip
168
+ // the per-IP anti-bot defense. Unset in production → no delay.
169
+ const pacingMs = Number(process.env.IMPRINT_AUDIT_PACING_MS);
170
+ if (Number.isFinite(pacingMs) && pacingMs > 0) {
171
+ await new Promise((r) => setTimeout(r, pacingMs));
172
+ }
173
+
143
174
  try {
144
175
  const ladder = resolveLadder('auto', tool.preferredOrder);
145
176
  const { result, usedBackend } = await runWithLadder(
@@ -148,7 +179,29 @@ function buildServer(
148
179
  args,
149
180
  assetRoot,
150
181
  stealthCache,
182
+ { cdpPool, winnerCache },
151
183
  );
184
+ // Reset the idle timer for this site's pooled Chrome.
185
+ if (result.ok && usedBackend === 'cdp-replay' && cdpPool.has(tool.site)) {
186
+ const prev = cdpIdleTimers.get(tool.site);
187
+ if (prev) clearTimeout(prev);
188
+ const timer = setTimeout(() => {
189
+ const cf = cdpPool.get(tool.site);
190
+ if (cf) {
191
+ log(`closing idle CDP session for ${tool.site}`);
192
+ cf.close().catch(() => {});
193
+ cdpPool.delete(tool.site);
194
+ cdpIdleTimers.delete(tool.site);
195
+ // Drop this site's winner memo too: a memoized cdp-replay would now
196
+ // point at a closed Chrome and re-pay the cold relaunch.
197
+ for (const key of winnerCache.keys()) {
198
+ if (key.startsWith(`${tool.site}:`)) winnerCache.delete(key);
199
+ }
200
+ }
201
+ }, CDP_IDLE_TIMEOUT_MS);
202
+ timer.unref();
203
+ cdpIdleTimers.set(tool.site, timer);
204
+ }
152
205
  if (!result.ok) {
153
206
  const text = formatToolError(result);
154
207
  return {
@@ -165,7 +218,18 @@ function buildServer(
165
218
  }
166
219
  });
167
220
 
168
- return server;
221
+ async function closeCdpPool(): Promise<void> {
222
+ for (const [site, cf] of cdpPool) {
223
+ log(`shutdown: closing CDP session for ${site}`);
224
+ await cf.close().catch(() => {});
225
+ }
226
+ cdpPool.clear();
227
+ for (const timer of cdpIdleTimers.values()) clearTimeout(timer);
228
+ cdpIdleTimers.clear();
229
+ winnerCache.clear();
230
+ }
231
+
232
+ return { server, closeCdpPool };
169
233
  }
170
234
 
171
235
  function formatToolError(result: Extract<ToolResult, { ok: false }>): string {
@@ -263,7 +327,7 @@ async function runStdio(
263
327
  tools: ResolvedTool[],
264
328
  assetRoot: string,
265
329
  ): Promise<void> {
266
- const server = buildServer(name, version, tools, assetRoot);
330
+ const { server, closeCdpPool } = buildServer(name, version, tools, assetRoot);
267
331
  const transport = new StdioServerTransport();
268
332
  await server.connect(transport);
269
333
  log(`stdio transport ready (${tools.length} tool${tools.length === 1 ? '' : 's'})`);
@@ -277,6 +341,7 @@ async function runStdio(
277
341
  process.once('SIGINT', () => done('SIGINT'));
278
342
  process.once('SIGTERM', () => done('SIGTERM'));
279
343
  });
344
+ await closeCdpPool();
280
345
  }
281
346
 
282
347
  /**
@@ -296,7 +361,7 @@ async function runHttp(
296
361
  port: number,
297
362
  assetRoot: string,
298
363
  ): Promise<void> {
299
- const server = buildServer(name, version, tools, assetRoot);
364
+ const { server, closeCdpPool } = buildServer(name, version, tools, assetRoot);
300
365
  const transport = new StreamableHTTPServerTransport({
301
366
  sessionIdGenerator: () => crypto.randomUUID(),
302
367
  });
@@ -347,4 +412,5 @@ async function runHttp(
347
412
  process.once('SIGINT', () => shutdown('SIGINT'));
348
413
  process.once('SIGTERM', () => shutdown('SIGTERM'));
349
414
  });
415
+ await closeCdpPool();
350
416
  }
@@ -55,15 +55,20 @@ export class MultiProgress {
55
55
  }
56
56
 
57
57
  private redraw(): void {
58
+ const cols = process.stderr.columns || 80;
58
59
  let buf = '';
59
60
  if (this.renderedCount > 0) {
60
61
  buf += `\x1b[${this.renderedCount}F`;
61
62
  }
62
63
  buf += '\x1b[J';
64
+ let physicalLines = 0;
63
65
  for (const [, msg] of this.lines) {
64
- buf += `│ ${msg}\n`;
66
+ const line = `│ ${msg}`;
67
+ const truncated = line.length >= cols ? line.slice(0, cols - 1) : line;
68
+ buf += `${truncated}\n`;
69
+ physicalLines += 1;
65
70
  }
66
71
  process.stderr.write(buf);
67
- this.renderedCount = this.lines.size;
72
+ this.renderedCount = physicalLines;
68
73
  }
69
74
  }