@dikshanty94/webnav 0.2.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 (145) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +283 -0
  3. package/bin/webnav +18 -0
  4. package/dist/agent/loop.d.ts +59 -0
  5. package/dist/agent/loop.js +267 -0
  6. package/dist/agent/server.d.ts +60 -0
  7. package/dist/agent/server.js +254 -0
  8. package/dist/cli-help.d.ts +3 -0
  9. package/dist/cli-help.js +111 -0
  10. package/dist/cli-spec.d.ts +24 -0
  11. package/dist/cli-spec.js +460 -0
  12. package/dist/cli.d.ts +208 -0
  13. package/dist/cli.js +2558 -0
  14. package/dist/contract.d.ts +20 -0
  15. package/dist/contract.js +15 -0
  16. package/dist/creds.d.ts +46 -0
  17. package/dist/creds.js +126 -0
  18. package/dist/dashboard/server.d.ts +137 -0
  19. package/dist/dashboard/server.js +395 -0
  20. package/dist/dashboard/shell.d.ts +1 -0
  21. package/dist/dashboard/shell.js +1343 -0
  22. package/dist/explorer/analyse.d.ts +25 -0
  23. package/dist/explorer/analyse.js +39 -0
  24. package/dist/explorer/diff.d.ts +11 -0
  25. package/dist/explorer/diff.js +22 -0
  26. package/dist/explorer/draft.d.ts +81 -0
  27. package/dist/explorer/draft.js +1658 -0
  28. package/dist/explorer/fingerprint-page.d.ts +11 -0
  29. package/dist/explorer/fingerprint-page.js +19 -0
  30. package/dist/explorer/fingerprint.d.ts +14 -0
  31. package/dist/explorer/fingerprint.js +18 -0
  32. package/dist/explorer/infer.d.ts +85 -0
  33. package/dist/explorer/infer.js +370 -0
  34. package/dist/explorer/pattern-propose.d.ts +27 -0
  35. package/dist/explorer/pattern-propose.js +81 -0
  36. package/dist/explorer/patterns.d.ts +49 -0
  37. package/dist/explorer/patterns.js +290 -0
  38. package/dist/explorer/shadow.d.ts +3 -0
  39. package/dist/explorer/shadow.js +95 -0
  40. package/dist/graph/coverage.d.ts +33 -0
  41. package/dist/graph/coverage.js +155 -0
  42. package/dist/graph/edit.d.ts +47 -0
  43. package/dist/graph/edit.js +211 -0
  44. package/dist/graph/frontier.d.ts +24 -0
  45. package/dist/graph/frontier.js +95 -0
  46. package/dist/graph/seed.d.ts +23 -0
  47. package/dist/graph/seed.js +29 -0
  48. package/dist/graph/show.d.ts +8 -0
  49. package/dist/graph/show.js +6 -0
  50. package/dist/graph/teach.d.ts +8 -0
  51. package/dist/graph/teach.js +10 -0
  52. package/dist/hosted.d.ts +36 -0
  53. package/dist/hosted.js +69 -0
  54. package/dist/index.d.ts +2 -0
  55. package/dist/index.js +4 -0
  56. package/dist/mapstore/record.d.ts +128 -0
  57. package/dist/mapstore/record.js +224 -0
  58. package/dist/mapstore/schema.sql +47 -0
  59. package/dist/mapstore/store.d.ts +108 -0
  60. package/dist/mapstore/store.js +399 -0
  61. package/dist/mapstore/types.d.ts +87 -0
  62. package/dist/mapstore/types.js +29 -0
  63. package/dist/mcp/server.d.ts +46 -0
  64. package/dist/mcp/server.js +163 -0
  65. package/dist/paths.d.ts +10 -0
  66. package/dist/paths.js +33 -0
  67. package/dist/playwright/adapter.d.ts +62 -0
  68. package/dist/playwright/adapter.js +145 -0
  69. package/dist/playwright/ax-adapter.d.ts +33 -0
  70. package/dist/playwright/ax-adapter.js +76 -0
  71. package/dist/playwright/capture.d.ts +2 -0
  72. package/dist/playwright/capture.js +10 -0
  73. package/dist/playwright/fingerprint.d.ts +40 -0
  74. package/dist/playwright/fingerprint.js +139 -0
  75. package/dist/playwright/profile-lock.d.ts +15 -0
  76. package/dist/playwright/profile-lock.js +51 -0
  77. package/dist/playwright/sessions.d.ts +72 -0
  78. package/dist/playwright/sessions.js +229 -0
  79. package/dist/playwright/snapshot.d.ts +10 -0
  80. package/dist/playwright/snapshot.js +52 -0
  81. package/dist/playwright/throttle.d.ts +14 -0
  82. package/dist/playwright/throttle.js +76 -0
  83. package/dist/playwright/video.d.ts +13 -0
  84. package/dist/playwright/video.js +32 -0
  85. package/dist/protocol.d.ts +49 -0
  86. package/dist/protocol.js +3 -0
  87. package/dist/recorder/agent-session.d.ts +59 -0
  88. package/dist/recorder/agent-session.js +230 -0
  89. package/dist/recorder/capture-loop.d.ts +19 -0
  90. package/dist/recorder/capture-loop.js +40 -0
  91. package/dist/recorder/coverage.d.ts +22 -0
  92. package/dist/recorder/coverage.js +49 -0
  93. package/dist/recorder/hover-probe.d.ts +35 -0
  94. package/dist/recorder/hover-probe.js +100 -0
  95. package/dist/recorder/ingest.d.ts +39 -0
  96. package/dist/recorder/ingest.js +138 -0
  97. package/dist/recorder/live-record.d.ts +41 -0
  98. package/dist/recorder/live-record.js +255 -0
  99. package/dist/recorder/live.d.ts +53 -0
  100. package/dist/recorder/live.js +400 -0
  101. package/dist/recorder/probe.d.ts +14 -0
  102. package/dist/recorder/probe.js +33 -0
  103. package/dist/recorder/replay.d.ts +60 -0
  104. package/dist/recorder/replay.js +401 -0
  105. package/dist/recorder/review.d.ts +73 -0
  106. package/dist/recorder/review.js +171 -0
  107. package/dist/recorder/snapshot-dom.d.ts +7 -0
  108. package/dist/recorder/snapshot-dom.js +25 -0
  109. package/dist/router/auth-status.d.ts +20 -0
  110. package/dist/router/auth-status.js +55 -0
  111. package/dist/router/browse.d.ts +108 -0
  112. package/dist/router/browse.js +214 -0
  113. package/dist/router/catalog.d.ts +18 -0
  114. package/dist/router/catalog.js +6 -0
  115. package/dist/router/extract-content.d.ts +13 -0
  116. package/dist/router/extract-content.js +87 -0
  117. package/dist/router/live-extension-browser.d.ts +35 -0
  118. package/dist/router/live-extension-browser.js +175 -0
  119. package/dist/router/path.d.ts +7 -0
  120. package/dist/router/path.js +55 -0
  121. package/dist/router/read.d.ts +26 -0
  122. package/dist/router/read.js +30 -0
  123. package/dist/router/readiness.d.ts +9 -0
  124. package/dist/router/readiness.js +55 -0
  125. package/dist/router/replay.d.ts +14 -0
  126. package/dist/router/replay.js +23 -0
  127. package/dist/router/resolve.d.ts +21 -0
  128. package/dist/router/resolve.js +33 -0
  129. package/dist/router/search-live.d.ts +21 -0
  130. package/dist/router/search-live.js +98 -0
  131. package/dist/router/search-providers.d.ts +5 -0
  132. package/dist/router/search-providers.js +12 -0
  133. package/dist/router/search.d.ts +6 -0
  134. package/dist/router/search.js +62 -0
  135. package/dist/router/suite.d.ts +69 -0
  136. package/dist/router/suite.js +226 -0
  137. package/dist/router/tokens.d.ts +13 -0
  138. package/dist/router/tokens.js +26 -0
  139. package/dist/router/walk-live.d.ts +57 -0
  140. package/dist/router/walk-live.js +308 -0
  141. package/dist/router/walk-session.d.ts +49 -0
  142. package/dist/router/walk-session.js +90 -0
  143. package/dist/router/walk.d.ts +48 -0
  144. package/dist/router/walk.js +384 -0
  145. package/package.json +59 -0
