imprint-mcp 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,355 @@
1
+ /**
2
+ * `imprint export` / `imprint import` — portable .tar.gz archives of
3
+ * generated MCP tools, optionally including encrypted credential bundles.
4
+ *
5
+ * Archive layout:
6
+ * manifest.json
7
+ * <site>/<tool>/{ workflow.json, playbook.yaml, index.ts, ... }
8
+ * <site>/_shared/{ *.ts, package.json }
9
+ * <site>/credentials.imprintbundle (when --include-credentials)
10
+ */
11
+
12
+ import { execSync } from 'node:child_process';
13
+ import {
14
+ copyFileSync,
15
+ existsSync,
16
+ mkdirSync,
17
+ mkdtempSync,
18
+ readFileSync,
19
+ readdirSync,
20
+ rmSync,
21
+ statSync,
22
+ writeFileSync,
23
+ } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { join as pathJoin, resolve as pathResolve } from 'node:path';
26
+ import * as p from '@clack/prompts';
27
+ import { type BundleEnvelope, exportBundle, importBundle } from './credential-bundle.ts';
28
+ import { getCredentialBackend } from './credential-store.ts';
29
+ import { imprintHomeDir, localSharedDir, localSiteDir } from './paths.ts';
30
+ import { ensureImprintRuntimeLink } from './runtime-link.ts';
31
+ import { availableSitesHint } from './sites.ts';
32
+ import { VERSION } from './version.ts';
33
+
34
+ const TOOL_FILES = [
35
+ 'workflow.json',
36
+ 'playbook.yaml',
37
+ 'index.ts',
38
+ 'parser.ts',
39
+ 'request-transform.ts',
40
+ 'package.json',
41
+ 'backends.json',
42
+ 'cron.json',
43
+ ];
44
+
45
+ const SHARED_SKIP = new Set(['node_modules', 'bun.lock']);
46
+
47
+ interface ExportManifest {
48
+ version: 1;
49
+ imprintVersion: string;
50
+ createdAt: string;
51
+ sites: Array<{
52
+ name: string;
53
+ tools: string[];
54
+ hasCredentials: boolean;
55
+ hasShared: boolean;
56
+ }>;
57
+ }
58
+
59
+ interface ExportResult {
60
+ archivePath: string;
61
+ sites: Array<{ name: string; tools: string[] }>;
62
+ byteSize: number;
63
+ }
64
+
65
+ interface ImportResult {
66
+ sites: Array<{
67
+ name: string;
68
+ tools: string[];
69
+ credentialsImported: boolean;
70
+ skipped: boolean;
71
+ }>;
72
+ }
73
+
74
+ function isToolDir(dir: string): boolean {
75
+ return existsSync(pathJoin(dir, 'index.ts'));
76
+ }
77
+
78
+ function discoverToolNames(siteDir: string): string[] {
79
+ if (!existsSync(siteDir)) return [];
80
+ return readdirSync(siteDir)
81
+ .filter((entry) => {
82
+ if (
83
+ entry.startsWith('.') ||
84
+ entry.startsWith('_') ||
85
+ entry === 'sessions' ||
86
+ entry === 'node_modules'
87
+ )
88
+ return false;
89
+ const full = pathJoin(siteDir, entry);
90
+ try {
91
+ return statSync(full).isDirectory() && isToolDir(full);
92
+ } catch {
93
+ return false;
94
+ }
95
+ })
96
+ .sort();
97
+ }
98
+
99
+ function collectToolFiles(toolDir: string): string[] {
100
+ return TOOL_FILES.filter((f) => existsSync(pathJoin(toolDir, f)));
101
+ }
102
+
103
+ function collectSharedFiles(sharedDir: string): string[] {
104
+ if (!existsSync(sharedDir)) return [];
105
+ return readdirSync(sharedDir).filter((f) => {
106
+ if (SHARED_SKIP.has(f)) return false;
107
+ if (f.endsWith('.test.ts') || f.endsWith('.plan.md')) return false;
108
+ const full = pathJoin(sharedDir, f);
109
+ try {
110
+ return statSync(full).isFile();
111
+ } catch {
112
+ return false;
113
+ }
114
+ });
115
+ }
116
+
117
+ export async function exportArchive(opts: {
118
+ sites: string[];
119
+ out: string;
120
+ includeCredentials?: boolean;
121
+ }): Promise<ExportResult> {
122
+ for (const site of opts.sites) {
123
+ const dir = localSiteDir(site);
124
+ if (!existsSync(dir)) {
125
+ throw new Error(
126
+ `Site "${site}" not found at ${dir}.\n${availableSitesHint(imprintHomeDir(), site)}`,
127
+ );
128
+ }
129
+ }
130
+
131
+ const staging = mkdtempSync(pathJoin(tmpdir(), 'imprint-export-'));
132
+
133
+ try {
134
+ const manifest: ExportManifest = {
135
+ version: 1,
136
+ imprintVersion: VERSION,
137
+ createdAt: new Date().toISOString(),
138
+ sites: [],
139
+ };
140
+
141
+ for (const site of opts.sites) {
142
+ const siteDir = localSiteDir(site);
143
+ const tools = discoverToolNames(siteDir);
144
+ if (tools.length === 0) {
145
+ throw new Error(
146
+ `Site "${site}" has no tools (no subdirectories with index.ts). Nothing to export.`,
147
+ );
148
+ }
149
+
150
+ const stagingSite = pathJoin(staging, site);
151
+ mkdirSync(stagingSite, { recursive: true });
152
+
153
+ for (const tool of tools) {
154
+ const toolDir = pathJoin(siteDir, tool);
155
+ const stagingTool = pathJoin(stagingSite, tool);
156
+ mkdirSync(stagingTool, { recursive: true });
157
+
158
+ for (const file of collectToolFiles(toolDir)) {
159
+ copyFileSync(pathJoin(toolDir, file), pathJoin(stagingTool, file));
160
+ }
161
+ }
162
+
163
+ const sharedDir = localSharedDir(site);
164
+ const sharedFiles = collectSharedFiles(sharedDir);
165
+ let hasShared = false;
166
+ if (sharedFiles.length > 0) {
167
+ const stagingShared = pathJoin(stagingSite, '_shared');
168
+ mkdirSync(stagingShared, { recursive: true });
169
+ for (const file of sharedFiles) {
170
+ copyFileSync(pathJoin(sharedDir, file), pathJoin(stagingShared, file));
171
+ }
172
+ hasShared = true;
173
+ }
174
+
175
+ let hasCredentials = false;
176
+ if (opts.includeCredentials) {
177
+ const backend = await getCredentialBackend();
178
+ const secrets = await backend.listSecrets(site);
179
+ const cookies = await backend.getCookies(site);
180
+ if (secrets.length > 0 || cookies.length > 0) {
181
+ const passphrase = await p.password({
182
+ message: `Passphrase to encrypt credentials for "${site}" (min 8 chars):`,
183
+ validate: (v) =>
184
+ (v ?? '').length < 8 ? 'Passphrase must be at least 8 characters.' : undefined,
185
+ });
186
+ if (p.isCancel(passphrase)) {
187
+ throw new Error('Export cancelled.');
188
+ }
189
+ const envelope = await exportBundle({
190
+ backend,
191
+ site,
192
+ passphrase,
193
+ });
194
+ writeFileSync(
195
+ pathJoin(stagingSite, 'credentials.imprintbundle'),
196
+ JSON.stringify(envelope, null, 2),
197
+ 'utf8',
198
+ );
199
+ hasCredentials = true;
200
+ }
201
+ }
202
+
203
+ manifest.sites.push({ name: site, tools, hasCredentials, hasShared });
204
+ }
205
+
206
+ writeFileSync(pathJoin(staging, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
207
+
208
+ const archivePath = pathResolve(opts.out);
209
+ execSync(`tar czf ${shellEscape(archivePath)} -C ${shellEscape(staging)} .`, {
210
+ stdio: 'pipe',
211
+ });
212
+
213
+ const byteSize = statSync(archivePath).size;
214
+ return {
215
+ archivePath,
216
+ sites: manifest.sites.map((s) => ({ name: s.name, tools: s.tools })),
217
+ byteSize,
218
+ };
219
+ } finally {
220
+ rmSync(staging, { recursive: true, force: true });
221
+ }
222
+ }
223
+
224
+ export async function importArchive(opts: {
225
+ archivePath: string;
226
+ force?: boolean;
227
+ }): Promise<ImportResult> {
228
+ const archivePath = pathResolve(opts.archivePath);
229
+ if (!existsSync(archivePath)) {
230
+ throw new Error(`Archive not found: ${archivePath}`);
231
+ }
232
+
233
+ const staging = mkdtempSync(pathJoin(tmpdir(), 'imprint-import-'));
234
+
235
+ try {
236
+ execSync(`tar xzf ${shellEscape(archivePath)} -C ${shellEscape(staging)}`, {
237
+ stdio: 'pipe',
238
+ });
239
+
240
+ const manifestPath = pathJoin(staging, 'manifest.json');
241
+ if (!existsSync(manifestPath)) {
242
+ throw new Error(
243
+ 'Invalid archive: missing manifest.json. This does not appear to be an imprint export.',
244
+ );
245
+ }
246
+
247
+ const manifest: ExportManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
248
+ if (manifest.version !== 1) {
249
+ throw new Error(
250
+ `Unsupported archive version ${manifest.version}. Update imprint and try again.`,
251
+ );
252
+ }
253
+
254
+ const home = imprintHomeDir();
255
+ const result: ImportResult = { sites: [] };
256
+
257
+ for (const entry of manifest.sites) {
258
+ const targetDir = localSiteDir(entry.name);
259
+ const skipped = false;
260
+
261
+ if (existsSync(targetDir) && !opts.force) {
262
+ console.error(
263
+ `warning: site "${entry.name}" already exists at ${targetDir} — skipping (use --force to overwrite).`,
264
+ );
265
+ result.sites.push({
266
+ name: entry.name,
267
+ tools: entry.tools,
268
+ credentialsImported: false,
269
+ skipped: true,
270
+ });
271
+ continue;
272
+ }
273
+
274
+ const stagedSite = pathJoin(staging, entry.name);
275
+
276
+ if (existsSync(targetDir)) {
277
+ rmSync(targetDir, { recursive: true, force: true });
278
+ }
279
+
280
+ for (const tool of entry.tools) {
281
+ assertSafeSegment('tool name', tool);
282
+ const src = pathJoin(stagedSite, tool);
283
+ const dest = pathJoin(targetDir, tool);
284
+ mkdirSync(dest, { recursive: true });
285
+ for (const file of readdirSync(src)) {
286
+ const srcFile = pathJoin(src, file);
287
+ if (statSync(srcFile).isFile()) {
288
+ copyFileSync(srcFile, pathJoin(dest, file));
289
+ }
290
+ }
291
+ }
292
+
293
+ if (entry.hasShared) {
294
+ const sharedSrc = pathJoin(stagedSite, '_shared');
295
+ const sharedDest = pathJoin(targetDir, '_shared');
296
+ if (existsSync(sharedSrc)) {
297
+ mkdirSync(sharedDest, { recursive: true });
298
+ for (const file of readdirSync(sharedSrc)) {
299
+ const srcFile = pathJoin(sharedSrc, file);
300
+ if (statSync(srcFile).isFile()) {
301
+ copyFileSync(srcFile, pathJoin(sharedDest, file));
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ let credentialsImported = false;
308
+ const bundlePath = pathJoin(stagedSite, 'credentials.imprintbundle');
309
+ if (entry.hasCredentials && existsSync(bundlePath)) {
310
+ const envelope: BundleEnvelope = JSON.parse(readFileSync(bundlePath, 'utf8'));
311
+ const passphrase = await p.password({
312
+ message: `Passphrase to decrypt credentials for "${entry.name}":`,
313
+ });
314
+ if (p.isCancel(passphrase)) {
315
+ console.error(`Skipping credential import for "${entry.name}".`);
316
+ } else {
317
+ try {
318
+ const backend = await getCredentialBackend();
319
+ await importBundle({ backend, envelope, passphrase });
320
+ credentialsImported = true;
321
+ } catch (err) {
322
+ console.error(
323
+ `warning: credential import failed for "${entry.name}": ${err instanceof Error ? err.message : String(err)}`,
324
+ );
325
+ }
326
+ }
327
+ }
328
+
329
+ ensureImprintRuntimeLink(home);
330
+
331
+ result.sites.push({
332
+ name: entry.name,
333
+ tools: entry.tools,
334
+ credentialsImported,
335
+ skipped,
336
+ });
337
+ }
338
+
339
+ return result;
340
+ } finally {
341
+ rmSync(staging, { recursive: true, force: true });
342
+ }
343
+ }
344
+
345
+ function assertSafeSegment(label: string, value: string): void {
346
+ if (value.includes('..') || value.includes('/') || value.includes('\\')) {
347
+ throw new Error(
348
+ `Invalid ${label} in archive: "${value}". Must not contain path separators or ".." sequences.`,
349
+ );
350
+ }
351
+ }
352
+
353
+ function shellEscape(s: string): string {
354
+ return `'${s.replace(/'/g, "'\\''")}'`;
355
+ }
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
3
3
  import { dirname as pathDirname, join as pathJoin, resolve as pathResolve } from 'node:path';
4
4
  import * as p from '@clack/prompts';
5
5
  import { parse as yamlParse, stringify as yamlStringify } from 'yaml';
6
+ import { defaultPlaywrightBrowsersPath, ensurePlaywrightChromiumInstalled } from './chromium.ts';
6
7
  import {
7
8
  type McpServerConfig,
8
9
  PLATFORMS,
@@ -16,7 +17,7 @@ import {
16
17
  shellQuote,
17
18
  } from './integrations.ts';
18
19
  import { imprintHomeDir } from './paths.ts';
19
- import { discoverTools } from './tool-loader.ts';
20
+ import { type ResolvedTool, discoverTools } from './tool-loader.ts';
20
21
  import type { Workflow } from './types.ts';
21
22
 
22
23
  type InstallSource = 'local' | 'examples';
@@ -28,6 +29,7 @@ interface InstallOptions {
28
29
  source?: InstallSource;
29
30
  print?: boolean;
30
31
  noInteractive?: boolean;
32
+ skipBrowserInstall?: boolean;
31
33
  }
32
34
 
33
35
  interface UninstallOptions {
@@ -70,6 +72,7 @@ interface InstallTarget {
70
72
  source: InstallSource;
71
73
  assetRoot: string;
72
74
  site: string;
75
+ tools: ResolvedTool[];
73
76
  workflows: Workflow[];
74
77
  }
75
78
 
@@ -114,7 +117,11 @@ function defaultOpenClawConfigPath(): string {
114
117
  return pathJoin(homedir(), '.openclaw', 'openclaw.json');
115
118
  }
116
119
 
117
- function defaultHermesConfigPath(): string {
120
+ export function defaultHermesConfigPath(): string {
121
+ const explicit = process.env.HERMES_CONFIG?.trim();
122
+ if (explicit) return explicit;
123
+ const hermesHome = process.env.HERMES_HOME?.trim();
124
+ if (hermesHome) return pathJoin(hermesHome, 'config.yaml');
118
125
  return pathJoin(homedir(), '.hermes', 'config.yaml');
119
126
  }
120
127
 
@@ -255,7 +262,7 @@ export async function install(opts: InstallOptions = {}): Promise<InstallResult>
255
262
  const imprintCommand = configFilePlatform(platform)
256
263
  ? detectDirectBunImprintCommand()
257
264
  : detectImprintCommand();
258
- const env = { IMPRINT_HOME: target.assetRoot };
265
+ const env = buildInstallEnvironment(target);
259
266
  const workflow = target.workflows[0];
260
267
  if (!workflow) {
261
268
  throw new Error(`No emitted workflows found for ${target.site}. Run \`imprint emit\` first.`);
@@ -287,6 +294,10 @@ export async function install(opts: InstallOptions = {}): Promise<InstallResult>
287
294
  };
288
295
  }
289
296
 
297
+ if (!opts.skipBrowserInstall) {
298
+ ensureBrowserRuntimeForInstall(target);
299
+ }
300
+
290
301
  const regCommand = buildRegistrationCommand({
291
302
  site: target.site,
292
303
  platform,
@@ -422,10 +433,68 @@ async function resolveInstallTarget(opts: InstallOptions): Promise<InstallTarget
422
433
  source: selected.source,
423
434
  assetRoot: selected.assetRoot,
424
435
  site: selected.site,
436
+ tools,
425
437
  workflows,
426
438
  };
427
439
  }
428
440
 
441
+ function buildInstallEnvironment(target: InstallTarget): Record<string, string> {
442
+ const env: Record<string, string> = { IMPRINT_HOME: target.assetRoot };
443
+ const browsersPath = defaultPlaywrightBrowsersPath();
444
+ if (browsersPath && installTargetNeedsBrowserRuntime(target)) {
445
+ env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
446
+ }
447
+ return env;
448
+ }
449
+
450
+ function ensureBrowserRuntimeForInstall(target: InstallTarget): void {
451
+ if (!installTargetNeedsBrowserRuntime(target)) return;
452
+ const result = ensurePlaywrightChromiumInstalled({
453
+ log: (message) => process.stderr.write(`[imprint install] ${message}\n`),
454
+ });
455
+ if (result.installed) {
456
+ process.stderr.write(`[imprint install] installed Playwright Chromium at ${result.path}\n`);
457
+ }
458
+ }
459
+
460
+ function installTargetNeedsBrowserRuntime(target: InstallTarget): boolean {
461
+ return target.tools.some(
462
+ (tool) => workflowNeedsBrowserRuntime(tool.workflow) || toolDirNeedsBrowserRuntime(tool.dir),
463
+ );
464
+ }
465
+
466
+ function workflowNeedsBrowserRuntime(workflow: Workflow): boolean {
467
+ if (workflow.bootstrap) return true;
468
+ if (workflow.liveVerified === false && workflow.liveVerifiedWaiver?.kind === 'waived-bot') {
469
+ return true;
470
+ }
471
+ if (workflow.requests.some((request) => request.url.includes('${state.'))) return true;
472
+ return workflow.requests.some((request) =>
473
+ (request.captures ?? []).some((capture) => captureNeedsBrowserRuntime(capture.capability)),
474
+ );
475
+ }
476
+
477
+ function toolDirNeedsBrowserRuntime(toolDir: string): boolean {
478
+ if (existsSync(pathJoin(toolDir, 'playbook.yaml'))) return true;
479
+ const backendsPath = pathJoin(toolDir, 'backends.json');
480
+ if (!existsSync(backendsPath)) return false;
481
+ try {
482
+ const parsed = JSON.parse(readFileSync(backendsPath, 'utf8')) as { preferredOrder?: unknown };
483
+ if (!Array.isArray(parsed.preferredOrder)) return false;
484
+ return parsed.preferredOrder.some(
485
+ (backend) =>
486
+ typeof backend === 'string' &&
487
+ ['fetch-bootstrap', 'cdp-replay', 'stealth-fetch', 'playbook'].includes(backend),
488
+ );
489
+ } catch {
490
+ return false;
491
+ }
492
+ }
493
+
494
+ function captureNeedsBrowserRuntime(capability: string | undefined): boolean {
495
+ return capability === 'browser_bootstrap' || capability === 'stealth_bootstrap';
496
+ }
497
+
429
498
  async function resolveInstallPlatform(
430
499
  opts: Pick<InstallOptions, 'platform' | 'noInteractive'>,
431
500
  message = 'Install this MCP server where?',
@@ -658,7 +727,13 @@ function isPlatformDetected(platform: Platform): boolean {
658
727
  case 'openclaw':
659
728
  return commandExists('openclaw') || existsSync(pathJoin(homedir(), '.openclaw'));
660
729
  case 'hermes':
661
- return commandExists('hermes') || existsSync(pathJoin(homedir(), '.hermes'));
730
+ return (
731
+ commandExists('hermes') ||
732
+ Boolean(process.env.HERMES_CONFIG?.trim()) ||
733
+ Boolean(process.env.HERMES_HOME?.trim()) ||
734
+ existsSync(defaultHermesConfigPath()) ||
735
+ existsSync(pathJoin(homedir(), '.hermes'))
736
+ );
662
737
  }
663
738
  }
664
739
 
@@ -105,7 +105,7 @@ export function generatePasteSnippet(opts: {
105
105
  This gives your agent a tool that ${descLower}. Parameters: ${paramList}.`;
106
106
 
107
107
  case 'hermes':
108
- return `Add the ${toolName} tool: add to ~/.hermes/config.yaml under mcp_servers:
108
+ return `Add the ${toolName} tool: add to $HERMES_HOME/config.yaml (or ~/.hermes/config.yaml outside Hermes) under mcp_servers:
109
109
 
110
110
  ${toolName}:
111
111
  command: "${ic.command}"
@@ -30,6 +30,7 @@ import { imprintHomeDir, localSiteDir } from './paths.ts';
30
30
  import {
31
31
  type WorkflowState,
32
32
  loadTeachState,
33
+ pruneStalePendingTeachWorkflows,
33
34
  resolveTeachStatePath,
34
35
  saveTeachState,
35
36
  teachStatePath,
@@ -37,7 +38,7 @@ import {
37
38
 
38
39
  type McpClient = 'claude-code' | 'codex' | 'claude-desktop' | 'openclaw' | 'hermes';
39
40
  type LocalDeleteMode = 'none' | 'tool' | 'site';
40
- type IssueKind = 'incomplete' | 'missing-session' | 'stale-registration';
41
+ type IssueKind = 'missing-session' | 'stale-registration';
41
42
 
42
43
  const CLIENTS: McpClient[] = ['claude-code', 'codex', 'claude-desktop', 'openclaw', 'hermes'];
43
44
  const DISABLED_STORE_VERSION = 1;
@@ -144,6 +145,14 @@ function defaultContext(opts: Partial<MaintenanceContext> = {}): MaintenanceCont
144
145
  };
145
146
  }
146
147
 
148
+ function hermesConfigPath(ctx: MaintenanceContext): string {
149
+ const explicit = process.env.HERMES_CONFIG?.trim();
150
+ if (explicit) return explicit;
151
+ const hermesHome = process.env.HERMES_HOME?.trim();
152
+ if (hermesHome) return pathJoin(hermesHome, 'config.yaml');
153
+ return pathJoin(ctx.homeDir, '.hermes', 'config.yaml');
154
+ }
155
+
147
156
  function parseSubArgs(argv: string[]): ParsedArgs {
148
157
  const positionals: string[] = [];
149
158
  const flags: Record<string, string | boolean> = {};
@@ -419,7 +428,7 @@ function fixIssue(issue: McpIssue, status: McpStatus, ctx: MaintenanceContext):
419
428
  };
420
429
  }
421
430
 
422
- if ((issue.kind === 'incomplete' || issue.kind === 'missing-session') && issue.workflow) {
431
+ if (issue.kind === 'missing-session' && issue.workflow) {
423
432
  return pruneSingleTeachWorkflow(issue.site, issue.workflow);
424
433
  }
425
434
 
@@ -680,8 +689,6 @@ function issueFixHint(issue: McpIssue): string | null {
680
689
  switch (issue.kind) {
681
690
  case 'stale-registration':
682
691
  return `choose "Fix an issue" or run: imprint mcp delete ${issue.name ?? `imprint-${issue.site}`} --client ${issue.client ?? 'all'} --yes`;
683
- case 'incomplete':
684
- return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --incomplete --yes`;
685
692
  case 'missing-session':
686
693
  return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --missing-session --yes`;
687
694
  }
@@ -729,6 +736,9 @@ function scanLocalSite(site: string, dir: string): LocalSiteStatus {
729
736
  }
730
737
 
731
738
  const state = loadTeachState(site);
739
+ if (pruneStalePendingTeachWorkflows(site, state)) {
740
+ saveTeachState(site, state);
741
+ }
732
742
  const workflows = Object.entries(state.workflows)
733
743
  .map(([name, ws]) => workflowStatus(site, name, ws, tools))
734
744
  .sort((a, b) => a.name.localeCompare(b.name));
@@ -795,14 +805,6 @@ function collectIssues(opts: {
795
805
  path: wf.sessionPath ?? undefined,
796
806
  });
797
807
  }
798
- if (wf.incomplete) {
799
- issues.push({
800
- kind: 'incomplete',
801
- site: site.site,
802
- workflow: wf.name,
803
- message: `${site.site}/${wf.name} is incomplete (${wf.completedSteps.join(', ') || 'no completed steps'})`,
804
- });
805
- }
806
808
  }
807
809
  }
808
810
 
@@ -1419,7 +1421,7 @@ function scanJsonMap(
1419
1421
  }
1420
1422
 
1421
1423
  function scanHermes(ctx: MaintenanceContext): McpRegistration[] {
1422
- const configPath = pathJoin(ctx.homeDir, '.hermes', 'config.yaml');
1424
+ const configPath = hermesConfigPath(ctx);
1423
1425
  if (!existsSync(configPath)) return [];
1424
1426
  const doc = YAML.parseDocument(readFileSync(configPath, 'utf8'));
1425
1427
  const servers = doc.get('mcp_servers', true);
@@ -179,7 +179,7 @@ function buildServer(
179
179
  args,
180
180
  assetRoot,
181
181
  stealthCache,
182
- { cdpPool, winnerCache },
182
+ { cdpPool, winnerCache, skipBootstrapSplice: Boolean(tool.preferredOrder?.length) },
183
183
  );
184
184
  // Reset the idle timer for this site's pooled Chrome.
185
185
  if (result.ok && usedBackend === 'cdp-replay' && cdpPool.has(tool.site)) {
@@ -1,4 +1,4 @@
1
- import { findChromium } from './chromium.ts';
1
+ import { ensurePlaywrightChromiumInstalled } from './chromium.ts';
2
2
 
3
3
  /**
4
4
  * Shared loader for Playwright's chromium with the stealth plugin applied.
@@ -69,13 +69,11 @@ export async function isStealthPluginAvailable(): Promise<boolean> {
69
69
  * replay browser using chrome-headless-shell looks like a bot. Using the
70
70
  * SAME binary for both eliminates the binary asymmetry.
71
71
  *
72
- * Returns `undefined` if no Chromium can be located callers should let
73
- * Playwright fall back to whatever default it finds.
72
+ * Throws if Chromium cannot be installed or started; callers translate the
73
+ * error into their own result shape.
74
74
  */
75
75
  export function getStealthExecutablePath(): string | undefined {
76
- try {
77
- return findChromium();
78
- } catch {
79
- return undefined;
80
- }
76
+ return ensurePlaywrightChromiumInstalled({
77
+ log: (message) => process.stderr.write(`[imprint] ${message}\n`),
78
+ }).path;
81
79
  }
@@ -148,6 +148,24 @@ export function resolveTeachStatePath(
148
148
  return resolveLocalSitePath(site, value);
149
149
  }
150
150
 
151
+ export function resolveWorkflowTriagedPath(
152
+ site: string,
153
+ ws: WorkflowState | undefined,
154
+ ): string | null {
155
+ if (!ws) return null;
156
+
157
+ const explicitPath = resolveTeachStatePath(site, ws.triagedPath);
158
+ if (explicitPath) return explicitPath;
159
+
160
+ if (!ws.completedSteps.includes('triage')) return null;
161
+
162
+ const redactedPath = resolveTeachStatePath(site, ws.redactedPath);
163
+ if (!redactedPath?.endsWith('.redacted.json')) return null;
164
+
165
+ const derivedPath = redactedPath.replace(/\.redacted\.json$/, '.triaged.json');
166
+ return existsSync(derivedPath) ? derivedPath : null;
167
+ }
168
+
151
169
  export function toRelativeTeachStatePath(site: string, absPath: string): string {
152
170
  const localRelative = relativeToLocalSite(site, absPath);
153
171
  if (localRelative) return localRelative;
@@ -245,6 +263,25 @@ export function isExistingTeachFile(path: string | null | undefined): path is st
245
263
  }
246
264
  }
247
265
 
266
+ function hasRecoverableRawOrRedactedSession(site: string, ws: WorkflowState): boolean {
267
+ return (
268
+ isExistingTeachFile(resolveTeachStatePath(site, ws.sessionPath)) ||
269
+ isExistingTeachFile(resolveTeachStatePath(site, ws.redactedPath))
270
+ );
271
+ }
272
+
273
+ export function pruneStalePendingTeachWorkflows(site: string, state: TeachState): boolean {
274
+ let changed = false;
275
+ for (const [key, ws] of Object.entries(state.workflows)) {
276
+ if (!key.startsWith('_pending_')) continue;
277
+ if (hasRecoverableRawOrRedactedSession(site, ws)) continue;
278
+ delete state.workflows[key];
279
+ changed = true;
280
+ }
281
+
282
+ return changed;
283
+ }
284
+
248
285
  export function friendlySessionTimestamp(sessionPath: string): string {
249
286
  const m = sessionPath.match(/(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})/);
250
287
  if (!m) return pathBasename(sessionPath);