package/dist/cli.js ADDED
@@ -0,0 +1,2558 @@
1
+ import { topLevelHelp, commandHelp } from './cli-help.js';
2
+ import { VERSION, COMMANDS } from './cli-spec.js';
3
+ import { wireSessionName } from './playwright/adapter.js';
4
+ import { dbPath } from './paths.js';
5
+ // Pull the value following a flag (or one of its aliases) out of an arg list.
6
+ function flagValue(args, ...names) {
7
+ for (const name of names) {
8
+ const i = args.indexOf(name);
9
+ if (i !== -1)
10
+ return args[i + 1];
11
+ }
12
+ return undefined;
13
+ }
14
+ // Collect EVERY value of a repeated flag (e.g. `--session a --session b`). Used by
15
+ // graph-analyse to draft from MULTIPLE recording sessions of one site in one pass.
16
+ function flagValues(args, name) {
17
+ const out = [];
18
+ for (let i = 0; i < args.length; i++)
19
+ if (args[i] === name && args[i + 1] !== undefined)
20
+ out.push(args[i + 1]);
21
+ return out;
22
+ }
23
+ // Browser launch flags shared by the verbs that open a browser (read / navigate /
24
+ // walk). Default is HEADED — a real visible window — so every run is watchable;
25
+ // pass `--headless` to opt out (CI / gated live tests). `--headed` is still
26
+ // accepted as an explicit no-op for back-compat. `--persistent` / `--profile
27
+ // <dir>` reuse a logged-in profile; `--browser chrome|firefox|webkit|msedge`
28
+ // picks the engine.
29
+ function browserOpts(args) {
30
+ const has = (f) => args.includes(f);
31
+ const o = {};
32
+ o.headed = !has('--headless'); // headed by default; --headless opts out
33
+ if (has('--persistent'))
34
+ o.persistent = true;
35
+ const profile = flagValue(args, '--profile');
36
+ if (profile) {
37
+ o.profile = profile;
38
+ o.persistent = true;
39
+ } // a profile implies persistent
40
+ const browser = flagValue(args, '--browser');
41
+ if (browser)
42
+ o.browser = browser;
43
+ return o;
44
+ }
45
+ // Collect repeated `--input slot=value` flags into a map. Runtime-only values
46
+ // (credentials, form fields) — the walk forwards slot NAMES, never stores values.
47
+ function inputFlags(args) {
48
+ const out = {};
49
+ for (let i = 0; i < args.length; i++) {
50
+ if (args[i] === '--input' && args[i + 1]) {
51
+ const [k, ...rest] = args[i + 1].split('=');
52
+ out[k] = rest.join('=');
53
+ i++;
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+ const KNOWN_VERBS = new Set([...COMMANDS.map((c) => c.name), 'read']);
59
+ export function parseArgs(argv) {
60
+ // Global help/version and empty argv are checked BEFORE the verb switch.
61
+ if (argv.length === 0)
62
+ return { cmd: 'help' };
63
+ const first = argv[0];
64
+ if (first === '--help' || first === '-h')
65
+ return { cmd: 'help' };
66
+ if (first === '--version' || first === '-V')
67
+ return { cmd: 'version' };
68
+ const [cmd, ...rest] = argv;
69
+ // Per-command trailing help: `webnav <verb> --help` → help for that verb.
70
+ // Checked after identifying the verb so `recall --help` doesn't try to run.
71
+ if (KNOWN_VERBS.has(cmd) && (rest.includes('--help') || rest.includes('-h'))) {
72
+ return { cmd: 'help', command: cmd };
73
+ }
74
+ if (cmd === 'list')
75
+ return { cmd };
76
+ if (cmd === 'read') {
77
+ // First non-flag positional is the URL, so `read --raw <url>` and
78
+ // `read <url> --raw` both work (agents write the flag in either order).
79
+ const url = rest.find((a) => !a.startsWith('--')) ?? '';
80
+ return { cmd, url, raw: rest.includes('--raw'), browser: browserOpts(rest) };
81
+ }
82
+ if (cmd === 'capture')
83
+ return { cmd, url: rest[0], out: rest[1] };
84
+ if (cmd === 'use') {
85
+ const sub = rest[0];
86
+ if (!sub || sub === '--help' || sub === '-h')
87
+ return { cmd: 'use-help' };
88
+ return parseArgs([sub, ...rest.slice(1)]);
89
+ }
90
+ if (cmd === 'dev') {
91
+ const sub = rest[0];
92
+ if (!sub || sub === '--help' || sub === '-h')
93
+ return { cmd: 'dev-help' };
94
+ return parseArgs([sub, ...rest.slice(1)]);
95
+ }
96
+ if (cmd === 'search') {
97
+ const query = rest[0];
98
+ const top = rest.includes('--top') ? Number(rest[rest.indexOf('--top') + 1]) : 3;
99
+ return { cmd, query, top };
100
+ }
101
+ if (cmd === 'node-add') {
102
+ return { cmd, id: rest[0], url: flagValue(rest, '--url') ?? '' };
103
+ }
104
+ if (cmd === 'eval') {
105
+ const pos = rest.filter((a) => !a.startsWith('--'));
106
+ return { cmd, url: pos[0], js: pos[1] };
107
+ }
108
+ if (cmd === 'network') {
109
+ const pos = rest.filter((a) => !a.startsWith('--'));
110
+ return { cmd, url: pos[0] };
111
+ }
112
+ if (cmd === 'go-back')
113
+ return { cmd, session: flagValue(rest, '--session') };
114
+ if (cmd === 'reload')
115
+ return { cmd, session: flagValue(rest, '--session') };
116
+ if (cmd === 'close')
117
+ return { cmd, session: flagValue(rest, '--session') ?? rest.find((a) => !a.startsWith('--')) ?? '' };
118
+ if (cmd === 'session')
119
+ return { cmd, session: flagValue(rest, '--session') ?? '', url: flagValue(rest, '--url') ?? rest.find((a) => !a.startsWith('--')) ?? 'about:blank', browser: browserOpts(rest), profile: flagValue(rest, '--profile') };
120
+ if (cmd === 'record-start')
121
+ return { cmd, session: flagValue(rest, '--session') ?? '' };
122
+ if (cmd === 'record-stop')
123
+ return { cmd, session: flagValue(rest, '--session') ?? '' };
124
+ if (cmd === 'record-live')
125
+ return { cmd, session: flagValue(rest, '--session') ?? '', url: flagValue(rest, '--url') ?? '', interval: Number(flagValue(rest, '--interval') ?? 500), browser: browserOpts(rest) };
126
+ // session comes from --session, falling back to the first positional — both humans
127
+ // and agents naturally type `graph-analyse <id> --draft`, and the flag-only parse
128
+ // silently queried session '' and reported "empty" (live-acceptance trap).
129
+ // sessions: repeated --session flags, or a single positional; --host <h> auto-includes
130
+ // every recorded session for that host (drafts the whole site from all its drives at once).
131
+ if (cmd === 'graph-analyse') {
132
+ const multi = flagValues(rest, '--session');
133
+ const host = flagValue(rest, '--host');
134
+ // positional session fallback (`graph-analyse <id>`) ONLY when no --session/--host given —
135
+ // else `--host x` would grab `x` as a positional. Skip a value that follows a value-flag.
136
+ const flagVals = new Set(['--host', '--session', '--browser', '--profile'].flatMap((f) => { const i = rest.indexOf(f); return i >= 0 ? [rest[i + 1]] : []; }));
137
+ const pos = (!multi.length && !host) ? rest.find((a) => !a.startsWith('--') && !flagVals.has(a)) : undefined;
138
+ const sessions = multi.length ? multi : (pos ? [pos] : []);
139
+ return { cmd, sessions, host, draft: rest.includes('--draft'), skipReviewGate: rest.includes('--skip-review-gate') };
140
+ }
141
+ if (cmd === 'graph-edit')
142
+ return { cmd, node: flagValue(rest, '--node') ?? '', graph: flagValue(rest, '--graph') ?? '' };
143
+ if (cmd === 'graph-show')
144
+ return { cmd, node: flagValue(rest, '--node') ?? '' };
145
+ if (cmd === 'node-clear')
146
+ return { cmd, node: flagValue(rest, '--node') ?? '' };
147
+ if (cmd === 'node-rm')
148
+ return { cmd, node: flagValue(rest, '--node') ?? '' };
149
+ if (cmd === 'import-map')
150
+ return { cmd, file: flagValue(rest, '--file') ?? rest[0] ?? '' };
151
+ if (cmd === 'pattern-propose') {
152
+ return { cmd, fromUnknown: flagValue(rest, '--from-unknown') ?? '', name: flagValue(rest, '--name') ?? '', lint: flagValue(rest, '--lint') };
153
+ }
154
+ if (cmd === 'export-map')
155
+ return { cmd, node: flagValue(rest, '--node') ?? rest[0] ?? '' };
156
+ // outline/mermaid take the site as a positional OR --node (ergonomic: `outline <site>`).
157
+ if (cmd === 'outline')
158
+ return { cmd, node: flagValue(rest, '--node') ?? rest[0] ?? '' };
159
+ if (cmd === 'mermaid')
160
+ return { cmd, node: flagValue(rest, '--node') ?? rest[0] ?? '' };
161
+ // frontier: --node (or positional) + repeatable --exclude <label> (caller's hard "never click" list).
162
+ if (cmd === 'frontier') {
163
+ const excludeVals = new Set(flagValues(rest, '--exclude'));
164
+ const node = flagValue(rest, '--node') ?? rest.find((a) => !a.startsWith('--') && !excludeVals.has(a)) ?? '';
165
+ return { cmd, node, exclude: [...excludeVals] };
166
+ }
167
+ if (cmd === 'effects')
168
+ return { cmd, session: flagValue(rest, '--session') ?? '' };
169
+ if (cmd === 'record-rename')
170
+ return { cmd, from: flagValue(rest, '--from') ?? '', to: flagValue(rest, '--to') ?? '' };
171
+ if (cmd === 'review')
172
+ return { cmd, session: flagValue(rest, '--session') ?? rest.find((a) => !a.startsWith('--')) ?? '', model: flagValue(rest, '--model') ?? 'sonnet', instructions: flagValue(rest, '--instructions') };
173
+ if (cmd === 'capture-loop')
174
+ return { cmd, objective: flagValue(rest, '--objective') ?? '', exploreCmd: flagValue(rest, '--explore-cmd') ?? '', sessionPrefix: flagValue(rest, '--session-prefix') ?? 'cl', maxRounds: Number(flagValue(rest, '--max-rounds') ?? 5), model: flagValue(rest, '--model') ?? 'sonnet' };
175
+ if (cmd === 'verify')
176
+ return { cmd, node: flagValue(rest, '--node') ?? '', session: flagValue(rest, '--session') ?? '' };
177
+ if (cmd === 'hover-probe')
178
+ return { cmd, session: flagValue(rest, '--session') ?? '', limit: Number(flagValue(rest, '--limit') ?? 12), rightClick: rest.includes('--right-click') };
179
+ if (cmd === 'profile-status') {
180
+ return { cmd, profile: flagValue(rest, '--profile') ?? '', site: flagValue(rest, '--site') ?? '', url: flagValue(rest, '--url') };
181
+ }
182
+ if (cmd === 'sessions') {
183
+ const maxAge = flagValue(rest, '--max-age-hours');
184
+ return { cmd, sub: rest.find((a) => !a.startsWith('--')) ?? 'list',
185
+ all: rest.includes('--all'), maxAgeHours: maxAge ? Number(maxAge) : undefined };
186
+ }
187
+ if (cmd === 'mcp')
188
+ return { cmd };
189
+ if (cmd === 'dashboard') {
190
+ const portFlag = flagValue(rest, '--port');
191
+ const port = Number(portFlag ?? process.env.WEBNAV_PORT ?? 7777);
192
+ return { cmd, port, open: rest.includes('--open') };
193
+ }
194
+ if (cmd === 'ingest')
195
+ return { cmd, port: Number(flagValue(rest, '--port') ?? 7778) };
196
+ if (cmd === 'agent-serve')
197
+ return { cmd, port: Number(flagValue(rest, '--port') ?? 7779), token: flagValue(rest, '--token') };
198
+ if (cmd === 'walk') {
199
+ return { cmd, start: flagValue(rest, '--start') ?? '', goal: flagValue(rest, '--goal') ?? '',
200
+ inputs: inputFlags(rest), browser: browserOpts(rest), hosted: rest.includes('--hosted'),
201
+ observe: flagValues(rest, '--observe'), observeDynamic: rest.includes('--observe-dynamic') };
202
+ }
203
+ if (cmd === 'test') {
204
+ return { cmd, suite: flagValue(rest, '--suite') ?? rest.find((a) => !a.startsWith('--')) ?? '', browser: browserOpts(rest) };
205
+ }
206
+ if (cmd === 'walk-resume') {
207
+ return { cmd, session: rest.find((a) => !a.startsWith('--')) ?? '',
208
+ ref: flagValue(rest, '--ref'), classify: flagValue(rest, '--classify'),
209
+ continue: rest.includes('--continue'),
210
+ inputs: inputFlags(rest) };
211
+ }
212
+ if (cmd === 'login') {
213
+ return { cmd, key: rest.find((a) => !a.startsWith('--')) ?? '' };
214
+ }
215
+ if (cmd === 'creds') {
216
+ // creds set <site> key=value... | creds list | creds rm <site> [key]
217
+ const sub = rest[0] ?? '';
218
+ const pos = rest.slice(1).filter((a) => !a.startsWith('--') && !a.includes('='));
219
+ const values = {};
220
+ for (const a of rest.slice(1)) {
221
+ if (a.includes('=') && !a.startsWith('--')) {
222
+ const [k, ...v] = a.split('=');
223
+ values[k] = v.join('=');
224
+ }
225
+ }
226
+ return { cmd, sub, site: pos[0], key: pos[1], values };
227
+ }
228
+ if (cmd === 'navigate') {
229
+ const pos = rest.filter((a) => !a.startsWith('--'));
230
+ return { cmd, url: pos[0] ?? '', session: flagValue(rest, '--session') ?? '', browser: browserOpts(rest) };
231
+ }
232
+ if (cmd === 'snapshot')
233
+ return { cmd, session: flagValue(rest, '--session') ?? '' };
234
+ if (cmd === 'click') {
235
+ const pos = rest.filter((a) => !a.startsWith('--'));
236
+ return { cmd, ref: pos[0] ?? '', session: flagValue(rest, '--session') ?? '' };
237
+ }
238
+ if (cmd === 'type') {
239
+ const sessionVal = flagValue(rest, '--session');
240
+ const pos = rest.filter((a) => !a.startsWith('--') && a !== sessionVal);
241
+ return { cmd, ref: pos[0] ?? '', text: pos[1] ?? '', session: sessionVal ?? '' };
242
+ }
243
+ throw new Error(`unknown command: ${cmd}\nRun \`webnav --help\` to see available commands.`);
244
+ }
245
+ async function main() {
246
+ const rawArgs = process.argv.slice(2);
247
+ // --json is a global output-mode flag detected directly in main() rather than
248
+ // threaded through the ParsedArgs union (which would complicate every variant).
249
+ // Under --json, ONLY pure JSON is written to stdout; all diagnostics → stderr.
250
+ const json = rawArgs.includes('--json');
251
+ const args = parseArgs(rawArgs);
252
+ if (args.cmd === 'help') {
253
+ // Help is informational: pure stdout, exit 0.
254
+ console.log(args.command ? commandHelp(args.command) : topLevelHelp());
255
+ return;
256
+ }
257
+ if (args.cmd === 'version') {
258
+ console.log(VERSION);
259
+ return;
260
+ }
261
+ if (args.cmd === 'list') {
262
+ // "what's on this map?" — the sites webnav has a map for + their state counts.
263
+ const { MapStore } = await import('./mapstore/store.js');
264
+ const { listCoverage } = await import('./router/catalog.js');
265
+ console.log(JSON.stringify(listCoverage(new MapStore(dbPath())), null, 2));
266
+ return;
267
+ }
268
+ if (args.cmd === 'read') {
269
+ const { readUrl } = await import('./router/read.js');
270
+ const { PlaywrightAdapter } = await import('./playwright/adapter.js');
271
+ const adapter = new PlaywrightAdapter(`read-${Date.now()}`, undefined, undefined, args.browser);
272
+ // open then snapshot WITH READINESS RETRY — a JS SPA renders after the first paint, so a
273
+ // bare immediate snapshot catches an unfinished shell and read wrongly reports
274
+ // `blocked: loading`. snapshotReady waits for the page to actually render.
275
+ const fetchSnapshot = async (u) => { await adapter.open(u); return adapter.snapshotReady(); };
276
+ const r = await readUrl(args.url, fetchSnapshot, { raw: args.raw });
277
+ await adapter.close().catch(() => { });
278
+ console.log(JSON.stringify(r, null, 2));
279
+ if (r.status !== 'done')
280
+ process.exitCode = 3;
281
+ return;
282
+ }
283
+ if (args.cmd === 'use-help') {
284
+ console.log(topLevelHelp());
285
+ return;
286
+ }
287
+ if (args.cmd === 'dev-help') {
288
+ const { devHelp } = await import('./cli-help.js');
289
+ console.log(devHelp());
290
+ return;
291
+ }
292
+ if (args.cmd === 'capture') {
293
+ const { capture } = await import('./playwright/capture.js');
294
+ await capture(args.url, args.out);
295
+ // Under --json, emit a clean JSON receipt instead of human prose so stdout
296
+ // stays pure JSON. Otherwise the human-friendly progress line goes to stderr.
297
+ if (json) {
298
+ console.log(JSON.stringify({ captured: args.url, out: args.out }));
299
+ }
300
+ else {
301
+ console.error(`captured ${args.url} -> ${args.out}`);
302
+ }
303
+ return;
304
+ }
305
+ if (args.cmd === 'search') {
306
+ // search: open-web search — search engine → top-N results → visit + extract
307
+ // answer-evidence. Prints a SearchGatherResult JSON for the calling agent.
308
+ const { runSearchLive } = await import('./router/search-live.js');
309
+ const response = await runSearchLive(args.query, args.top);
310
+ console.log(JSON.stringify(response, null, 2));
311
+ // "ran fine but found nothing / blocked" → exit 3 so an agent's shell can
312
+ // distinguish a clean empty result from a crash.
313
+ if (isEmptyOrFailed(response))
314
+ process.exitCode = 3;
315
+ return;
316
+ }
317
+ if (args.cmd === 'node-add') {
318
+ // node-add: teach webnav a new site (persisted; the viz UI reads the same store).
319
+ const { MapStore } = await import('./mapstore/store.js');
320
+ const { ensureSeeded } = await import('./graph/seed.js');
321
+ const { addNode } = await import('./graph/teach.js');
322
+ const store = new MapStore();
323
+ ensureSeeded(store);
324
+ const node = addNode(store, { id: args.id, homeUrl: args.url });
325
+ console.log(JSON.stringify(node, null, 2));
326
+ return;
327
+ }
328
+ if (args.cmd === 'eval') {
329
+ const { runEval } = await import('./router/browse.js');
330
+ const r = await runEval(args.url, args.js);
331
+ console.log(JSON.stringify(r, null, 2));
332
+ if (r.status !== 'done')
333
+ process.exitCode = 3;
334
+ return;
335
+ }
336
+ if (args.cmd === 'network') {
337
+ const { runNetwork } = await import('./router/browse.js');
338
+ const r = await runNetwork(args.url);
339
+ console.log(JSON.stringify(r, null, 2));
340
+ if (r.status !== 'done')
341
+ process.exitCode = 3;
342
+ return;
343
+ }
344
+ if (args.cmd === 'session') {
345
+ // Interactive long-lived agent session: ONE process owns the browser + video +
346
+ // record loop, reads JSON-line commands on stdin, writes JSON results on stdout,
347
+ // closes cleanly on quit/EOF. This is the shape that lets video span the whole
348
+ // session and leaks nothing (spec: 2026-07-09-interactive-agent-session-design.md).
349
+ if (!args.session) {
350
+ console.log(JSON.stringify({ ok: false, error: 'usage: webnav use session --session <S> [--url <U>] [--profile <name>]' }));
351
+ process.exitCode = 2;
352
+ return;
353
+ }
354
+ const { PlaywrightAdapter } = await import('./playwright/adapter.js');
355
+ const { RecordStore } = await import('./mapstore/record.js');
356
+ const { runAgentSession, OVERLAY_ON_JS } = await import('./recorder/agent-session.js');
357
+ const { parseSnapshot } = await import('./playwright/snapshot.js');
358
+ const { recoverFingerprint } = await import('./playwright/fingerprint.js');
359
+ const { homedir } = await import('node:os');
360
+ const { join } = await import('node:path');
361
+ const { mkdirSync } = await import('node:fs');
362
+ const readline = await import('node:readline');
363
+ const store = new RecordStore(dbPath());
364
+ const videosRoot = join(homedir(), '.webnav', 'recordings');
365
+ const profilesRoot = join(homedir(), '.webnav', 'profiles');
366
+ // profile resolve + prep (same discipline as every other launch path)
367
+ const sbrowser = { ...args.browser };
368
+ // Maximized-window config for HEADED capture sessions (repo-root playwright-cli.json;
369
+ // adapter applies it only when headed). Resolved from THIS module so CWD doesn't matter.
370
+ if (sbrowser.headed) {
371
+ const { fileURLToPath } = await import('node:url');
372
+ sbrowser.configPath = join(fileURLToPath(new URL('.', import.meta.url)), '..', 'playwright-cli.json');
373
+ }
374
+ let profName = null;
375
+ if (args.profile) {
376
+ const { resolveProfile } = await import('./playwright/adapter.js');
377
+ const { prepProfile } = await import('./playwright/profile-lock.js');
378
+ sbrowser.profile = resolveProfile(args.profile, profilesRoot);
379
+ profName = sbrowser.profile.split('/').pop() ?? null;
380
+ try {
381
+ mkdirSync(sbrowser.profile, { recursive: true });
382
+ }
383
+ catch { /* */ }
384
+ prepProfile(sbrowser.profile);
385
+ }
386
+ const adapter = new PlaywrightAdapter(args.session, undefined, undefined, sbrowser);
387
+ // realtime → dashboard: best-effort POST to /api/notify so it pushes SSE. The
388
+ // dashboard is a SEPARATE process; this is the cross-process bridge.
389
+ const dashPort = Number(process.env.WEBNAV_DASHBOARD_PORT ?? 7777);
390
+ const notify = (kind, line) => {
391
+ const body = JSON.stringify({ kind, line });
392
+ // fire-and-forget; a missing dashboard is fine (store still has the truth)
393
+ import('node:http').then(({ request }) => {
394
+ const req = request({ host: '127.0.0.1', port: dashPort, path: '/api/notify', method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } }, (res) => res.resume());
395
+ req.on('error', () => { });
396
+ req.write(body);
397
+ req.end();
398
+ }).catch(() => { });
399
+ };
400
+ try {
401
+ await adapter.open(args.url);
402
+ if (sbrowser.profile && args.url && args.url !== 'about:blank') {
403
+ try {
404
+ await adapter.goto(args.url);
405
+ }
406
+ catch { /* past restored tab */ }
407
+ }
408
+ await adapter.evalJs(OVERLAY_ON_JS).catch(() => { }); // best-effort: video overlay on the starting page
409
+ store.start(args.session);
410
+ store.setOrigin(args.session, 'agent');
411
+ if (profName)
412
+ store.setProfile(args.session, profName);
413
+ if (args.url && args.url !== 'about:blank')
414
+ store.setStartUrl(args.session, args.url);
415
+ const rl = readline.createInterface({ input: process.stdin });
416
+ const lines = [];
417
+ const waiters = [];
418
+ let ended = false;
419
+ rl.on('line', (l) => { const w = waiters.shift(); if (w)
420
+ w(l);
421
+ else
422
+ lines.push(l); });
423
+ rl.on('close', () => { ended = true; while (waiters.length)
424
+ waiters.shift()(null); });
425
+ const readLine = () => new Promise((resolve) => {
426
+ if (lines.length)
427
+ return resolve(lines.shift());
428
+ if (ended)
429
+ return resolve(null);
430
+ waiters.push(resolve);
431
+ });
432
+ await runAgentSession({
433
+ sessionId: args.session, adapter: adapter, store: store,
434
+ recover: (snap, ref) => {
435
+ const nodes = parseSnapshot(snap);
436
+ const chosen = nodes.find((n) => n.ref === ref);
437
+ const elementFp = recoverFingerprint(nodes, ref);
438
+ return { action: chosen ? { role: chosen.role, name: chosen.name, ref, elementFp } : { role: '', name: null, ref } };
439
+ },
440
+ readLine, write: (l) => process.stdout.write(l + '\n'), notify,
441
+ startVideo: async () => {
442
+ try {
443
+ await adapter.videoStart();
444
+ notify('log', 'video: recording started');
445
+ }
446
+ catch {
447
+ notify('log', 'video: START FAILED');
448
+ }
449
+ },
450
+ stopVideo: async () => {
451
+ const dir = join(videosRoot, args.session);
452
+ try {
453
+ mkdirSync(dir, { recursive: true });
454
+ }
455
+ catch { /* */ }
456
+ const file = join(dir, 'take-' + Date.now() + '.webm');
457
+ const ok = await adapter.videoStop(file).catch(() => false);
458
+ const { existsSync } = await import('node:fs');
459
+ // video-stop can return before the .webm is fully flushed; poll briefly so
460
+ // the file is present before this process exits (else the write is lost).
461
+ for (let i = 0; ok && i < 25 && !existsSync(file); i++)
462
+ await new Promise((r) => setTimeout(r, 200));
463
+ if (ok && existsSync(file)) {
464
+ notify('log', 'video: saved ' + file);
465
+ return file;
466
+ }
467
+ notify('log', 'video: no frames captured');
468
+ return null;
469
+ },
470
+ startUrl: args.url,
471
+ });
472
+ rl.close();
473
+ }
474
+ finally {
475
+ store.stop(args.session);
476
+ notify('sessions');
477
+ }
478
+ return;
479
+ }
480
+ if (args.cmd === 'close') {
481
+ // Explicit teardown for a `use` session (agent's "I'm done" — the `use` verbs
482
+ // keep the browser alive between calls, so SOMETHING must close it; this is it).
483
+ if (!args.session) {
484
+ console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav use close --session <S>' }, null, 2));
485
+ process.exitCode = 2;
486
+ return;
487
+ }
488
+ const { closeByName } = await import('./playwright/sessions.js');
489
+ let closed = false;
490
+ try {
491
+ closed = await closeByName(args.session);
492
+ }
493
+ catch { /* already gone */ }
494
+ console.log(JSON.stringify({ status: closed ? 'closed' : 'not-found', session: args.session }, null, 2));
495
+ if (!closed)
496
+ process.exitCode = 3;
497
+ return;
498
+ }
499
+ if (args.cmd === 'go-back' || args.cmd === 'reload') {
500
+ const { PlaywrightAdapter } = await import('./playwright/adapter.js');
501
+ // These only make sense against an EXISTING session the agent has been
502
+ // driving (a fresh session has no page to go back to). --session names it;
503
+ // default 'webnav-nav' is the convenience session for a quick standalone step.
504
+ const adapter = new PlaywrightAdapter(args.session ?? 'webnav-nav');
505
+ try {
506
+ const out = args.cmd === 'go-back' ? await adapter.goBack() : await adapter.reload();
507
+ console.log(JSON.stringify({ status: 'done', action: args.cmd, out: out.trim() }, null, 2));
508
+ }
509
+ catch (e) {
510
+ console.log(JSON.stringify({ status: 'failed', action: args.cmd, reason: String(e) }, null, 2));
511
+ process.exitCode = 3;
512
+ }
513
+ return;
514
+ }
515
+ if (args.cmd === 'record-start') {
516
+ const { RecordStore } = await import('./mapstore/record.js');
517
+ const rec = new RecordStore(dbPath());
518
+ const session = args.session || `map-${Date.now()}`;
519
+ rec.start(session);
520
+ rec.setOrigin(session, 'agent');
521
+ console.log(JSON.stringify({ status: 'recording', session }, null, 2));
522
+ return;
523
+ }
524
+ if (args.cmd === 'record-stop') {
525
+ const { RecordStore } = await import('./mapstore/record.js');
526
+ new RecordStore(dbPath()).stop(args.session);
527
+ // Close the browser too — record-stop is the end of an agent session, so its
528
+ // window must not leak (the `use` verbs keep the session alive between calls;
529
+ // record-stop is the sanctioned teardown). A long-lived `record-live` owns its
530
+ // own adapter and closes on its own loop end — this closeByName is a no-op there.
531
+ const { closeByName } = await import('./playwright/sessions.js');
532
+ let closed = false;
533
+ try {
534
+ closed = await closeByName(args.session);
535
+ }
536
+ catch { /* already gone */ }
537
+ console.log(JSON.stringify({ status: 'stopped', session: args.session, closed }, null, 2));
538
+ return;
539
+ }
540
+ if (args.cmd === 'record-live') {
541
+ // Human-driven recording (vs. record-start's agent-driven `use` loop): opens a headed
542
+ // browser, then runs Task 3's poll loop until Ctrl-C or `record-stop` flips isActive off.
543
+ // Long-lived like `ingest`/`dashboard` — does NOT print-and-exit until stopped.
544
+ if (!args.session || !args.url) {
545
+ console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav dev record-live --session <S> --url <U>' }, null, 2));
546
+ process.exitCode = 2;
547
+ return;
548
+ }
549
+ const { runLiveRecord } = await import('./recorder/live-record.js');
550
+ const { PlaywrightAdapter } = await import('./playwright/adapter.js');
551
+ const { RecordStore } = await import('./mapstore/record.js');
552
+ const { homedir } = await import('node:os');
553
+ const { join } = await import('node:path');
554
+ const store = new RecordStore(dbPath());
555
+ store.start(args.session);
556
+ store.setOrigin(args.session, 'manual');
557
+ const videosRoot = join(homedir(), '.webnav', 'recordings');
558
+ // --profile resolve + prep (parity with the dashboard/agent paths): one
559
+ // long-lived process OWNS the session, so video capture survives the whole span.
560
+ const rlBrowser = { ...args.browser };
561
+ if (rlBrowser.profile) {
562
+ const { resolveProfile } = await import('./playwright/adapter.js');
563
+ const { prepProfile } = await import('./playwright/profile-lock.js');
564
+ rlBrowser.profile = resolveProfile(rlBrowser.profile, join(homedir(), '.webnav', 'profiles'));
565
+ try {
566
+ (await import('node:fs')).mkdirSync(rlBrowser.profile, { recursive: true });
567
+ }
568
+ catch { /* */ }
569
+ prepProfile(rlBrowser.profile);
570
+ store.setProfile(args.session, rlBrowser.profile.split('/').pop());
571
+ }
572
+ store.setStartUrl(args.session, args.url);
573
+ // finally-guard: a throw anywhere below (adapter.open on a dead URL, the loop
574
+ // itself) must not leave the record session dangling active=1 in the DB.
575
+ try {
576
+ const adapter = new PlaywrightAdapter(args.session, undefined, undefined, rlBrowser);
577
+ await adapter.open(args.url);
578
+ // Video: started here, stopped in finally — SAME process owns the session the
579
+ // whole time, so playwright-cli actually records it (the split-process agent
580
+ // flow could not — this long-lived process is how agent sessions get video).
581
+ // Video: started here, stopped in onBeforeClose (while the session is still
582
+ // open — after adapter.close() playwright-cli saves nothing). One long-lived
583
+ // process owns the session, so unlike the split agent CLI calls this CAN record.
584
+ const { mkdirSync } = await import('node:fs');
585
+ let videoStarted = false;
586
+ await adapter.videoStart().then(() => { videoStarted = true; process.stderr.write('video: recording started\n'); }, () => process.stderr.write('video: START FAILED\n'));
587
+ let stopped = false;
588
+ process.on('SIGINT', () => { stopped = true; });
589
+ process.stderr.write(`recording — drive the browser (human clicks or agent \`use\` on session ${args.session}); stop with Ctrl-C or \`webnav dev record-stop --session ${args.session}\`\n`);
590
+ const res = await runLiveRecord({
591
+ adapter, store, sessionId: args.session, intervalMs: args.interval,
592
+ log: (l) => process.stderr.write(l + '\n'), isStopped: () => stopped,
593
+ onBeforeClose: async () => {
594
+ if (!videoStarted)
595
+ return;
596
+ const dir = join(videosRoot, args.session);
597
+ try {
598
+ mkdirSync(dir, { recursive: true });
599
+ }
600
+ catch { /* */ }
601
+ const file = join(dir, 'take-' + Date.now() + '.webm');
602
+ const ok = await adapter.videoStop(file).catch(() => false);
603
+ const { existsSync } = await import('node:fs');
604
+ process.stderr.write(ok && existsSync(file) ? 'video: saved ' + file + '\n' : 'video: no frames captured\n');
605
+ },
606
+ });
607
+ console.log(JSON.stringify({
608
+ status: 'stopped', session: args.session, appended: res.appended,
609
+ next: `webnav dev graph-analyse --session ${args.session} --draft`,
610
+ }, null, 2));
611
+ if (res.appended === 0)
612
+ process.exitCode = 3;
613
+ }
614
+ finally {
615
+ store.stop(args.session);
616
+ }
617
+ return;
618
+ }
619
+ if (args.cmd === 'graph-analyse') {
620
+ const { RecordStore } = await import('./mapstore/record.js');
621
+ const store = new RecordStore(dbPath());
622
+ // Resolve the session set: explicit --session (repeatable), or every recorded session
623
+ // for --host. MULTIPLE sessions of one site fold into ONE draft: their effects are
624
+ // concatenated in order and stable-pathname keying merges same-page visits automatically.
625
+ let sessionIds = args.sessions;
626
+ if (args.host) {
627
+ sessionIds = store.listSessions().filter((s) => s.site === args.host && s.steps > 0)
628
+ .map((s) => s.sessionId);
629
+ }
630
+ if (sessionIds.length === 0) {
631
+ console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav dev graph-analyse --session <S> [--session <S2> …] [--host <h>] [--draft]' }, null, 2));
632
+ process.exitCode = 2;
633
+ return;
634
+ }
635
+ // APPROVAL GATE: only build the map from sessions whose capture-review PASSED. A failed OR
636
+ // never-reviewed session is NOT trusted — training the graph on it would bake in whatever the
637
+ // review flagged (a missed step, a broken capture). --skip-review-gate bypasses for a
638
+ // deliberate raw build. Excluded sessions are reported, not silently dropped.
639
+ const excluded = [];
640
+ if (!args.skipReviewGate) {
641
+ const kept = [];
642
+ for (const id of sessionIds) {
643
+ const rev = store.reviewOf(id);
644
+ if (rev?.approved)
645
+ kept.push(id);
646
+ else
647
+ excluded.push({ session: id, reason: rev ? `review failed (${rev.gaps} gap${rev.gaps === 1 ? '' : 's'})` : 'never reviewed' });
648
+ }
649
+ sessionIds = kept;
650
+ if (sessionIds.length === 0) {
651
+ console.log(JSON.stringify({ status: 'error', reason: 'no APPROVED sessions to build from', excluded,
652
+ hint: 'run `webnav dev review --session <S>` until it passes, or pass --skip-review-gate to build from raw sessions anyway' }, null, 2));
653
+ process.exitCode = 2;
654
+ return;
655
+ }
656
+ }
657
+ const effects = sessionIds.flatMap((id) => store.actionEffects(id));
658
+ if (args.draft) {
659
+ // --draft: fold the recorded walk-through(s) into a ready, SELF-VERIFIED {node,states,edges}
660
+ // graph-edit spec (absolute URLs, uniqueness fingerprints, resolvable edges) so learning
661
+ // is "drive → accept", not hand-author. The agent pipes it straight to graph-edit.
662
+ const { draftFromEffects } = await import('./explorer/draft.js');
663
+ const draft = draftFromEffects(effects);
664
+ console.log(JSON.stringify({ status: draft.states.length ? 'done' : 'empty', sessions: sessionIds,
665
+ ...(excluded.length ? { excludedUnverified: excluded } : {}), ...draft }, null, 2));
666
+ if (draft.states.length === 0)
667
+ process.exitCode = 3;
668
+ return;
669
+ }
670
+ const { analyseActionEffects } = await import('./explorer/analyse.js');
671
+ const result = analyseActionEffects(effects);
672
+ console.log(JSON.stringify({ ...result, ...(excluded.length ? { excludedUnverified: excluded } : {}) }, null, 2));
673
+ if (result.sites.length === 0)
674
+ process.exitCode = 3;
675
+ return;
676
+ }
677
+ if (args.cmd === 'ingest') {
678
+ // Long-lived localhost receiver (like `dashboard`/`mcp`): does NOT print-and-exit.
679
+ // The webnav-extension Chrome extension POSTs recorded sessions here; they land
680
+ // in webnav.db as ActionEffects via `serveIngest` -> `ingest` (Task 2).
681
+ const { serveIngest } = await import('./recorder/ingest.js');
682
+ const { RecordStore } = await import('./mapstore/record.js');
683
+ const server = serveIngest(args.port, new RecordStore(dbPath()));
684
+ process.stderr.write(`webnav ingest listening on http://127.0.0.1:${args.port}/ingest\n`);
685
+ console.log(JSON.stringify({ status: 'listening', port: args.port }));
686
+ await new Promise(() => { }); // run until killed
687
+ return;
688
+ }
689
+ if (args.cmd === 'agent-serve') {
690
+ // Long-lived localhost receiver (like `ingest`/`dashboard`): the Chrome extension
691
+ // sidePanel's local server. Streams AgentEvent over SSE, accepts a goal, runs the
692
+ // agent loop over a real AgentChannel that drives the extension's tab, and mounts
693
+ // /ingest-ax so a live goal run is recorded through the same path human/agent
694
+ // recordings use (Task 3).
695
+ const { serveAgent } = await import('./agent/server.js');
696
+ const { RecordStore } = await import('./mapstore/record.js');
697
+ const { runAgentGoal } = await import('./agent/loop.js');
698
+ const { makeLiveExtensionBrowser } = await import('./router/live-extension-browser.js');
699
+ const { ingestAX } = await import('./recorder/ingest.js');
700
+ const { MapStore } = await import('./mapstore/store.js');
701
+ const { ensureSeeded } = await import('./graph/seed.js');
702
+ // One RecordStore for the server's lifetime: it backs BOTH the /ingest-ax route AND
703
+ // the end-of-run flush below, so an agent run lands in the same webnav.db `dev
704
+ // dashboard` reads and run-2 recall learns from.
705
+ const recordStore = new RecordStore(dbPath());
706
+ // ponytail: one MapStore for the server's lifetime — same seeded map the `walk`
707
+ // verb uses, so check_route/walkRoute route against the real states. Extra sites
708
+ // recorded via /ingest-ax build the RecordStore, not this map; that's fine — the
709
+ // loop drives manually when the live page isn't a known state.
710
+ const mapStore = new MapStore();
711
+ ensureSeeded(mapStore);
712
+ const states = mapStore.allStates();
713
+ // onGoal: a goal POST runs the loop. Inputs = {} for v1 — the goal body carries no
714
+ // site/start-state to key CredStore by; the extension user is already logged in on
715
+ // the live tab, and creds-injection is a walk-verb concern. Real SDK query runs.
716
+ const onGoal = async (goal, channel, emit, awaitApproval, signal, resumeSessionId, onSdkSession) => {
717
+ const browser = makeLiveExtensionBrowser(channel, {});
718
+ // /stop aborts `signal`; runAgentGoal bridges it to the SDK query's abortController
719
+ // (loop.ts), so /stop cancels the in-flight turn — not just the next browser command.
720
+ try {
721
+ await runAgentGoal({
722
+ goal: goal.goal,
723
+ sessionId: goal.sessionId,
724
+ mode: goal.mode === 'ask' ? 'ask' : 'act',
725
+ model: goal.model,
726
+ browser,
727
+ store: mapStore,
728
+ states,
729
+ emit,
730
+ awaitApproval,
731
+ signal,
732
+ // Conversation continuity: resume the SDK session the server remembered for this
733
+ // panel conversation, and hand the captured session id back so the NEXT goal
734
+ // resumes it too. No re-listing routes / re-orienting on follow-ups.
735
+ resumeSessionId,
736
+ onSdkSession,
737
+ });
738
+ }
739
+ finally {
740
+ // RECORD the run into the map on END (done / error / stop). Housekeeping never
741
+ // breaks the command (guardrail-sweep rule): a record failure is a warning, never
742
+ // a thrown goal. Empty runs (no action taken) record nothing.
743
+ try {
744
+ const steps = browser.getRecordedSteps();
745
+ if (steps.length) {
746
+ ingestAX({ sessionId: goal.sessionId, steps }, recordStore);
747
+ recordStore.setOrigin(goal.sessionId, 'extension'); // tag: driven by the Chrome extension
748
+ }
749
+ }
750
+ catch (e) {
751
+ process.stderr.write(`webnav agent-serve: recording flush failed (run unaffected): ${String(e)}\n`);
752
+ }
753
+ }
754
+ };
755
+ // Per-run auth secret: the extension must present it on every /api/agent/* call
756
+ // (header on POSTs, ?token= on the SSE GET). Without it any web page (DNS-rebind /
757
+ // localhost fetch) or local process could POST a goal and drive the user's browser.
758
+ // `--token <hex>` pins a stable token across restarts (paste once into the panel);
759
+ // omitted → a fresh random token every run, as before.
760
+ // Assemble a session video from the extension's CDP screencast frames. Frames are
761
+ // base64 JPEGs with ms-relative timestamps; the concat demuxer sets per-frame durations
762
+ // and `-r 15 -fps_mode cfr` re-times to a CONSTANT frame rate — without CFR the output
763
+ // has irregular PTS and no duration metadata, which browsers show as an unseekable video
764
+ // that jumps 0→end on play (the observed bug). Idle gaps are capped so a "thinking" pause
765
+ // doesn't become a frozen minute. Writes to ~/.webnav/recordings/<sessionId>/, exactly
766
+ // where the dashboard serves videos. Best-effort: failures log, never break the run.
767
+ const onScreencast = async (sessionId, frames) => {
768
+ if (!frames.length)
769
+ return;
770
+ const { homedir, tmpdir } = await import('node:os');
771
+ const { join } = await import('node:path');
772
+ const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import('node:fs');
773
+ const { spawn } = await import('node:child_process');
774
+ const tmp = mkdtempSync(join(tmpdir(), 'webnav-scr-'));
775
+ const MAX_HOLD_S = 1.5; // cap a single frame's on-screen time (idle/thinking gaps)
776
+ try {
777
+ const lines = [];
778
+ for (let i = 0; i < frames.length; i++) {
779
+ const f = join(tmp, 'f' + String(i).padStart(5, '0') + '.jpg');
780
+ writeFileSync(f, Buffer.from(frames[i].data, 'base64'));
781
+ const nextMs = i + 1 < frames.length ? frames[i + 1].timestampMs : frames[i].timestampMs + 500;
782
+ const dur = Math.min(MAX_HOLD_S, Math.max(0.06, (nextMs - frames[i].timestampMs) / 1000));
783
+ lines.push("file '" + f + "'", 'duration ' + dur.toFixed(3));
784
+ }
785
+ lines.push("file '" + join(tmp, 'f' + String(frames.length - 1).padStart(5, '0') + '.jpg') + "'"); // concat quirk: repeat last
786
+ const listPath = join(tmp, 'frames.txt');
787
+ writeFileSync(listPath, lines.join('\n'));
788
+ const outDir = join(homedir(), '.webnav', 'recordings', sessionId);
789
+ mkdirSync(outDir, { recursive: true });
790
+ const outFile = join(outDir, 'take-' + Date.now() + '.webm');
791
+ await new Promise((resolve, reject) => {
792
+ const ff = spawn('ffmpeg', ['-y', '-f', 'concat', '-safe', '0', '-i', listPath,
793
+ '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p',
794
+ '-r', '15', '-fps_mode', 'cfr', '-c:v', 'libvpx-vp9', '-b:v', '1M', outFile], { stdio: 'ignore' });
795
+ ff.on('error', reject);
796
+ ff.on('exit', (code) => code === 0 ? resolve() : reject(new Error('ffmpeg exit ' + code)));
797
+ });
798
+ process.stderr.write('video: saved ' + outFile + ' (' + frames.length + ' frames)\n');
799
+ }
800
+ catch (e) {
801
+ process.stderr.write('video: assembly failed (run unaffected): ' + String(e) + '\n');
802
+ }
803
+ finally {
804
+ try {
805
+ rmSync(tmp, { recursive: true, force: true });
806
+ }
807
+ catch { /* */ }
808
+ }
809
+ };
810
+ const { randomBytes } = await import('node:crypto');
811
+ const token = args.token || randomBytes(16).toString('hex');
812
+ const tokenMode = args.token ? 'pinned via --token' : 'random per-run';
813
+ const server = serveAgent(args.port, recordStore, { onGoal, token, onScreencast });
814
+ process.stderr.write(`webnav agent-serve on http://127.0.0.1:${args.port} token: ${token} (${tokenMode}) (paste this into the extension panel settings)\n`);
815
+ console.log(JSON.stringify({ status: 'listening', port: args.port, token }));
816
+ await new Promise(() => { }); // run until killed
817
+ return;
818
+ }
819
+ if (args.cmd === 'record-rename') {
820
+ // Rename a recording's id (DB row + its observations) AND move its on-disk video/review
821
+ // dirs, so a session reads as what it captures (reports-list) not an ad-hoc id (s1final).
822
+ if (!args.from || !args.to) {
823
+ console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav dev record-rename --from <id> --to <id>' }, null, 2));
824
+ process.exitCode = 2;
825
+ return;
826
+ }
827
+ const { RecordStore } = await import('./mapstore/record.js');
828
+ const { homedir } = await import('node:os');
829
+ const { join } = await import('node:path');
830
+ const { renameSync, existsSync } = await import('node:fs');
831
+ const ok = new RecordStore(dbPath()).renameSession(args.from, args.to);
832
+ if (!ok) {
833
+ console.log(JSON.stringify({ status: 'error', reason: `cannot rename: '${args.from}' unknown or '${args.to}' already exists` }, null, 2));
834
+ process.exitCode = 2;
835
+ return;
836
+ }
837
+ // move on-disk recordings/ + reviews/ dirs to match (best-effort; DB is the source of truth)
838
+ for (const root of ['recordings', 'reviews']) {
839
+ const src = join(homedir(), '.webnav', root, args.from);
840
+ const dst = join(homedir(), '.webnav', root, args.to);
841
+ if (existsSync(src) && !existsSync(dst)) {
842
+ try {
843
+ renameSync(src, dst);
844
+ }
845
+ catch { /* */ }
846
+ }
847
+ }
848
+ console.log(JSON.stringify({ status: 'done', from: args.from, to: args.to }, null, 2));
849
+ return;
850
+ }
851
+ if (args.cmd === 'review') {
852
+ // Audit ONE recorded session's VIDEO against its captured STEPS (Sonnet over ffmpeg
853
+ // frames) → capture gaps. Writes a review verdict tag on the session: APPROVED (zero
854
+ // gaps → graph-ready) or needs-fix (gaps listed). This is the per-session gate the
855
+ // user asked for. Reuses runSessionReview (the same call capture-loop makes).
856
+ if (!args.session) {
857
+ console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav dev review --session <S> [--model sonnet]' }, null, 2));
858
+ process.exitCode = 2;
859
+ return;
860
+ }
861
+ const { RecordStore } = await import('./mapstore/record.js');
862
+ const { runSessionReview } = await import('./recorder/review.js');
863
+ const { homedir } = await import('node:os');
864
+ const { join } = await import('node:path');
865
+ const store = new RecordStore(dbPath());
866
+ const fx = store.actionEffects(args.session);
867
+ if (fx.length === 0) {
868
+ console.log(JSON.stringify({ status: 'empty', session: args.session, hint: 'no captured steps — nothing to review' }, null, 2));
869
+ process.exitCode = 3;
870
+ return;
871
+ }
872
+ const steps = fx.map((e) => ({
873
+ seq: e.seq,
874
+ kind: e.action ? (e.action.hover ? 'hover' : e.navigated ? 'navigate' : e.action.role === 'textbox' ? 'input' : 'click') : (e.navigated ? 'jump' : 'observe'),
875
+ label: e.action?.name ?? e.toUrl, value: e.action?.value, capturedAt: e.capturedAt,
876
+ }));
877
+ const { coverage, landingStructure } = await import('./recorder/coverage.js');
878
+ const cov = coverage(store.events(args.session));
879
+ const videosRoot = join(homedir(), '.webnav', 'recordings');
880
+ const reviewsRoot = join(homedir(), '.webnav', 'reviews');
881
+ const res = await runSessionReview(args.session, {
882
+ videosDir: join(videosRoot, args.session), outDir: join(reviewsRoot, args.session),
883
+ steps, logs: [], log: (l) => process.stderr.write(l + '\n'),
884
+ claudeModel: args.model, instructions: args.instructions, structured: true,
885
+ knownDrops: cov.dropped, coverage: cov, structure: landingStructure(fx),
886
+ });
887
+ const gaps = typeof res === 'string' ? [] : res.gaps;
888
+ const approved = gaps.length === 0;
889
+ const at = Date.now();
890
+ store.setReview(args.session, { approved, gaps: gaps.length, at, model: args.model,
891
+ reason: approved ? 'all on-screen actions captured' : `${gaps.length} capture gap(s)` });
892
+ console.log(JSON.stringify({ status: approved ? 'approved' : 'needs-fix', session: args.session,
893
+ approved, gaps, coverage: cov, report: join(reviewsRoot, args.session, 'review.md') }, null, 2));
894
+ if (!approved)
895
+ process.exitCode = 3;
896
+ return;
897
+ }
898
+ if (args.cmd === 'effects') {
899
+ const { RecordStore } = await import('./mapstore/record.js');
900
+ const effects = new RecordStore(dbPath()).actionEffects(args.session);
901
+ console.log(JSON.stringify({ status: effects.length ? 'done' : 'empty', session: args.session, effects }, null, 2));
902
+ if (effects.length === 0)
903
+ process.exitCode = 3;
904
+ return;
905
+ }
906
+ if (args.cmd === 'capture-loop') {
907
+ // Self-improving capture loop: each round runs --explore-cmd (which drives ONE
908
+ // agent exploration of the objective via `use session`, recording session
909
+ // $WEBNAV_LOOP_SESSION), then a STRUCTURED review audits video-vs-steps for gaps.
910
+ // Converges on a clean audit; else exits 3 with the gaps for a recorder-code fix.
911
+ // webnav stays zero-LLM: the exploring AGENT lives in --explore-cmd (caller wires
912
+ // a Haiku driver), not here. spec: 2026-07-09-capture-improvement-loop-design.md
913
+ if (!args.objective || !args.exploreCmd) {
914
+ console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav dev capture-loop --objective "<text>" --explore-cmd "<cmd that drives $WEBNAV_LOOP_SESSION>" [--max-rounds N] [--model sonnet]' }, null, 2));
915
+ process.exitCode = 2;
916
+ return;
917
+ }
918
+ const { runCaptureLoop } = await import('./recorder/capture-loop.js');
919
+ const { runSessionReview } = await import('./recorder/review.js');
920
+ const { RecordStore } = await import('./mapstore/record.js');
921
+ const { execSync } = await import('node:child_process');
922
+ const { homedir } = await import('node:os');
923
+ const { join } = await import('node:path');
924
+ const store = new RecordStore(dbPath());
925
+ const videosRoot = join(homedir(), '.webnav', 'recordings');
926
+ const reviewsRoot = join(homedir(), '.webnav', 'reviews');
927
+ const result = await runCaptureLoop({
928
+ objective: args.objective, maxRounds: args.maxRounds,
929
+ log: (l) => process.stderr.write(l + '\n'),
930
+ explore: async (round) => {
931
+ // SHORT session name: the playwright-cli daemon socket path embeds it and
932
+ // macOS caps socket paths at ~104 chars (learned before). base36 seconds + round.
933
+ const stamp = Math.floor(Date.now() / 1000).toString(36); // ~6 chars
934
+ const session = (args.sessionPrefix + 'r' + round + stamp).slice(0, 14);
935
+ try {
936
+ execSync(args.exploreCmd, { stdio: 'inherit', timeout: 5 * 60_000,
937
+ env: { ...process.env, WEBNAV_LOOP_SESSION: session, WEBNAV_LOOP_OBJECTIVE: args.objective } });
938
+ }
939
+ catch { /* explore-cmd non-zero → treat as recorded-what-it-could */ }
940
+ // re-open the store to see the subprocess's committed writes (a long-lived
941
+ // connection can hold a stale snapshot across another process's commit).
942
+ const fresh = new RecordStore(dbPath());
943
+ return fresh.actionEffects(session).length ? session : null;
944
+ },
945
+ review: async (session) => {
946
+ const fresh = new RecordStore(dbPath());
947
+ const fx = fresh.actionEffects(session);
948
+ const steps = fx.map((e) => ({
949
+ seq: e.seq, kind: e.action ? (e.action.hover ? 'hover' : e.navigated ? 'navigate' : e.action.role === 'textbox' ? 'input' : 'click') : (e.navigated ? 'jump' : 'observe'),
950
+ label: e.action?.name ?? e.toUrl, value: e.action?.value, capturedAt: e.capturedAt,
951
+ }));
952
+ const { coverage, landingStructure } = await import('./recorder/coverage.js');
953
+ const cov = coverage(fresh.events(session));
954
+ const res = await runSessionReview(session, { videosDir: join(videosRoot, session), outDir: join(reviewsRoot, session),
955
+ steps, logs: [], log: (l) => process.stderr.write(l + '\n'), claudeModel: args.model, structured: true,
956
+ knownDrops: cov.dropped, coverage: cov, structure: landingStructure(fx) });
957
+ return typeof res === 'string' ? [] : res.gaps;
958
+ },
959
+ });
960
+ console.log(JSON.stringify({ status: result.status,
961
+ rounds: result.rounds.map((r) => ({ round: r.round, session: r.session, gaps: r.gaps.length })),
962
+ gaps: result.gaps }, null, 2));
963
+ if (result.status !== 'clean')
964
+ process.exitCode = 3; // needs-fix / max-rounds
965
+ return;
966
+ }
967
+ if (args.cmd === 'verify') {
968
+ // Phase 5: check a hand-authored map's affordance fingerprints resolve UNIQUELY against
969
+ // the live page the session is currently on. graph-edit is offline; this is the only
970
+ // place an authored elementFp is validated against real bytes. v1 = single page (the
971
+ // state the browser is on); multi-state drive-through is a follow-up.
972
+ if (!args.node || !args.session) {
973
+ console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav dev verify --node <id> --session <S>' }, null, 2));
974
+ process.exitCode = 2;
975
+ return;
976
+ }
977
+ const { MapStore } = await import('./mapstore/store.js');
978
+ const { PlaywrightAdapter } = await import('./playwright/adapter.js');
979
+ const { parseSnapshot } = await import('./playwright/snapshot.js');
980
+ const { matchState } = await import('./explorer/fingerprint.js');
981
+ const { resolveByFingerprint } = await import('./playwright/fingerprint.js');
982
+ const store = new MapStore(dbPath());
983
+ const nodes = parseSnapshot(await new PlaywrightAdapter(args.session).snapshot());
984
+ const matched = matchState(nodes, store.statesForNode(args.node));
985
+ if (matched.status !== 'matched') {
986
+ console.log(JSON.stringify({ status: 'no-match', node: args.node, reason: matched.status }, null, 2));
987
+ process.exitCode = 3;
988
+ return;
989
+ }
990
+ const state = matched.state;
991
+ const checks = [];
992
+ const walkAffs = (affs) => {
993
+ for (const a of affs) {
994
+ if (a.elementFp && (a.kind === 'navigate' || a.kind === 'reveal' || a.kind === 'input')) {
995
+ const ref = resolveByFingerprint(a.elementFp, nodes);
996
+ const all = nodes.filter((n) => n.ref && n.role === a.elementFp.role && n.name === a.elementFp.name).map((n) => n.ref);
997
+ checks.push({ id: a.id, label: a.label, unique: ref !== null, matchedRefs: ref ? [ref] : all });
998
+ }
999
+ if (a.children)
1000
+ walkAffs(a.children);
1001
+ }
1002
+ };
1003
+ walkAffs(state.affordances ?? []);
1004
+ const allUnique = checks.every((c) => c.unique);
1005
+ console.log(JSON.stringify({ status: allUnique ? 'done' : 'non-unique', node: args.node, state: state.id, affordances: checks }, null, 2));
1006
+ if (!allUnique)
1007
+ process.exitCode = 3;
1008
+ return;
1009
+ }
1010
+ if (args.cmd === 'hover-probe') {
1011
+ // X2 (spec 2026-07-16 §2): an OPT-IN pass over the CURRENT page of a LIVE recording
1012
+ // session. Hover (or --right-click) each structural candidate, diff the reveal, and
1013
+ // append a reveal ActionEffect to the same recording. Attaches by session name (the
1014
+ // `dev verify --session` shape) and appends like the agent-session loop.
1015
+ if (!args.session) {
1016
+ console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav dev hover-probe --session <S> [--limit N] [--right-click]' }, null, 2));
1017
+ process.exitCode = 2;
1018
+ return;
1019
+ }
1020
+ const { RecordStore } = await import('./mapstore/record.js');
1021
+ const { PlaywrightAdapter } = await import('./playwright/adapter.js');
1022
+ const { runHoverProbe } = await import('./recorder/hover-probe.js');
1023
+ const store = new RecordStore(dbPath());
1024
+ // The probe WRITES effects; a session that isn't recording would silently drop them
1025
+ // (appendEvent/appendActionEffect are isActive-gated). That's dishonest, so refuse.
1026
+ if (!store.isActive(args.session)) {
1027
+ console.log(JSON.stringify({ status: 'error', session: args.session, hint: `session '${args.session}' is not recording — start it with 'dev record-start --session ${args.session}' and drive it to the page first` }, null, 2));
1028
+ process.exitCode = 2;
1029
+ return;
1030
+ }
1031
+ const adapter = new PlaywrightAdapter(args.session);
1032
+ const { probed, revealed } = await runHoverProbe({
1033
+ adapter, store, sessionId: args.session, limit: args.limit, rightClick: args.rightClick,
1034
+ log: (l) => process.stderr.write(l + '\n'),
1035
+ });
1036
+ console.log(JSON.stringify({ status: revealed ? 'done' : 'empty', session: args.session, probed, revealed }, null, 2));
1037
+ if (revealed === 0)
1038
+ process.exitCode = 3;
1039
+ return;
1040
+ }
1041
+ if (args.cmd === 'profile-status') {
1042
+ // "Am I logged in?" checked BEFORE walking/recording an authed site, instead of
1043
+ // guessing and hitting a stale-login wall mid-walk. One polite headless load of
1044
+ // the site's entry url (map homeUrl, or --url override), settled + classified
1045
+ // against the map's own fingerprints (the oracle) — then the session this verb
1046
+ // opened is ALWAYS reaped, success or failure.
1047
+ if (!args.profile || !args.site) {
1048
+ console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav dev profile-status --profile <p> --site <host> [--url <u>]' }, null, 2));
1049
+ process.exitCode = 2;
1050
+ return;
1051
+ }
1052
+ const { MapStore } = await import('./mapstore/store.js');
1053
+ const store = new MapStore(dbPath());
1054
+ const node = store.getNode(args.site);
1055
+ const url = args.url ?? node?.homeUrl;
1056
+ if (!url) {
1057
+ console.log(JSON.stringify({ status: 'error', hint: `no map for site '${args.site}' yet (no homeUrl) — pass --url, or map the site first with dev record-start` }, null, 2));
1058
+ process.exitCode = 2;
1059
+ return;
1060
+ }
1061
+ const { homedir: homedir3 } = await import('node:os');
1062
+ const { join: join3 } = await import('node:path');
1063
+ const { resolveProfile, PlaywrightAdapter } = await import('./playwright/adapter.js');
1064
+ const { prepProfile } = await import('./playwright/profile-lock.js');
1065
+ const { settleSnapshot } = await import('./router/browse.js');
1066
+ const { classifyAuthLanding } = await import('./router/auth-status.js');
1067
+ const profilesRoot3 = join3(homedir3(), '.webnav', 'profiles');
1068
+ const profileDir = resolveProfile(args.profile, profilesRoot3);
1069
+ try {
1070
+ (await import('node:fs')).mkdirSync(profileDir, { recursive: true });
1071
+ }
1072
+ catch { /* */ }
1073
+ prepProfile(profileDir);
1074
+ // SHORT session id: the playwright-cli daemon socket path embeds it and macOS
1075
+ // caps socket paths at ~104 chars (same constraint as capture-loop's session name).
1076
+ const session = 'pchk-' + Math.random().toString(36).slice(2, 6);
1077
+ const adapter = new PlaywrightAdapter(session, undefined, undefined, { headed: false, persistent: true, profile: profileDir });
1078
+ try {
1079
+ await adapter.open(url);
1080
+ const landedUrl = await adapter.currentUrl();
1081
+ const snapshot = await settleSnapshot(() => adapter.snapshot());
1082
+ const states = store.statesForNode(args.site);
1083
+ const { auth, loginUrl } = classifyAuthLanding(landedUrl, snapshot, args.site, states);
1084
+ console.log(JSON.stringify({
1085
+ status: 'ok', auth, site: args.site, profile: args.profile,
1086
+ checkedAt: new Date().toISOString(), ...(loginUrl ? { loginUrl } : {}),
1087
+ }, null, 2));
1088
+ // The verb ran fine even when auth is stale — needs-login is exit 0 (a normal,
1089
+ // useful answer), not exit 3 (ran-but-empty/failed). Documented in cli-spec help.
1090
+ }
1091
+ catch (e) {
1092
+ console.log(JSON.stringify({ status: 'failed', reason: String(e) }, null, 2));
1093
+ process.exitCode = 2;
1094
+ }
1095
+ finally {
1096
+ await adapter.close().catch(() => { });
1097
+ }
1098
+ return;
1099
+ }
1100
+ if (args.cmd === 'sessions') {
1101
+ const { listSessions, reapSessions } = await import('./playwright/sessions.js');
1102
+ const now = Date.now();
1103
+ if (args.sub === 'reap') {
1104
+ const maxAgeMs = args.maxAgeHours !== undefined ? args.maxAgeHours * 3600_000 : undefined;
1105
+ const closed = await reapSessions(now, { all: args.all, maxAgeMs });
1106
+ console.log(JSON.stringify({ status: closed.length ? 'done' : 'empty', reaped: closed }, null, 2));
1107
+ if (closed.length === 0)
1108
+ process.exitCode = 3;
1109
+ return;
1110
+ }
1111
+ // default: list
1112
+ const sessions = (await listSessions(now)).map((s) => ({
1113
+ name: s.name, live: s.live,
1114
+ ageHours: s.ageMs === Infinity ? null : Math.round(s.ageMs / 3600_000 * 10) / 10,
1115
+ }));
1116
+ console.log(JSON.stringify({ status: sessions.length ? 'done' : 'empty', sessions }, null, 2));
1117
+ if (sessions.length === 0)
1118
+ process.exitCode = 3;
1119
+ return;
1120
+ }
1121
+ if (args.cmd === 'mcp') {
1122
+ // Server mode: stdout carries JSON-RPC (the MCP stdio transport) until
1123
+ // stdin closes — the documented exception to one-JSON-object-stdout.
1124
+ const { startMcpServer } = await import('./mcp/server.js');
1125
+ await startMcpServer();
1126
+ return;
1127
+ }
1128
+ if (args.cmd === 'graph-edit') {
1129
+ const { MapStore } = await import('./mapstore/store.js');
1130
+ const { editGraph } = await import('./graph/edit.js');
1131
+ const store = new MapStore(dbPath());
1132
+ const graph = JSON.parse(args.graph);
1133
+ console.log(JSON.stringify(editGraph(store, args.node, graph), null, 2));
1134
+ return;
1135
+ }
1136
+ if (args.cmd === 'graph-show') {
1137
+ const { MapStore } = await import('./mapstore/store.js');
1138
+ const { showInterior } = await import('./graph/show.js');
1139
+ console.log(JSON.stringify(showInterior(new MapStore(dbPath()), args.node), null, 2));
1140
+ return;
1141
+ }
1142
+ if (args.cmd === 'node-clear') {
1143
+ // Wipe a node's interior so it can be re-learned through webnav (never raw sqlite).
1144
+ const { MapStore } = await import('./mapstore/store.js');
1145
+ if (!args.node) {
1146
+ console.log(JSON.stringify({ status: 'error', hint: 'pass --node <site-id>' }));
1147
+ process.exitCode = 2;
1148
+ return;
1149
+ }
1150
+ const store = new MapStore(dbPath());
1151
+ const before = store.statesForNode(args.node).length;
1152
+ store.clearNode(args.node);
1153
+ console.log(JSON.stringify({ status: before ? 'done' : 'empty', node: args.node, statesCleared: before }, null, 2));
1154
+ if (!before)
1155
+ process.exitCode = 3;
1156
+ return;
1157
+ }
1158
+ if (args.cmd === 'node-rm') {
1159
+ // Fully delete a node (row + states + edges + node-edges) — never raw sqlite.
1160
+ const { MapStore } = await import('./mapstore/store.js');
1161
+ if (!args.node) {
1162
+ console.log(JSON.stringify({ status: 'error', hint: 'pass --node <site-id>' }));
1163
+ process.exitCode = 2;
1164
+ return;
1165
+ }
1166
+ const store = new MapStore(dbPath());
1167
+ const existed = store.getNode(args.node) !== null;
1168
+ const states = store.statesForNode(args.node).length;
1169
+ store.removeNode(args.node);
1170
+ console.log(JSON.stringify({ status: existed ? 'done' : 'empty', node: args.node, removed: existed, statesRemoved: states }, null, 2));
1171
+ if (!existed)
1172
+ process.exitCode = 3;
1173
+ return;
1174
+ }
1175
+ if (args.cmd === 'export-map') {
1176
+ // Emit a site's full MAP PACK { node, states } as JSON — the unit a hosted
1177
+ // service publishes/imports. Skeleton only; credentials are never in the map.
1178
+ const { MapStore } = await import('./mapstore/store.js');
1179
+ const store = new MapStore(dbPath());
1180
+ const node = store.getNode(args.node);
1181
+ const states = store.statesForNode(args.node);
1182
+ if (!node || states.length === 0) {
1183
+ console.log(JSON.stringify({ status: 'empty', node: args.node, hint: 'no map for this site in the local db — build/seed it first' }, null, 2));
1184
+ process.exitCode = 3;
1185
+ return;
1186
+ }
1187
+ console.log(JSON.stringify({ node, states }, null, 2));
1188
+ return;
1189
+ }
1190
+ if (args.cmd === 'import-map') {
1191
+ // Load a map pack (export-map's JSON) into the local map — import a site someone
1192
+ // else mapped, no re-learning. Skeleton only; creds are set separately.
1193
+ const { MapStore } = await import('./mapstore/store.js');
1194
+ const { importMapPack } = await import('./hosted.js');
1195
+ const { readFileSync } = await import('node:fs');
1196
+ if (!args.file) {
1197
+ console.log(JSON.stringify({ status: 'error', hint: 'pass a map-pack file path (or --file)' }));
1198
+ process.exitCode = 2;
1199
+ return;
1200
+ }
1201
+ let pack;
1202
+ try {
1203
+ pack = JSON.parse(readFileSync(args.file, 'utf8'));
1204
+ }
1205
+ catch (e) {
1206
+ console.log(JSON.stringify({ status: 'error', hint: `could not read/parse ${args.file}: ${e.message}` }));
1207
+ process.exitCode = 2;
1208
+ return;
1209
+ }
1210
+ if (!pack || !pack.node || !Array.isArray(pack.states)) {
1211
+ console.log(JSON.stringify({ status: 'error', hint: 'not a map pack — expected {node, states:[...]} (from `dev export-map`)' }));
1212
+ process.exitCode = 2;
1213
+ return;
1214
+ }
1215
+ const store = new MapStore(dbPath());
1216
+ importMapPack(store, pack);
1217
+ console.log(JSON.stringify({ status: 'done', node: pack.node.id, statesImported: pack.states.length,
1218
+ hint: `set login creds with: webnav dev creds set ${pack.node.id} username=… password=…` }, null, 2));
1219
+ return;
1220
+ }
1221
+ if (args.cmd === 'pattern-propose') {
1222
+ const { readFileSync, writeFileSync, existsSync, mkdirSync } = await import('node:fs');
1223
+ const { dirname, join } = await import('node:path');
1224
+ const { lintPackEntry } = await import('./explorer/patterns.js');
1225
+ // RE-CHECK PATH (no standalone `pattern-lint` verb — --lint <file> IS the re-check the plan
1226
+ // asks for): re-runs lint on an existing pack file after the agent filled in the TODO trigger.
1227
+ // Independent of --from-unknown/--name — just lints whatever file is named.
1228
+ if (args.lint) {
1229
+ let raw;
1230
+ try {
1231
+ raw = JSON.parse(readFileSync(args.lint, 'utf8'));
1232
+ }
1233
+ catch (e) {
1234
+ console.log(JSON.stringify({ status: 'error', hint: `could not read/parse ${args.lint}: ${e.message}` }, null, 2));
1235
+ process.exitCode = 2;
1236
+ return;
1237
+ }
1238
+ const entries = Array.isArray(raw) ? raw : [raw];
1239
+ const results = entries.map((e, i) => ({ index: i, reasons: lintPackEntry(e) }));
1240
+ const allClean = results.every((r) => r.reasons.length === 0);
1241
+ console.log(JSON.stringify({
1242
+ status: allClean ? 'done' : 'error', file: args.lint, results,
1243
+ hint: allClean
1244
+ ? `lint clean. Next: re-run graph-analyse --draft on the site that surfaced this gap and confirm the unknown is gone, then add a grammar fixture test, then: gh pr create ...`
1245
+ : `lint failed — fix the reasons above (usually: fill trigger.contains with >=1 { role, min?, attr? } predicate) and re-run --lint`,
1246
+ }, null, 2));
1247
+ if (!allClean)
1248
+ process.exitCode = 2;
1249
+ return;
1250
+ }
1251
+ if (!args.fromUnknown || !args.name) {
1252
+ console.log(JSON.stringify({ status: 'error',
1253
+ hint: 'usage: webnav dev pattern-propose --from-unknown <graph-analyse-draft.json>#<index> --name <slug> (or: --lint <pack-file> to re-check)' }, null, 2));
1254
+ process.exitCode = 2;
1255
+ return;
1256
+ }
1257
+ const { parseFromUnknownArg, proposeFromUnknown, ghPrCommand } = await import('./explorer/pattern-propose.js');
1258
+ let path, index;
1259
+ try {
1260
+ ({ path, index } = parseFromUnknownArg(args.fromUnknown));
1261
+ }
1262
+ catch (e) {
1263
+ console.log(JSON.stringify({ status: 'error', hint: e.message }, null, 2));
1264
+ process.exitCode = 2;
1265
+ return;
1266
+ }
1267
+ let draft;
1268
+ try {
1269
+ draft = JSON.parse(readFileSync(path, 'utf8'));
1270
+ }
1271
+ catch (e) {
1272
+ console.log(JSON.stringify({ status: 'error', hint: `could not read/parse ${path}: ${e.message}` }, null, 2));
1273
+ process.exitCode = 2;
1274
+ return;
1275
+ }
1276
+ const unknowns = draft?.unknowns;
1277
+ if (!Array.isArray(unknowns) || !unknowns[index]) {
1278
+ console.log(JSON.stringify({ status: 'error',
1279
+ hint: `${path} has no unknowns[${index}] — pass a graph-analyse --draft JSON file (its "unknowns" array) and a valid index (0..${(unknowns?.length ?? 1) - 1})` }, null, 2));
1280
+ process.exitCode = 2;
1281
+ return;
1282
+ }
1283
+ const unknown = unknowns[index];
1284
+ const result = proposeFromUnknown(unknown, args.name);
1285
+ if (!result.pack) {
1286
+ // core-design boundary: no pack schema field expresses this gap — the honest answer is a
1287
+ // core-design issue with the fixture, never a pack (and never a hand-patch). No file written.
1288
+ console.log(JSON.stringify({ status: 'declined', kind: unknown.kind, extensionPoint: unknown.extensionPoint,
1289
+ reason: result.coreDesignBoundary, evidence: unknown.evidence, context: unknown.context }, null, 2));
1290
+ process.exitCode = 3;
1291
+ return;
1292
+ }
1293
+ const outPath = join('packs', 'patterns', 'proposed', `${args.name}.json`);
1294
+ if (existsSync(outPath)) {
1295
+ console.log(JSON.stringify({ status: 'error', hint: `${outPath} already exists — pick a different --name or edit it directly` }, null, 2));
1296
+ process.exitCode = 2;
1297
+ return;
1298
+ }
1299
+ mkdirSync(dirname(outPath), { recursive: true });
1300
+ writeFileSync(outPath, JSON.stringify(result.pack, null, 2) + '\n');
1301
+ // lint is EXPECTED to fail here — the trigger is a deliberate TODO (empty `contains`). Printing
1302
+ // the failure IS the agent's next step, per the plan ("prints the failure as the agent's next step").
1303
+ console.log(JSON.stringify({
1304
+ status: 'scaffolded',
1305
+ file: outPath,
1306
+ pack: result.pack,
1307
+ lint: result.lintReasons.length ? { clean: false, reasons: result.lintReasons } : { clean: true },
1308
+ checklist: [
1309
+ `1. Fill trigger.contains in ${outPath} with >=1 { role, min?, attr? } predicate derived from the evidence above (role/attr names only — hostnames/URLs/text are rejected by lint).`,
1310
+ `2. Re-check: webnav dev pattern-propose --lint ${outPath}`,
1311
+ `3. Re-run graph-analyse --draft on the site that surfaced this gap — this unknown should now be GONE from the report.`,
1312
+ `4. Add a grammar fixture test proving the resolved shape (tests/grammar/*.test.ts — see pickers.test.ts for the idiom).`,
1313
+ `5. Once green: ${ghPrCommand(result.pack, unknown)}`,
1314
+ ` (moves ${outPath} to packs/patterns/core/ for upstream review — NO auto-PR; this command is printed, never run for you.)`,
1315
+ ],
1316
+ }, null, 2));
1317
+ // exit 0: the SCAFFOLD succeeded (file written) — the TODO trigger's expected lint failure is
1318
+ // reported IN the JSON body (lint.clean:false + reasons), not as a process failure, so an agent
1319
+ // scripting this loop doesn't misread "scaffolded, here's your next step" as an error.
1320
+ return;
1321
+ }
1322
+ if (args.cmd === 'outline' || args.cmd === 'mermaid') {
1323
+ // Human-scannable completeness views of a site's interior (no UI needed).
1324
+ // Per the CLI contract, the text view is carried as a `text` field on the
1325
+ // JSON stdout object (never bare); the human reads `text`, the coverage
1326
+ // summary (counts / unexplored / dead-ends / orphans) rides alongside.
1327
+ const { MapStore } = await import('./mapstore/store.js');
1328
+ const { analyseCoverage, toOutline, toMermaid } = await import('./graph/coverage.js');
1329
+ const store = new MapStore(dbPath());
1330
+ const states = store.statesForNode(args.node);
1331
+ if (!states.length) {
1332
+ console.log(JSON.stringify({ status: 'empty', node: args.node,
1333
+ hint: `no interior captured for "${args.node}" — map it with the record/teach flow` }, null, 2));
1334
+ process.exitCode = 3;
1335
+ return;
1336
+ }
1337
+ const coverage = analyseCoverage(args.node, states);
1338
+ const text = args.cmd === 'outline' ? toOutline(args.node, states) : toMermaid(args.node, states);
1339
+ console.log(JSON.stringify({ status: 'ok', node: args.node, coverage, text }, null, 2));
1340
+ return;
1341
+ }
1342
+ if (args.cmd === 'frontier') {
1343
+ // The UNEXPLORED FRONTIER: declared affordances the map never followed to a
1344
+ // resolved state. Measures "is exploration complete?" instead of guessing.
1345
+ // Exit 3 (ran-fine-but-incomplete) when the frontier is non-empty; 0 when
1346
+ // empty (fully explored, minus the caller's hard exclusions). Same
1347
+ // ran-but-empty/incomplete convention as outline/mermaid.
1348
+ const { MapStore } = await import('./mapstore/store.js');
1349
+ const { computeFrontier } = await import('./graph/frontier.js');
1350
+ const store = new MapStore(dbPath());
1351
+ const states = store.statesForNode(args.node);
1352
+ if (!states.length) {
1353
+ console.log(JSON.stringify({ status: 'empty', node: args.node,
1354
+ hint: `no interior captured for "${args.node}" — map it with the record/teach flow` }, null, 2));
1355
+ process.exitCode = 3;
1356
+ return;
1357
+ }
1358
+ const result = computeFrontier(args.node, states, args.exclude);
1359
+ console.log(JSON.stringify(result, null, 2));
1360
+ // frontier non-empty = work remains; exit 3 (ran fine, incomplete).
1361
+ if (result.total > 0)
1362
+ process.exitCode = 3;
1363
+ return;
1364
+ }
1365
+ if (args.cmd === 'creds') {
1366
+ // Local credential store (~/.webnav/credentials.json, chmod 600). Values are
1367
+ // NEVER printed (list shows key NAMES only) and never stored in the map.
1368
+ const { CredStore, credsPath } = await import('./creds.js');
1369
+ const cs = new CredStore();
1370
+ if (args.sub === 'set') {
1371
+ if (!args.site || Object.keys(args.values).length === 0) {
1372
+ console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav creds set <site> key=value [key=value...]' }, null, 2));
1373
+ process.exitCode = 2;
1374
+ return;
1375
+ }
1376
+ const keys = cs.set(args.site, args.values);
1377
+ console.log(JSON.stringify({ status: 'ok', site: args.site, keys, file: credsPath() }, null, 2));
1378
+ return;
1379
+ }
1380
+ if (args.sub === 'list') {
1381
+ console.log(JSON.stringify({ status: 'ok', sites: cs.list(), file: credsPath() }, null, 2));
1382
+ return;
1383
+ }
1384
+ if (args.sub === 'rm') {
1385
+ if (!args.site) {
1386
+ console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav creds rm <site> [key]' }, null, 2));
1387
+ process.exitCode = 2;
1388
+ return;
1389
+ }
1390
+ const removed = cs.remove(args.site, args.key);
1391
+ console.log(JSON.stringify({ status: removed ? 'ok' : 'empty', site: args.site, key: args.key, removed }, null, 2));
1392
+ if (!removed)
1393
+ process.exitCode = 3;
1394
+ return;
1395
+ }
1396
+ console.log(JSON.stringify({ status: 'error', hint: 'webnav creds set|list|rm' }, null, 2));
1397
+ process.exitCode = 2;
1398
+ return;
1399
+ }
1400
+ if (args.cmd === 'dashboard') {
1401
+ // Long-lived LOCAL operator UI (not a one-shot JSON verb): start the server,
1402
+ // print the URL to stderr (keeps stdout clean per CLI rules), auto-open the
1403
+ // browser, then stay alive until Ctrl-C. Reads ./webnav.db + the creds file.
1404
+ const { MapStore } = await import('./mapstore/store.js');
1405
+ const { ensureSeeded } = await import('./graph/seed.js');
1406
+ const { CredStore } = await import('./creds.js');
1407
+ const { startDashboard } = await import('./dashboard/server.js');
1408
+ const { RecordStore } = await import('./mapstore/record.js');
1409
+ const { runLiveRecord } = await import('./recorder/live-record.js');
1410
+ const { MODE_JS } = await import('./recorder/live.js');
1411
+ const { ReplayController, runReplay, runLedgerReplay } = await import('./recorder/replay.js');
1412
+ const { coverage } = await import('./recorder/coverage.js');
1413
+ const { draftFromEffects } = await import('./explorer/draft.js');
1414
+ const { PlaywrightAdapter } = await import('./playwright/adapter.js');
1415
+ const { join } = await import('node:path');
1416
+ const { homedir } = await import('node:os');
1417
+ const { rmSync, mkdirSync, readdirSync, readFileSync, writeFileSync, statSync, renameSync, existsSync: existsSync2 } = await import('node:fs');
1418
+ const { execSync } = await import('node:child_process');
1419
+ const { listSessions: listPwSessions } = await import('./playwright/sessions.js');
1420
+ const store = new MapStore(dbPath());
1421
+ ensureSeeded(store);
1422
+ const creds = new CredStore();
1423
+ const port = args.port;
1424
+ // Recordings deps for the dashboard's Recordings tab — record by clicking,
1425
+ // replay to verify. ONE driven browser at a time (CLAUDE.md rule); `busy`
1426
+ // tracks it so a second open/replay while one is up gets a clear 409-style error.
1427
+ const recordStore = new RecordStore(dbPath());
1428
+ // Reconcile stale-active rows: a session left active=1 by a crashed/killed
1429
+ // recorder OR by an agent `use session` process that ended (its /api/notify
1430
+ // can race the SSE tab, or get missed) shows "recording" forever in an
1431
+ // already-open tab. Any active session with NO live browser process is stale
1432
+ // → stop it. Uses REAL process liveness (listPwSessions), never the dashboard's
1433
+ // own `busy`, so it never touches a session legitimately active in another
1434
+ // live process. Run at boot AND on every `list()` read, so even a missed SSE
1435
+ // event self-heals on the next poll/refresh (ground truth, not just a push).
1436
+ const reconcileStale = async () => {
1437
+ try {
1438
+ const live = new Set((await listPwSessions(Date.now())).map((s) => s.name));
1439
+ for (const x of recordStore.listSessions()) {
1440
+ if (x.active && !live.has(x.sessionId))
1441
+ recordStore.stop(x.sessionId);
1442
+ }
1443
+ }
1444
+ catch { /* liveness probe failed → leave flags as-is */ }
1445
+ };
1446
+ await reconcileStale();
1447
+ // Per-(profile,site) auth-status cache — checks are on-demand only (no background
1448
+ // polling, per the design doc's non-goals), so this is just last-result memory for
1449
+ // the chip; key is 'profile\x00site' (both are already validated [\w.-]+ elsewhere,
1450
+ // but the separator is NUL to rule out any collision regardless).
1451
+ const statusCache = new Map();
1452
+ let busy = null;
1453
+ let activeAdapter = null; // for instant overlay updates
1454
+ // realtime push hub: SSE subscribers get 'sessions' | 'step' | 'replay' | 'log' pings
1455
+ const sseListeners = new Set();
1456
+ const emit = (t) => { for (const f of sseListeners)
1457
+ f(t); };
1458
+ // dashboard log stream: everything the operator should see, timestamped + pushed
1459
+ const logBuf = [];
1460
+ const dlog = (line) => {
1461
+ logBuf.push({ t: Date.now(), line });
1462
+ if (logBuf.length > 500)
1463
+ logBuf.shift();
1464
+ process.stderr.write(line + '\n');
1465
+ emit('log');
1466
+ };
1467
+ // Session VIDEO: recording-active spans are captured as .webm takes (ground
1468
+ // truth to verify the step capture against, and a session recording artifact).
1469
+ const videosRoot = join(homedir(), '.webnav', 'recordings');
1470
+ const reviewsRoot = join(homedir(), '.webnav', 'reviews');
1471
+ const profilesRoot = join(homedir(), '.webnav', 'profiles'); // STABLE per-session browser profile
1472
+ const profileDir = (id) => join(profilesRoot, id.replace(/[^\w.-]/g, '_'));
1473
+ // A Chrome profile dir opens in ONE process only; an orphan webnav Chrome leaves
1474
+ // a SingletonLock and holds the dir → next launch fails / hands off to the orphan
1475
+ // (the dashboard-vs-reality desync cluster). prepProfile reaps a live webnav-owned
1476
+ // holder + clears stale locks before each persistent launch. (src/playwright/profile-lock.ts)
1477
+ const { prepProfile } = await import('./playwright/profile-lock.js');
1478
+ const dirSizeMb = (dir) => {
1479
+ // cheap recursive size; profiles are small (cookies+cache), so a sync walk is fine
1480
+ let total = 0;
1481
+ const walk = (d) => {
1482
+ let ents = [];
1483
+ try {
1484
+ ents = readdirSync(d);
1485
+ }
1486
+ catch {
1487
+ return;
1488
+ }
1489
+ for (const e of ents) {
1490
+ const fp = join(d, e);
1491
+ try {
1492
+ const st = statSync(fp);
1493
+ if (st.isDirectory())
1494
+ walk(fp);
1495
+ else
1496
+ total += st.size;
1497
+ }
1498
+ catch { /* skip */ }
1499
+ }
1500
+ };
1501
+ walk(dir);
1502
+ return Math.round(total / 1e5) / 10; // MB, 1 decimal
1503
+ };
1504
+ let reviewBusy = null;
1505
+ const { DEFAULT_INSTRUCTIONS: reviewDefaultInstructions } = await import('./recorder/review.js');
1506
+ const { makeVideoSync } = await import('./playwright/video.js');
1507
+ const videoSync = makeVideoSync(() => activeAdapter, { videosRoot, log: dlog, onSaved: () => emit('sessions') });
1508
+ let activeCtl = null;
1509
+ const shotsRoot = join(homedir(), '.webnav', 'replays');
1510
+ const rec = {
1511
+ list: async () => {
1512
+ await reconcileStale(); // ground truth on every read: self-heals a missed/raced SSE 'sessions' event
1513
+ return recordStore.listSessions().map((x) => {
1514
+ const profile = recordStore.profileOf(x.sessionId);
1515
+ let videoCount = 0;
1516
+ try {
1517
+ videoCount = readdirSync(join(videosRoot, x.sessionId)).filter((f) => f.endsWith('.webm')).length;
1518
+ }
1519
+ catch { /* none */ }
1520
+ return { ...x, profile, hasProfile: !!profile && existsSync2(profileDir(profile)),
1521
+ startUrl: recordStore.startUrl(x.sessionId), videoCount, origin: recordStore.originOf(x.sessionId) };
1522
+ });
1523
+ },
1524
+ steps: (id) => recordStore.actionEffects(id).map((e) => ({ seq: e.seq,
1525
+ label: e.action?.name ?? (e.navigated ? new URL(e.toUrl).pathname : 'observe'),
1526
+ kind: e.action ? (e.navigated ? 'navigate' : e.action.role === 'textbox' ? 'input' : 'click') : (e.navigated ? 'jump' : 'observe'),
1527
+ fromUrl: e.fromUrl, toUrl: e.toUrl, value: e.action?.value, capturedAt: e.capturedAt })),
1528
+ del: (id) => {
1529
+ recordStore.deleteSession(id);
1530
+ emit('sessions');
1531
+ // remove the session's replay screenshots too (spec: shots die with the recording).
1532
+ // The same guard as shotPath: '.'/'..' here would rmSync ~/.webnav recursively.
1533
+ if (/^[\w.-]+$/.test(id) && id !== '.' && id !== '..') {
1534
+ try {
1535
+ rmSync(join(shotsRoot, id), { recursive: true, force: true });
1536
+ }
1537
+ catch { /* decoration */ }
1538
+ try {
1539
+ rmSync(join(videosRoot, id), { recursive: true, force: true });
1540
+ }
1541
+ catch { /* decoration */ }
1542
+ try {
1543
+ rmSync(join(reviewsRoot, id), { recursive: true, force: true });
1544
+ }
1545
+ catch { /* decoration */ }
1546
+ }
1547
+ },
1548
+ draft: (id) => draftFromEffects(recordStore.actionEffects(id)),
1549
+ open: async (url, session, persistent, armedOnly, profile) => {
1550
+ if (busy)
1551
+ return { ok: false, error: 'a driven browser is already open (' + busy + ')' };
1552
+ busy = session;
1553
+ try {
1554
+ // persistent → a NAMED, SHARED profile dir (default 'default'), so a login
1555
+ // done once in a profile is reused by EVERY session under it (and by walks
1556
+ // via --profile). Without an explicit dir, playwright-cli uses a random temp
1557
+ // dir per launch and the login evaporates (live finding).
1558
+ const profName = persistent ? (profile && /^[\w.-]+$/.test(profile) ? profile : 'default') : null;
1559
+ if (profName) {
1560
+ try {
1561
+ mkdirSync(profileDir(profName), { recursive: true });
1562
+ }
1563
+ catch { /* */ }
1564
+ prepProfile(profileDir(profName));
1565
+ }
1566
+ const adapter = new PlaywrightAdapter(session, undefined, undefined, profName ? { headed: true, persistent: true, profile: profileDir(profName) } : { headed: true });
1567
+ await adapter.open(url);
1568
+ // A persistent profile RESTORES its previous tab on launch (e.g. the Google
1569
+ // account page after an auth bounce). Force-navigate to the requested URL so
1570
+ // the window lands on the product, not the restored tab (live bug).
1571
+ if (persistent && url && url !== 'about:blank') {
1572
+ try {
1573
+ await adapter.goto(url);
1574
+ }
1575
+ catch { /* */ }
1576
+ }
1577
+ // ONE CLICK = OPEN + RECORD (live feedback: asking to press Record again
1578
+ // after "Open window" was a redundant second intent). Stop still returns
1579
+ // the window to the armed/grey state for another take.
1580
+ activeAdapter = adapter;
1581
+ if (armedOnly) {
1582
+ // armed reopen from a recording's detail: window only; Record is a
1583
+ // separate intent there (live feedback #1). Row stays visible.
1584
+ recordStore.start(session);
1585
+ recordStore.stop(session);
1586
+ recordStore.setOrigin(session, 'manual');
1587
+ dlog('window opened (armed) for ' + session);
1588
+ }
1589
+ else {
1590
+ recordStore.start(session);
1591
+ recordStore.setOrigin(session, 'manual');
1592
+ videoSync(session, true);
1593
+ dlog('recording STARTED: ' + session);
1594
+ }
1595
+ // record the session's profile + intended start url (row now exists)
1596
+ if (profName)
1597
+ recordStore.setProfile(session, profName);
1598
+ if (url && url !== 'about:blank')
1599
+ recordStore.setStartUrl(session, url);
1600
+ emit('sessions');
1601
+ // window liveness = the DAEMON still has a Chromium child (the daemon itself
1602
+ // outlives the window — probing it via evals is what resurrected the window).
1603
+ let daemonPid;
1604
+ try {
1605
+ daemonPid = (await listPwSessions(Date.now())).find((x) => x.name === session)?.pid;
1606
+ }
1607
+ catch { /* fallback below */ }
1608
+ const browserAlive = daemonPid === undefined ? undefined : () => {
1609
+ try {
1610
+ return execSync(`ps -axo ppid=,comm= | awk '$1==${daemonPid}'`, { encoding: 'utf8' })
1611
+ .toLowerCase().includes('chrom');
1612
+ }
1613
+ catch {
1614
+ return true;
1615
+ } // ps hiccup ≠ dead
1616
+ };
1617
+ void runLiveRecord({ adapter, store: recordStore, sessionId: session, intervalMs: 200, armed: true,
1618
+ tickExtras: { port, session },
1619
+ onEvent: emit,
1620
+ browserAlive,
1621
+ onToggle: (recording) => videoSync(session, recording),
1622
+ // fire the instant the loop ends (window closed OR external stop): stop capture,
1623
+ // save the video, and force-reap the daemon so it can't RESURRECT the window
1624
+ // (live: split-second reopen). Runs before the graceful close in finally.
1625
+ onEnd: (reason) => {
1626
+ videoSync(session, false);
1627
+ recordStore.stop(session);
1628
+ // GRACEFUL close only (the loop's finally calls adapter.close() → Chrome
1629
+ // exits cleanly, releasing the profile lock and leaving NO "restore pages"
1630
+ // bubble). A lingering orphan is handled by prepProfile on the NEXT launch,
1631
+ // not an ungraceful pkill here (that was the cause of the restore bubble +
1632
+ // reopen loop — advisor finding). Force-kill the daemon GROUP only if it's
1633
+ // truly wedged AND the window is gone (resurrection guard).
1634
+ if (reason === 'closed') {
1635
+ setTimeout(() => {
1636
+ try {
1637
+ if (daemonPid !== undefined && execSync('ps -axo ppid=,comm= | awk \'$1==' + daemonPid + '\'', { encoding: 'utf8' }).toLowerCase().includes('chrom')) {
1638
+ execSync('pkill -f ' + JSON.stringify('-s=' + wireSessionName(session)));
1639
+ }
1640
+ }
1641
+ catch { /* gone */ }
1642
+ }, 1500);
1643
+ }
1644
+ busy = null;
1645
+ activeAdapter = null;
1646
+ dlog('window session ended (' + reason + '): ' + session);
1647
+ emit('sessions');
1648
+ },
1649
+ log: dlog, isStopped: () => false })
1650
+ .catch(() => { });
1651
+ return { ok: true };
1652
+ }
1653
+ catch (e) {
1654
+ busy = null; // final-review #1: an open() throw (session ceiling, bad URL) wedged the guard forever
1655
+ const msg = String(e);
1656
+ if (/EINVAL/.test(msg) && /\.sock/.test(msg)) {
1657
+ return { ok: false, error: 'session name too long — macOS caps the daemon socket path (~104 chars); use a shorter name (≤ ~14 chars is safe)' };
1658
+ }
1659
+ return { ok: false, error: msg };
1660
+ }
1661
+ },
1662
+ // instant overlay update: don't wait for the loop's next tick (live finding: lag)
1663
+ record: (id) => { recordStore.start(id); videoSync(id, true); dlog('recording STARTED: ' + id); emit('sessions'); void activeAdapter?.evalJs(MODE_JS(true)).catch(() => { }); return true; },
1664
+ stop: (id) => { recordStore.stop(id); videoSync(id, false); dlog('recording STOPPED: ' + id); emit('sessions'); void activeAdapter?.evalJs(MODE_JS(false)).catch(() => { }); return true; },
1665
+ // the window pill's realtime channel (POSTed directly from the page).
1666
+ // `desired` (from the pill) is IDEMPOTENT — a stale visual can't double-toggle.
1667
+ toggle: (id, desired) => {
1668
+ const was = recordStore.isActive(id);
1669
+ const next = desired ?? !was;
1670
+ if (next === was)
1671
+ return { recording: was };
1672
+ if (next)
1673
+ recordStore.start(id);
1674
+ else
1675
+ recordStore.stop(id);
1676
+ videoSync(id, next);
1677
+ dlog('recording ' + (next ? 'STARTED' : 'STOPPED') + ' (pill): ' + id);
1678
+ emit('sessions');
1679
+ void activeAdapter?.evalJs(MODE_JS(next)).catch(() => { });
1680
+ return { recording: next };
1681
+ },
1682
+ videos: (id) => {
1683
+ if (!/^[\w.-]+$/.test(id) || id === '.' || id === '..')
1684
+ return [];
1685
+ try {
1686
+ return readdirSync(join(videosRoot, id)).filter((f) => f.endsWith('.webm')).sort();
1687
+ }
1688
+ catch {
1689
+ return [];
1690
+ }
1691
+ },
1692
+ videoPath: (session, file) => /^[\w.-]+$/.test(session) && session !== '.' && session !== '..' && /^take-\d+\.webm$/.test(file)
1693
+ ? join(videosRoot, session, file) : null,
1694
+ subscribe: (cb) => { sseListeners.add(cb); return () => sseListeners.delete(cb); },
1695
+ activeWindow: () => (busy && !busy.startsWith('replay:') ? busy : null),
1696
+ logs: () => ({ now: Date.now(), lines: logBuf.slice(-200) }),
1697
+ // cross-process realtime bridge: a `use session` process POSTs /api/notify →
1698
+ // this appends its log line to our buffer and pushes the SSE event, so the
1699
+ // dashboard streams an agent session's steps/logs live just like the human one.
1700
+ notify: (kind, line) => {
1701
+ if (line)
1702
+ dlog(line); // dlog already emits('log')
1703
+ if (kind === 'step' || kind === 'sessions')
1704
+ emit(kind);
1705
+ },
1706
+ review: (id, opts) => {
1707
+ if (reviewBusy)
1708
+ return { ok: false, error: 'a review is already running (' + reviewBusy + ')' };
1709
+ if (!/^[\w.-]+$/.test(id) || id === '.' || id === '..')
1710
+ return { ok: false, error: 'bad session id' };
1711
+ const model = /^[\w.-]{1,40}$/.test(opts?.model ?? '') ? opts.model : undefined;
1712
+ const instructions = typeof opts?.instructions === 'string' && opts.instructions.trim() ? opts.instructions.slice(0, 8000) : undefined;
1713
+ // persist last-used config so the next run (and the config GET) starts from it
1714
+ try {
1715
+ mkdirSync(reviewsRoot, { recursive: true });
1716
+ writeFileSync(join(reviewsRoot, 'config.json'), JSON.stringify({ model: model ?? 'sonnet', instructions }, null, 2));
1717
+ }
1718
+ catch { /* decoration */ }
1719
+ reviewBusy = id;
1720
+ emit('sessions');
1721
+ const outDir = join(reviewsRoot, id);
1722
+ void (async () => {
1723
+ const { runSessionReview } = await import('./recorder/review.js');
1724
+ const steps = recordStore.actionEffects(id).map((e) => ({
1725
+ seq: e.seq,
1726
+ kind: e.action ? (e.navigated ? 'navigate' : e.action.role === 'textbox' ? 'input' : 'click') : (e.navigated ? 'jump' : 'observe'),
1727
+ label: e.action?.name ?? e.toUrl, value: e.action?.value, capturedAt: e.capturedAt,
1728
+ }));
1729
+ await runSessionReview(id, { videosDir: join(videosRoot, id), outDir,
1730
+ steps, logs: logBuf.slice(-200), log: dlog, claudeModel: model, instructions });
1731
+ })().finally(() => { reviewBusy = null; emit('sessions'); });
1732
+ return { ok: true };
1733
+ },
1734
+ reviewReport: (id) => {
1735
+ if (!/^[\w.-]+$/.test(id) || id === '.' || id === '..')
1736
+ return null;
1737
+ try {
1738
+ const f = join(reviewsRoot, id, 'review.md');
1739
+ const verdict = recordStore.reviewOf(id); // stored {approved,gaps,...} → verdict banner
1740
+ return { report: readFileSync(f, 'utf8'), at: statSync(f).mtimeMs, verdict };
1741
+ }
1742
+ catch {
1743
+ return null;
1744
+ }
1745
+ },
1746
+ reviewRunning: () => reviewBusy,
1747
+ reviewConfig: () => {
1748
+ let saved = {};
1749
+ try {
1750
+ saved = JSON.parse(readFileSync(join(reviewsRoot, 'config.json'), 'utf8'));
1751
+ }
1752
+ catch { /* defaults */ }
1753
+ // DEFAULT_INSTRUCTIONS import is dynamic-only elsewhere; inline require here is fine at runtime
1754
+ return { model: saved.model ?? 'sonnet', instructions: saved.instructions ?? reviewDefaultInstructions };
1755
+ },
1756
+ reviewFramePath: (session, file) => /^[\w.-]+$/.test(session) && session !== '.' && session !== '..' && /^[\w-]+\.png$/.test(file)
1757
+ ? (readdirSync(join(reviewsRoot, session)).filter((d) => d.startsWith('frames-'))
1758
+ .map((d) => join(reviewsRoot, session, d, file)).find((f) => existsSync2(f)) ?? null)
1759
+ : null,
1760
+ // Profiles are NAMED, shared logged-in browser states (dir name = profile
1761
+ // name). Metadata (site, last recording url) is derived from the sessions
1762
+ // that use the profile — profiles themselves store only cookies on disk.
1763
+ profiles: () => {
1764
+ let dirs = [];
1765
+ try {
1766
+ dirs = readdirSync(profilesRoot);
1767
+ }
1768
+ catch {
1769
+ return [];
1770
+ }
1771
+ return dirs.filter((d) => { try {
1772
+ return statSync(join(profilesRoot, d)).isDirectory();
1773
+ }
1774
+ catch {
1775
+ return false;
1776
+ } })
1777
+ .map((name) => {
1778
+ const dir = join(profilesRoot, name);
1779
+ let lastUsed = 0;
1780
+ try {
1781
+ lastUsed = statSync(dir).mtimeMs;
1782
+ }
1783
+ catch { /* */ }
1784
+ const sessions = recordStore.sessionsUsingProfile(name);
1785
+ // site = host of the most-recent using-session's start url (best-effort)
1786
+ let site = null;
1787
+ for (const sid of sessions) {
1788
+ const u = recordStore.startUrl(sid);
1789
+ if (u) {
1790
+ try {
1791
+ site = new URL(u).host;
1792
+ break;
1793
+ }
1794
+ catch { /* */ }
1795
+ }
1796
+ }
1797
+ const status = site ? statusCache.get(name + '\x00' + site) : undefined;
1798
+ return { name, site, sessions: sessions.length, sizeMb: dirSizeMb(dir), lastUsed, open: busy === 'relogin-' + name, ...(status ? { status } : {}) };
1799
+ }).sort((a, b) => b.lastUsed - a.lastUsed);
1800
+ },
1801
+ profileNew: (name) => {
1802
+ if (!/^[\w.-]+$/.test(name) || name === '.' || name === '..')
1803
+ return { ok: false, error: 'name must be letters/numbers/-._' };
1804
+ const dir = profileDir(name);
1805
+ if (existsSync2(dir))
1806
+ return { ok: false, error: 'profile "' + name + '" already exists' };
1807
+ try {
1808
+ mkdirSync(dir, { recursive: true });
1809
+ dlog('profile created: ' + name);
1810
+ emit('sessions');
1811
+ return { ok: true };
1812
+ }
1813
+ catch (e) {
1814
+ return { ok: false, error: String(e) };
1815
+ }
1816
+ },
1817
+ profileOpen: async (name) => {
1818
+ if (busy)
1819
+ return { ok: false, error: 'a driven browser is already open (' + busy + ')' };
1820
+ if (!/^[\w.-]+$/.test(name) || name === '.' || name === '..')
1821
+ return { ok: false, error: 'bad profile name' };
1822
+ const dir = profileDir(name);
1823
+ try {
1824
+ mkdirSync(dir, { recursive: true });
1825
+ }
1826
+ catch { /* */ } // new profile: create on first login
1827
+ prepProfile(dir); // reap any orphan holding the lock + clear stale Singleton*
1828
+ const winId = 'relogin-' + name;
1829
+ busy = winId;
1830
+ // re-login window: open the profile headed at the site of a session that uses
1831
+ // it (or blank) so the human can refresh an expired Cloudflare/2FA login. NOT
1832
+ // recording — closes on window close; state persists back to the profile dir.
1833
+ let startUrl = 'about:blank';
1834
+ for (const sid of recordStore.sessionsUsingProfile(name)) {
1835
+ const u = recordStore.startUrl(sid);
1836
+ if (u) {
1837
+ startUrl = u;
1838
+ break;
1839
+ }
1840
+ }
1841
+ try {
1842
+ const adapter = new PlaywrightAdapter(winId, undefined, undefined, { headed: true, persistent: true, profile: dir });
1843
+ activeAdapter = adapter;
1844
+ await adapter.open(startUrl);
1845
+ if (startUrl !== 'about:blank') {
1846
+ try {
1847
+ await adapter.goto(startUrl);
1848
+ }
1849
+ catch { /* */ }
1850
+ } // past the restored tab
1851
+ dlog('re-login window opened for profile ' + name + ' — log in by hand, then close the window');
1852
+ emit('sessions');
1853
+ void (async () => {
1854
+ const { listSessions: listPw } = await import('./playwright/sessions.js');
1855
+ let pid;
1856
+ try {
1857
+ pid = (await listPw(Date.now())).find((x) => x.name === winId)?.pid;
1858
+ }
1859
+ catch { /* */ }
1860
+ const alive = () => { if (pid === undefined)
1861
+ return true; try {
1862
+ return execSync('ps -axo ppid=,comm= | awk \'$1==' + pid + '\'', { encoding: 'utf8' }).toLowerCase().includes('chrom');
1863
+ }
1864
+ catch {
1865
+ return true;
1866
+ } };
1867
+ while (alive()) {
1868
+ await new Promise((r) => setTimeout(r, 1000));
1869
+ if (busy !== winId)
1870
+ break;
1871
+ }
1872
+ })().finally(() => { busy = null; activeAdapter = null; dlog('re-login window closed: ' + name); emit('sessions'); });
1873
+ return { ok: true };
1874
+ }
1875
+ catch (e) {
1876
+ busy = null;
1877
+ return { ok: false, error: String(e) };
1878
+ }
1879
+ },
1880
+ profileRename: (from, to) => {
1881
+ const bad = (n) => !/^[\w.-]+$/.test(n) || n === '.' || n === '..';
1882
+ if (bad(from) || bad(to))
1883
+ return { ok: false, error: 'names must be letters/numbers/-._' };
1884
+ if (!existsSync2(profileDir(from)))
1885
+ return { ok: false, error: 'no profile "' + from + '"' };
1886
+ if (existsSync2(profileDir(to)))
1887
+ return { ok: false, error: '"' + to + '" already exists' };
1888
+ try {
1889
+ renameSync(profileDir(from), profileDir(to));
1890
+ recordStore.renameProfileRefs(from, to);
1891
+ dlog('profile renamed: ' + from + ' → ' + to);
1892
+ emit('sessions');
1893
+ return { ok: true };
1894
+ }
1895
+ catch (e) {
1896
+ return { ok: false, error: String(e) };
1897
+ }
1898
+ },
1899
+ profileDelete: (name) => {
1900
+ if (!/^[\w.-]+$/.test(name) || name === '.' || name === '..')
1901
+ return { ok: false };
1902
+ try {
1903
+ rmSync(profileDir(name), { recursive: true, force: true });
1904
+ dlog('profile deleted (logged out): ' + name);
1905
+ emit('sessions');
1906
+ return { ok: true };
1907
+ }
1908
+ catch {
1909
+ return { ok: false };
1910
+ }
1911
+ },
1912
+ // Status chip engine (Task C item 1) — the SAME flow as `dev profile-status`
1913
+ // (Task A), reused rather than duplicated: one polite headless load of the
1914
+ // site's map homeUrl, settle, classifyAuthLanding against the site's own
1915
+ // fingerprints. Cached per (profile,site) so the tab can show a last-checked
1916
+ // time without re-checking on every render.
1917
+ profileStatus: async (name, site) => {
1918
+ if (busy)
1919
+ return { ok: false, error: 'a driven browser is already open (' + busy + ')' };
1920
+ if (!/^[\w.-]+$/.test(name) || name === '.' || name === '..')
1921
+ return { ok: false, error: 'bad profile name' };
1922
+ const node = store.getNode(site);
1923
+ const url = node?.homeUrl;
1924
+ if (!url)
1925
+ return { ok: false, error: 'no map for site "' + site + '" yet (no homeUrl)' };
1926
+ const dir = profileDir(name);
1927
+ try {
1928
+ mkdirSync(dir, { recursive: true });
1929
+ }
1930
+ catch { /* */ }
1931
+ prepProfile(dir);
1932
+ const winId = 'pchk-' + name;
1933
+ busy = winId;
1934
+ const session = 'pchk-' + Math.random().toString(36).slice(2, 6);
1935
+ const { settleSnapshot } = await import('./router/browse.js');
1936
+ const { classifyAuthLanding } = await import('./router/auth-status.js');
1937
+ const adapter = new PlaywrightAdapter(session, undefined, undefined, { headed: false, persistent: true, profile: dir });
1938
+ try {
1939
+ await adapter.open(url);
1940
+ const landedUrl = await adapter.currentUrl();
1941
+ const snapshot = await settleSnapshot(() => adapter.snapshot());
1942
+ const states = store.statesForNode(site);
1943
+ const { auth, loginUrl } = classifyAuthLanding(landedUrl, snapshot, site, states);
1944
+ const checkedAt = new Date().toISOString();
1945
+ statusCache.set(name + '\x00' + site, { auth, ...(loginUrl ? { loginUrl } : {}), checkedAt });
1946
+ emit('sessions');
1947
+ return { ok: true, auth, ...(loginUrl ? { loginUrl } : {}), checkedAt };
1948
+ }
1949
+ catch (e) {
1950
+ return { ok: false, error: String(e) };
1951
+ }
1952
+ finally {
1953
+ await adapter.close().catch(() => { });
1954
+ busy = null;
1955
+ }
1956
+ },
1957
+ // Reset profile (Task C item 4): per-origin cookie clearing was investigated —
1958
+ // playwright-cli's cookie-clear/cookie-delete are process-wide (no --domain
1959
+ // filter, unlike cookie-list's read-only filter), so deleting a same-named
1960
+ // cookie risks wiping an unrelated site sharing this profile. Not cleanly
1961
+ // reachable → recreate the profile dir in place instead (same name, empty):
1962
+ // honest "log in with a different account", logs out every site in it.
1963
+ profileReset: (name) => {
1964
+ if (!/^[\w.-]+$/.test(name) || name === '.' || name === '..')
1965
+ return { ok: false, error: 'bad profile name' };
1966
+ const dir = profileDir(name);
1967
+ if (busy === 'relogin-' + name)
1968
+ return { ok: false, error: 'close the login window first' };
1969
+ try {
1970
+ rmSync(dir, { recursive: true, force: true });
1971
+ mkdirSync(dir, { recursive: true });
1972
+ for (const key of statusCache.keys()) {
1973
+ if (key.startsWith(name + '\x00'))
1974
+ statusCache.delete(key);
1975
+ }
1976
+ dlog('profile reset (logged out of all sites): ' + name);
1977
+ emit('sessions');
1978
+ return { ok: true };
1979
+ }
1980
+ catch (e) {
1981
+ return { ok: false, error: String(e) };
1982
+ }
1983
+ },
1984
+ events: (id) => {
1985
+ const evs = recordStore.events(id);
1986
+ return { events: evs, coverage: coverage(evs) };
1987
+ },
1988
+ replay: async (id, mode = 'steps') => {
1989
+ if (busy)
1990
+ return { ok: false, error: 'a driven browser is already open (' + busy + ')' };
1991
+ if (mode === 'ledger') {
1992
+ const events = recordStore.events(id);
1993
+ if (!events.length)
1994
+ return { ok: false, error: 'no ledger — recorded before the ledger existed; use steps replay' };
1995
+ const site = (() => { try {
1996
+ return new URL(String(events[0].descriptor.url ?? events[0].descriptor.fromUrl)).host;
1997
+ }
1998
+ catch {
1999
+ return '';
2000
+ } })();
2001
+ const label = (e) => {
2002
+ const d = e.descriptor;
2003
+ return String(d.name ?? d.ariaLabel ?? d.leafText ?? d.placeholder ?? e.kind);
2004
+ };
2005
+ busy = 'replay:' + id;
2006
+ const ctl = new ReplayController(id, events.map((e) => ({ seq: e.seq, label: label(e) })));
2007
+ activeCtl = ctl;
2008
+ const adapter = new PlaywrightAdapter('replay-' + id, undefined, undefined, { headed: true });
2009
+ void runLedgerReplay(events, ctl, { adapter, creds, site, shotsDir: join(shotsRoot, id) })
2010
+ .catch(() => { })
2011
+ .finally(() => { busy = null; });
2012
+ return { ok: true };
2013
+ }
2014
+ const effects = recordStore.actionEffects(id);
2015
+ if (!effects.length)
2016
+ return { ok: false, error: 'empty recording' };
2017
+ busy = 'replay:' + id;
2018
+ const site = (() => { try {
2019
+ return new URL(effects[0].fromUrl).host;
2020
+ }
2021
+ catch {
2022
+ return '';
2023
+ } })();
2024
+ const ctl = new ReplayController(id, effects.map((e) => ({ seq: e.seq, label: e.action?.name ?? (e.navigated ? 'jump' : 'observe') })));
2025
+ activeCtl = ctl;
2026
+ const adapter = new PlaywrightAdapter('replay-' + id, undefined, undefined, { headed: true });
2027
+ void runReplay(effects, ctl, { adapter, creds, site, shotsDir: join(shotsRoot, id) })
2028
+ .catch(() => { })
2029
+ .finally(() => { busy = null; });
2030
+ return { ok: true };
2031
+ },
2032
+ replayState: () => activeCtl?.state ?? null,
2033
+ replayControl: (action, p) => {
2034
+ if (!activeCtl)
2035
+ return false;
2036
+ if (action === 'supply')
2037
+ return activeCtl.supply(p.value ?? '', !!p.save);
2038
+ if (action === 'confirm')
2039
+ return activeCtl.confirm(!!p.fire);
2040
+ return activeCtl.control(action);
2041
+ },
2042
+ shotPath: (session, file) =>
2043
+ // review finding: '.'/'..' pass [\w.-]+ (dots are in the class) → one-level traversal
2044
+ /^[\w.-]+$/.test(session) && session !== '.' && session !== '..' && /^step-\d+\.png$/.test(file)
2045
+ ? join(shotsRoot, session, file) : null,
2046
+ };
2047
+ startDashboard(store, creds, { port }, rec);
2048
+ const url = `http://127.0.0.1:${port}`;
2049
+ process.stderr.write(`webnav dashboard running at ${url}\n(reads ./webnav.db + ${process.env.WEBNAV_CREDS ?? '~/.webnav/credentials.json'}; Ctrl-C to stop)\n`);
2050
+ // Open a browser tab ONLY when asked (--open). Default is quiet: auto-opening a
2051
+ // tab on every start floods the user's Chrome — the URL is printed to click.
2052
+ if (args.open && process.platform === 'darwin') {
2053
+ const { exec } = await import('node:child_process');
2054
+ exec(`open ${url}`, () => { });
2055
+ }
2056
+ // Keep the process alive (the server holds the event loop; nothing else to do).
2057
+ return;
2058
+ }
2059
+ if (args.cmd === 'login') {
2060
+ // Save the hosted-route API key to ~/.webnav/config.json. This file holds ONLY
2061
+ // the service key — never site credentials (those stay in credentials.json).
2062
+ const { saveConfig } = await import('./hosted.js');
2063
+ const { configPath } = await import('./paths.js');
2064
+ if (!args.key) {
2065
+ console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav login <api-key>' }, null, 2));
2066
+ process.exitCode = 2;
2067
+ return;
2068
+ }
2069
+ saveConfig({ apiKey: args.key });
2070
+ console.log(JSON.stringify({ status: 'ok', saved: configPath(), note: 'hosted route enabled — use `webnav walk --hosted ...`' }, null, 2));
2071
+ return;
2072
+ }
2073
+ if (args.cmd === 'test') {
2074
+ const { readFileSync } = await import('node:fs');
2075
+ const { MapStore } = await import('./mapstore/store.js');
2076
+ const { parseSuite, runSuite, SuiteConfigError } = await import('./router/suite.js');
2077
+ const { makeLiveWalkBrowser } = await import('./router/walk-live.js');
2078
+ const { PlaywrightAdapter, resolveProfile } = await import('./playwright/adapter.js');
2079
+ const { ensureCanOpen } = await import('./playwright/sessions.js');
2080
+ const { settleSnapshot } = await import('./router/browse.js');
2081
+ const { classifyAuthLanding } = await import('./router/auth-status.js');
2082
+ const { CredStore } = await import('./creds.js');
2083
+ const { homedir } = await import('node:os');
2084
+ const { join } = await import('node:path');
2085
+ if (!args.suite) {
2086
+ console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav test --suite <file.suite.json> [--headless]' }, null, 2));
2087
+ process.exitCode = 2;
2088
+ return;
2089
+ }
2090
+ let suite;
2091
+ try {
2092
+ suite = parseSuite(JSON.parse(readFileSync(args.suite, 'utf8')));
2093
+ }
2094
+ catch (e) {
2095
+ const hint = e instanceof SuiteConfigError ? e.message : 'could not read/parse suite: ' + String(e.message);
2096
+ console.log(JSON.stringify({ status: 'error', hint }, null, 2));
2097
+ process.exitCode = 2;
2098
+ return;
2099
+ }
2100
+ const store = new MapStore(dbPath());
2101
+ const node = store.getNode(suite.site);
2102
+ if (!node) {
2103
+ console.log(JSON.stringify({ status: 'error', hint: `no map for site '${suite.site}' — map it first (dev record-start) then re-run` }, null, 2));
2104
+ process.exitCode = 2;
2105
+ return;
2106
+ }
2107
+ const states = store.statesForNode(suite.site);
2108
+ // A suite always runs headless (a release check should never pop windows; the
2109
+ // suite opens a fresh browser per case). --headless is documented as the
2110
+ // recommended flag but the runner enforces headless regardless of the default.
2111
+ const profilesRoot = join(homedir(), '.webnav', 'profiles');
2112
+ const profileDir = suite.profile ? resolveProfile(suite.profile, profilesRoot) : undefined;
2113
+ const bopts = { headed: false, ...(profileDir ? { persistent: true, profile: profileDir } : {}) };
2114
+ const siteCreds = new CredStore().get(suite.site);
2115
+ const progress = [];
2116
+ const shortId = () => 'tst-' + Math.random().toString(36).slice(2, 6);
2117
+ const res = await runSuite(suite, {
2118
+ store, states,
2119
+ onProgress: (l) => { progress.push(l); process.stderr.write(l + '\n'); },
2120
+ // AUTH PRE-FLIGHT (once, before case 1): the SAME check as `dev profile-status`
2121
+ // — one headless load of the site homeUrl, settle, classify against the map's
2122
+ // own fingerprints (the oracle). needs-login fails the whole run fast.
2123
+ preflight: async () => {
2124
+ const session = shortId();
2125
+ const adapter = new PlaywrightAdapter(session, undefined, undefined, bopts);
2126
+ try {
2127
+ await adapter.open(node.homeUrl ?? 'about:blank');
2128
+ const landedUrl = await adapter.currentUrl();
2129
+ const snapshot = await settleSnapshot(() => adapter.snapshot());
2130
+ const { auth, loginUrl } = classifyAuthLanding(landedUrl, snapshot, suite.site, states);
2131
+ return { auth, loginUrl };
2132
+ }
2133
+ finally {
2134
+ await adapter.close().catch(() => { });
2135
+ }
2136
+ },
2137
+ // A FRESH browser session per case (the CF first-load pattern), reaped after.
2138
+ openCase: async (startUrl) => {
2139
+ const session = shortId();
2140
+ const gate = await ensureCanOpen(session, []);
2141
+ if (!gate.ok)
2142
+ throw new Error(gate.reason);
2143
+ const adapter = new PlaywrightAdapter(session, undefined, undefined, bopts);
2144
+ await adapter.open(startUrl);
2145
+ const browser = makeLiveWalkBrowser(adapter, { ...siteCreds }, bopts, session);
2146
+ return { browser, close: async () => { await adapter.close().catch(() => { }); } };
2147
+ },
2148
+ });
2149
+ console.log(JSON.stringify(res, null, 2));
2150
+ process.exitCode = res.status === 'ok' ? 0 : 3;
2151
+ return;
2152
+ }
2153
+ if (args.cmd === 'walk') {
2154
+ const { MapStore } = await import('./mapstore/store.js');
2155
+ const { ensureSeeded } = await import('./graph/seed.js');
2156
+ const { findPath } = await import('./router/path.js');
2157
+ const { walkRoute } = await import('./router/walk.js');
2158
+ const { WalkSessionStore } = await import('./router/walk-session.js');
2159
+ const { makeLiveWalkBrowser } = await import('./router/walk-live.js');
2160
+ const { PlaywrightAdapter } = await import('./playwright/adapter.js');
2161
+ const store = new MapStore();
2162
+ if (args.hosted) {
2163
+ // HOSTED ROUTE: fetch the site's map LIVE from the service and import it,
2164
+ // instead of using the local seed. The site id is the start state's prefix
2165
+ // (e.g. www.saucedemo.com:login -> www.saucedemo.com). Credentials are still
2166
+ // loaded LOCALLY below (CredStore) — the hosted route never sees them.
2167
+ const site = args.start.includes(':') ? args.start.slice(0, args.start.lastIndexOf(':')) : args.start;
2168
+ try {
2169
+ const { fetchHostedMap, importMapPack } = await import('./hosted.js');
2170
+ const pack = await fetchHostedMap(site);
2171
+ importMapPack(store, pack);
2172
+ }
2173
+ catch (e) {
2174
+ console.log(JSON.stringify({ status: 'failed', reason: String(e.message) }, null, 2));
2175
+ process.exitCode = 2;
2176
+ return;
2177
+ }
2178
+ }
2179
+ else {
2180
+ ensureSeeded(store);
2181
+ }
2182
+ if (!store.getState(args.start)) {
2183
+ console.log(JSON.stringify({ status: 'failed', reason: 'unknown state ' + args.start }, null, 2));
2184
+ process.exitCode = 2;
2185
+ return;
2186
+ }
2187
+ if (!store.getState(args.goal)) {
2188
+ console.log(JSON.stringify({ status: 'failed', reason: 'unknown state ' + args.goal }, null, 2));
2189
+ process.exitCode = 2;
2190
+ return;
2191
+ }
2192
+ const path = findPath(store, args.start, args.goal);
2193
+ if (!path) {
2194
+ console.log(JSON.stringify({ status: 'failed', reason: 'no route from ' + args.start + ' to ' + args.goal }, null, 2));
2195
+ process.exitCode = 3;
2196
+ return;
2197
+ }
2198
+ const startState = store.getState(args.start);
2199
+ const states = store.statesForNode(startState.nodeId ?? '');
2200
+ // --observe <label>: resolved against the goal's node like --start/--goal (a
2201
+ // state id, or its bare semanticName) — same scope, so `--observe inventory`
2202
+ // finds `www.saucedemo.com:inventory` without repeating the site prefix. A
2203
+ // label that resolves to nothing errors HERE, loudly, BEFORE any browser
2204
+ // opens — a silent drop would return a normal done with zero checkpoints and
2205
+ // no signal that the typo'd observe never armed (same posture as a bad
2206
+ // --start/--goal).
2207
+ const observe = [];
2208
+ for (const label of args.observe) {
2209
+ const id = resolveObserveLabel(states, label);
2210
+ if (!id) {
2211
+ console.log(JSON.stringify({ status: 'error',
2212
+ hint: 'unknown --observe state "' + label + '" — known states for ' + (startState.nodeId ?? args.start)
2213
+ + ': ' + states.map((s) => s.semanticName).join(', ') }, null, 2));
2214
+ process.exitCode = 2;
2215
+ return;
2216
+ }
2217
+ observe.push(id);
2218
+ }
2219
+ const browserSession = 'w-' + Date.now();
2220
+ // Live-session CEILING (prevents the browser-count explosion). First frees orphans +
2221
+ // abandoned paused-walk browsers older than 1h (the real leak — a needs-* pause leaves a
2222
+ // live daemon nothing else reaps), then refuses if still at the cap. Never breaks a walk
2223
+ // on a housekeeping error (ensureCanOpen degrades to ok).
2224
+ const { ensureCanOpen } = await import('./playwright/sessions.js');
2225
+ const staleWalks = new WalkSessionStore().staleBrowserSessions(60 * 60 * 1000);
2226
+ const gate = await ensureCanOpen(browserSession, staleWalks);
2227
+ if (!gate.ok) {
2228
+ console.log(JSON.stringify({ status: 'error', reason: gate.reason }, null, 2));
2229
+ process.exitCode = 2;
2230
+ return;
2231
+ }
2232
+ // --profile may be a session NAME (reuse a dashboard hand-login) or an absolute
2233
+ // path. A bare name resolves to ~/.webnav/profiles/<name> — the same dir the
2234
+ // dashboard's persistent recording wrote, so a Cloudflare/2FA login done by
2235
+ // hand once carries into every walk.
2236
+ const bopts = { ...args.browser };
2237
+ if (bopts.profile) {
2238
+ const { homedir } = await import('node:os');
2239
+ const { join } = await import('node:path');
2240
+ const { resolveProfile } = await import('./playwright/adapter.js');
2241
+ bopts.profile = resolveProfile(bopts.profile, join(homedir(), '.webnav', 'profiles'));
2242
+ }
2243
+ const adapter = new PlaywrightAdapter(browserSession, undefined, undefined, bopts);
2244
+ await adapter.open(startState.urlPattern || 'about:blank');
2245
+ // Inputs = stored creds for this site (if any) overlaid with any --input flags
2246
+ // (flags win). Lets `walk` run without re-typing credentials each time, while
2247
+ // a one-off --input still overrides. Creds live in ~/.webnav/credentials.json,
2248
+ // never in the map.
2249
+ const { CredStore } = await import('./creds.js');
2250
+ const siteCreds = startState.nodeId ? new CredStore().get(startState.nodeId) : {};
2251
+ const inputs = { ...siteCreds, ...args.inputs };
2252
+ // Pass bopts through so the walk can retry a detected SSO wall in a FRESH
2253
+ // session under the SAME profile (design item 2) — omitted (no retry
2254
+ // capability) when the walk isn't running under a named profile.
2255
+ const browser = makeLiveWalkBrowser(adapter, inputs, bopts, browserSession);
2256
+ const res = await walkRoute({ goalName: 'walk:' + args.goal, startStateId: args.start, goalStateId: args.goal, store, states, browser, path, profile: args.browser.profile, observe, observeDynamic: args.observeDynamic });
2257
+ // needs-auth: the wall persisted through the fresh-session retry (or no retry was
2258
+ // possible). Fail-fast, NOT a resumable pause — a stale login can't be fixed by
2259
+ // replaying the same route, so leaving a paused walk-session + a live daemon
2260
+ // around would just be a dangling browser nothing resumes. Close it here; the
2261
+ // consumer contract is: the agent tells the user to re-login, then re-runs `walk`.
2262
+ if (res.status === 'needs-auth') {
2263
+ await browser.close?.().catch(() => { });
2264
+ console.log(JSON.stringify(res, null, 2));
2265
+ process.exitCode = 2;
2266
+ return;
2267
+ }
2268
+ if (res.status === 'needs-navigation' || res.status === 'needs-classification' || res.status === 'checkpoint') {
2269
+ const sessions = new WalkSessionStore();
2270
+ // A wall retry may have rotated to a brand-new browser session — read it
2271
+ // back from the browser (not the original `browserSession` var) so a paused
2272
+ // session id the agent resumes against actually points at the live browser.
2273
+ const liveSession = browser.sessionId?.() || browserSession;
2274
+ const id = sessions.create({ startState: args.start, goalState: args.goal, path, browserSession: liveSession, profile: bopts.profile, observe, observeDynamic: args.observeDynamic });
2275
+ // pos points at the state the walk paused ON, so resume restarts there.
2276
+ const pausedAt = res.at;
2277
+ if (typeof pausedAt === 'number')
2278
+ sessions.advance(id, pausedAt);
2279
+ // Fire-once tracking: a checkpoint pause adds its OWN state to `observed` so a
2280
+ // resume doesn't re-fire it; pauseKind gates walk-resume's `--continue` to only
2281
+ // ever answer a checkpoint (never a needs-navigation/needs-classification pause).
2282
+ const observedNow = res.status === 'checkpoint' ? [res.state] : [];
2283
+ sessions.setPause(id, observedNow, res.status);
2284
+ // Expose browserSession so the agent can act on the LIVE paused browser
2285
+ // (e.g. fire an in-page affordance) via `use <verb> --session <browserSession>`
2286
+ // before calling walk-resume.
2287
+ console.log(JSON.stringify({ ...res, session: id, browserSession: liveSession }, null, 2));
2288
+ }
2289
+ else {
2290
+ if (browser.close)
2291
+ await browser.close().catch(() => { });
2292
+ else
2293
+ await adapter.close().catch(() => { });
2294
+ console.log(JSON.stringify(res, null, 2));
2295
+ if (res.status === 'failed')
2296
+ process.exitCode = 3;
2297
+ }
2298
+ return;
2299
+ }
2300
+ if (args.cmd === 'walk-resume') {
2301
+ const { MapStore } = await import('./mapstore/store.js');
2302
+ const { walkRoute } = await import('./router/walk.js');
2303
+ const { WalkSessionStore } = await import('./router/walk-session.js');
2304
+ const { makeLiveWalkBrowser } = await import('./router/walk-live.js');
2305
+ const { PlaywrightAdapter } = await import('./playwright/adapter.js');
2306
+ const store = new MapStore();
2307
+ const sessions = new WalkSessionStore();
2308
+ const w = sessions.load(args.session);
2309
+ if (!w) {
2310
+ console.log(JSON.stringify({ status: 'failed', reason: 'no active walk-session ' + args.session }, null, 2));
2311
+ process.exitCode = 2;
2312
+ return;
2313
+ }
2314
+ // --continue only ever answers a checkpoint pause — --ref/--classify answer the
2315
+ // OTHER pause kinds. Reject early with a hint rather than silently misapplying it
2316
+ // (walkRoute would otherwise just fall through to a normal step, masking the mistake).
2317
+ if (args.continue && w.pauseKind !== 'checkpoint') {
2318
+ console.log(JSON.stringify({ status: 'failed',
2319
+ reason: '--continue answers a checkpoint pause, but this session paused on ' + (w.pauseKind ?? 'unknown')
2320
+ + ' — use --ref (needs-navigation) or --classify (needs-classification) instead' }, null, 2));
2321
+ process.exitCode = 2;
2322
+ return;
2323
+ }
2324
+ const answer = args.continue ? { kind: 'continue' }
2325
+ : args.ref ? { kind: 'ref', ref: args.ref }
2326
+ : args.classify ? { kind: 'classify', verdict: args.classify }
2327
+ : undefined;
2328
+ if (!answer) {
2329
+ console.log(JSON.stringify({ status: 'failed', reason: 'supply --ref, --classify, or --continue' }, null, 2));
2330
+ process.exitCode = 2;
2331
+ return;
2332
+ }
2333
+ const resumeFrom = w.path[w.pos] ?? w.startState;
2334
+ const adapter = new PlaywrightAdapter(w.browserSession); // reattach the live browser
2335
+ const startState = store.getState(resumeFrom) ?? store.getState(w.startState);
2336
+ // Rebuild the SAME stored creds the original `walk` used, so input steps
2337
+ // encountered AFTER the pause (e.g. checkout's firstName/lastName/zip) still
2338
+ // auto-fill. Keying is by node, so it covers any input step on the route, not
2339
+ // just login. Without this the resume hits unfillable fields and fails to
2340
+ // resolve them — the bug that forced a `use` fallback.
2341
+ // One-off `--input` values are runtime-only (never persisted in the walk
2342
+ // session), so the agent re-supplies them here; flags win over stored creds,
2343
+ // same overlay as `walk`.
2344
+ const { CredStore } = await import('./creds.js');
2345
+ const siteCreds = startState.nodeId ? new CredStore().get(startState.nodeId) : {};
2346
+ const inputs = { ...siteCreds, ...args.inputs };
2347
+ // w.profile is the ALREADY-RESOLVED profile dir the original `walk` stored —
2348
+ // reusing it here (walk-resume takes no --profile of its own) is what lets a
2349
+ // wall retry reopen under the SAME profile mid-resume.
2350
+ const rbopts = w.profile ? { profile: w.profile } : undefined;
2351
+ const browser = makeLiveWalkBrowser(adapter, inputs, rbopts, w.browserSession);
2352
+ const states = store.statesForNode(startState.nodeId ?? '');
2353
+ const res = await walkRoute({ goalName: 'walk:' + w.goalState, startStateId: resumeFrom, goalStateId: w.goalState, store, states, browser, path: w.path, answer, profile: w.profile,
2354
+ observe: w.observe, observeDynamic: w.observeDynamic, observed: w.observed });
2355
+ if (res.status === 'needs-auth') {
2356
+ sessions.close(args.session);
2357
+ await (browser.close ? browser.close() : adapter.close()).catch(() => { });
2358
+ console.log(JSON.stringify(res, null, 2));
2359
+ process.exitCode = 2;
2360
+ }
2361
+ else if (res.status === 'needs-navigation' || res.status === 'needs-classification' || res.status === 'checkpoint') {
2362
+ // walkRoute's `at` is RELATIVE to resumeFrom (it starts each call at 0), but
2363
+ // the session `pos` is ABSOLUTE over the full path. resumeFrom sits at w.pos,
2364
+ // so absolute = w.pos + at. A single resume can traverse several states before
2365
+ // halting, so this keeps the session synced (the desync bug that restarted the
2366
+ // next resume at the wrong step).
2367
+ const relAt = res.at;
2368
+ const absPos = typeof relAt === 'number' ? w.pos + relAt : w.pos + 1;
2369
+ sessions.advance(args.session, absPos);
2370
+ // A wall retry may have rotated to a brand-new browser session mid-resume;
2371
+ // repoint the (stable) session_id at it so the NEXT walk-resume reattaches
2372
+ // to the live daemon instead of the one that was just closed.
2373
+ const liveSession = browser.sessionId?.() || w.browserSession;
2374
+ if (liveSession !== w.browserSession)
2375
+ sessions.rebrowser(args.session, liveSession);
2376
+ // Fire-once tracking carries forward: append this checkpoint's state (if any)
2377
+ // to the ALREADY-observed set from the original walk / prior resumes.
2378
+ const observedNow = res.status === 'checkpoint' ? [...w.observed, res.state] : w.observed;
2379
+ sessions.setPause(args.session, observedNow, res.status);
2380
+ console.log(JSON.stringify({ ...res, session: args.session, browserSession: liveSession }, null, 2));
2381
+ }
2382
+ else {
2383
+ sessions.close(args.session);
2384
+ await (browser.close ? browser.close() : adapter.close()).catch(() => { });
2385
+ console.log(JSON.stringify(res, null, 2));
2386
+ }
2387
+ return;
2388
+ }
2389
+ if (args.cmd === 'navigate') {
2390
+ const { PlaywrightAdapter } = await import('./playwright/adapter.js');
2391
+ const { RecordStore } = await import('./mapstore/record.js');
2392
+ // Live-session ceiling. ensureCanOpen excludes args.session, so REATTACHING an existing
2393
+ // session (the common record-flow case) is never refused — only a genuinely new session
2394
+ // past the cap is. It also frees orphans + stale paused-walk browsers first.
2395
+ const { ensureCanOpen } = await import('./playwright/sessions.js');
2396
+ const { WalkSessionStore } = await import('./router/walk-session.js');
2397
+ const gate = await ensureCanOpen(args.session, new WalkSessionStore().staleBrowserSessions(60 * 60 * 1000));
2398
+ if (!gate.ok) {
2399
+ console.log(JSON.stringify({ status: 'error', reason: gate.reason }, null, 2));
2400
+ process.exitCode = 2;
2401
+ return;
2402
+ }
2403
+ const { homedir: homedir2 } = await import('node:os');
2404
+ const { join: join2 } = await import('node:path');
2405
+ const profilesRoot2 = join2(homedir2(), '.webnav', 'profiles');
2406
+ // Agent path parity with the dashboard: a bare --profile NAME → the shared
2407
+ // profiles dir (+ prepProfile to clear an orphan lock) so an agent runs under
2408
+ // the same login a human would. resolveProfile leaves absolute paths untouched.
2409
+ const nbrowser = { ...args.browser };
2410
+ if (nbrowser.profile) {
2411
+ const { resolveProfile } = await import('./playwright/adapter.js');
2412
+ const { prepProfile } = await import('./playwright/profile-lock.js');
2413
+ nbrowser.profile = resolveProfile(nbrowser.profile, profilesRoot2);
2414
+ try {
2415
+ (await import('node:fs')).mkdirSync(nbrowser.profile, { recursive: true });
2416
+ }
2417
+ catch { /* */ }
2418
+ prepProfile(nbrowser.profile);
2419
+ }
2420
+ const adapter = new PlaywrightAdapter(args.session, undefined, undefined, nbrowser);
2421
+ try {
2422
+ // `open` creates the session if new AND navigates; it also works to
2423
+ // re-navigate an existing session (whereas `goto` requires the session to
2424
+ // already exist, which fails on the first navigate of a fresh session).
2425
+ await adapter.open(args.url);
2426
+ const rec = new RecordStore(dbPath());
2427
+ // Auto-start recording so an agent session is a first-class, dashboard-visible
2428
+ // recording (steps + video) without a separate record-start. Video capture
2429
+ // starts on the first navigate and is stopped by `dev record-stop`.
2430
+ const fresh = !rec.isActive(args.session);
2431
+ if (fresh) {
2432
+ rec.start(args.session);
2433
+ const profName = nbrowser.profile ? nbrowser.profile.split('/').pop() : null;
2434
+ if (profName)
2435
+ rec.setProfile(args.session, profName);
2436
+ rec.setStartUrl(args.session, args.url);
2437
+ // NOTE: no video here. playwright-cli video capture does NOT survive across
2438
+ // separate CLI processes (proven: video-stop in a later process → "No videos
2439
+ // were recorded"). Agent video needs a long-lived process owning the session
2440
+ // (like the human live-loop) — tracked as a follow-up, NOT faked here.
2441
+ }
2442
+ // Settle + record via the shared seam (same gate as agent-session/runActionRecorded):
2443
+ // a client-side redirect otherwise records a pre-render shell as the page, and the
2444
+ // requestedUrl (what the agent ASKED for) is the draft's redirect-alias evidence.
2445
+ const { recordNavigateEffect, classifyNavigateWall } = await import('./router/browse.js');
2446
+ const { toUrl, toSnapshot } = await recordNavigateEffect(args.url, args.session, rec, adapter);
2447
+ // Wall check (design item 2, NO auto-retry here — a recording captures what
2448
+ // actually happened, judgment-free). Classify against the target site's own
2449
+ // map states (the map is the oracle, same as profile-status/walk) and surface
2450
+ // authWall so the driving agent knows immediately instead of guessing from a
2451
+ // bare snapshot.
2452
+ let site = null;
2453
+ try {
2454
+ site = new URL(args.url).host;
2455
+ }
2456
+ catch { /* unparseable url */ }
2457
+ const { MapStore: MapStoreForWall } = await import('./mapstore/store.js');
2458
+ const wallStates = site ? new MapStoreForWall(dbPath()).statesForNode(site) : [];
2459
+ const wall = classifyNavigateWall(args.url, toUrl, toSnapshot, wallStates);
2460
+ console.log(JSON.stringify({
2461
+ status: 'done', url: toUrl, recorded: true,
2462
+ ...(wall.authWall ? { authWall: true, loginUrl: wall.loginUrl } : {}),
2463
+ }, null, 2));
2464
+ }
2465
+ catch (e) {
2466
+ console.log(JSON.stringify({ status: 'failed', reason: String(e) }, null, 2));
2467
+ process.exitCode = 2;
2468
+ }
2469
+ return;
2470
+ }
2471
+ if (args.cmd === 'snapshot') {
2472
+ const { PlaywrightAdapter } = await import('./playwright/adapter.js');
2473
+ const adapter = new PlaywrightAdapter(args.session);
2474
+ try {
2475
+ // Uniform JSON: every verb's stdout is structured. The page YAML (which the
2476
+ // agent reads for element refs) is carried as the `snapshot` field.
2477
+ const yaml = await adapter.snapshot();
2478
+ console.log(JSON.stringify({ status: 'done', snapshot: yaml }, null, 2));
2479
+ }
2480
+ catch (e) {
2481
+ console.log(JSON.stringify({ status: 'failed', reason: 'no live page for session ' + args.session + ' — run `use navigate` first' }, null, 2));
2482
+ process.exitCode = 2;
2483
+ }
2484
+ return;
2485
+ }
2486
+ if (args.cmd === 'click' || args.cmd === 'type') {
2487
+ const { PlaywrightAdapter } = await import('./playwright/adapter.js');
2488
+ const { RecordStore } = await import('./mapstore/record.js');
2489
+ const { runActionRecorded } = await import('./router/browse.js');
2490
+ const adapter = new PlaywrightAdapter(args.session);
2491
+ try {
2492
+ const fromSnapshot = await adapter.snapshot();
2493
+ const fromUrl = await adapter.currentUrl();
2494
+ // Recover a DURABLE fingerprint for the clicked element from the snapshot we just
2495
+ // took (the ephemeral ref is reassigned per snapshot; role+name+near survive). This
2496
+ // is carried through graph-analyse → graph-edit so an authored map gets fingerprints
2497
+ // without hand-writing them (Phase 4).
2498
+ const { parseSnapshot } = await import('./playwright/snapshot.js');
2499
+ const { recoverFingerprint } = await import('./playwright/fingerprint.js');
2500
+ const nodes = parseSnapshot(fromSnapshot);
2501
+ const chosen = nodes.find((n) => n.ref === args.ref);
2502
+ const elementFp = recoverFingerprint(nodes, args.ref);
2503
+ const action = chosen
2504
+ ? { role: chosen.role, name: chosen.name, ref: args.ref, elementFp }
2505
+ : { role: '', name: null, ref: args.ref };
2506
+ const r = await runActionRecorded({
2507
+ sessionId: args.session, recordStore: new RecordStore(dbPath()),
2508
+ fromUrl, fromSnapshot,
2509
+ action,
2510
+ text: args.cmd === 'type' ? args.text : undefined,
2511
+ adapter: adapter,
2512
+ });
2513
+ console.log(JSON.stringify(r, null, 2));
2514
+ if (r.status === 'failed')
2515
+ process.exitCode = 2;
2516
+ }
2517
+ catch (e) {
2518
+ console.log(JSON.stringify({ status: 'failed', reason: String(e) }, null, 2));
2519
+ process.exitCode = 2;
2520
+ }
2521
+ return;
2522
+ }
2523
+ }
2524
+ // --observe <label>: resolved the same way --start/--goal already are — a full
2525
+ // state id, or its bare semanticName within the node's states (so `--observe
2526
+ // inventory` finds `www.saucedemo.com:inventory` without repeating the prefix).
2527
+ // null = unknown label; the walk handler errors on it (exit 2 + a hint listing
2528
+ // the node's known states) BEFORE opening a browser — same loud posture as a
2529
+ // bad --start/--goal, so a typo never silently disarms the checkpoint.
2530
+ export function resolveObserveLabel(states, label) {
2531
+ const byId = states.find((s) => s.id === label);
2532
+ if (byId)
2533
+ return byId.id;
2534
+ const byName = states.find((s) => s.semanticName === label);
2535
+ return byName ? byName.id : null;
2536
+ }
2537
+ // A result that "ran fine but found nothing/blocked/failed" → exit code 3.
2538
+ function isEmptyOrFailed(response) {
2539
+ if (!response || typeof response !== 'object')
2540
+ return false;
2541
+ const r = response;
2542
+ if (r.status === 'failed')
2543
+ return true;
2544
+ // search: zero results gathered.
2545
+ if (Array.isArray(r.results) && r.results.length === 0)
2546
+ return true;
2547
+ if (Array.isArray(r.evidence) && r.evidence.length === 0)
2548
+ return true;
2549
+ return false;
2550
+ }
2551
+ // Only run when invoked directly, not when imported by tests.
2552
+ if (import.meta.url === `file://${process.argv[1]}`) {
2553
+ // Thrown errors (bad args, unknown verb, crash) → stderr + exit code 2.
2554
+ main().catch((e) => {
2555
+ console.error(e instanceof Error ? e.message : String(e));
2556
+ process.exit(2);
2557
+ });
2558
+ }