@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
@@ -0,0 +1,139 @@
1
+ // ─── layer-3 anchor (the `near` content disambiguator) ───────────────────────
2
+ // All structural, zero-LLM. Verified against the snapshot fixtures in tests/fixtures/.
3
+ /** Ancestors of a node: preceding nodes at strictly-decreasing depth, nearest first. */
4
+ function ancestorsOf(nodes, idx) {
5
+ const out = [];
6
+ let minDepth = nodes[idx].depth;
7
+ for (let i = idx - 1; i >= 0; i--) {
8
+ if (nodes[i].depth < minDepth) {
9
+ out.push({ node: nodes[i], idx: i });
10
+ minDepth = nodes[i].depth;
11
+ }
12
+ }
13
+ return out;
14
+ }
15
+ /** Does ancestor A's bounded subtree (until the next node at depth <= A.depth) contain
16
+ * a node matching `pred`? */
17
+ function subtreeHas(nodes, aIdx, aDepth, pred) {
18
+ for (let j = aIdx + 1; j < nodes.length; j++) {
19
+ if (nodes[j].depth <= aDepth)
20
+ break;
21
+ if (pred(nodes[j], j))
22
+ return true;
23
+ }
24
+ return false;
25
+ }
26
+ /** The LARGEST enclosing ancestor of `candIdx` whose bounded subtree EXCLUDES every other
27
+ * candidate (the candidate's per-row/card scope) — or null if none (flat page). NOT the
28
+ * nearest container (too small) and NOT the smallest-containing-near (too big). */
29
+ function anchorScope(nodes, candIdx, others) {
30
+ let scope = null;
31
+ for (const a of ancestorsOf(nodes, candIdx)) {
32
+ if (subtreeHas(nodes, a.idx, a.node.depth, (_n, j) => j !== candIdx && others.has(j)))
33
+ break; // pulls in a sibling candidate → stop
34
+ scope = a; // still clean → remember, try larger
35
+ }
36
+ return scope;
37
+ }
38
+ /** Given a `near` text, which of `candidateIdxs` have it inside their clean anchor scope. */
39
+ export function resolveByNear(nodes, candidateIdxs, near) {
40
+ const set = new Set(candidateIdxs);
41
+ const hits = [];
42
+ for (const ci of candidateIdxs) {
43
+ const scope = anchorScope(nodes, ci, set);
44
+ if (!scope)
45
+ continue;
46
+ if (subtreeHas(nodes, scope.idx, scope.node.depth, (n, j) => j !== ci && n.name === near))
47
+ hits.push(ci);
48
+ }
49
+ return hits;
50
+ }
51
+ /** Stability score for a candidate `near` text (HIGHER = more durable). Prefers anchors
52
+ * that survive content edits/i18n: +3 id-like (3+ digit run), +0..1 length, -2 free-text
53
+ * prose (space + no digit). Pure, deterministic. */
54
+ export function nearStability(text) {
55
+ let s = 0;
56
+ if (/\d{3,}/.test(text))
57
+ s += 3;
58
+ s += Math.min(text.length, 40) / 40;
59
+ if (/\s/.test(text) && !/\d/.test(text))
60
+ s -= 2;
61
+ return s;
62
+ }
63
+ /** Text-bearing names inside `scope` (doc order, excluding the candidate, skip empty). */
64
+ function scopeTexts(nodes, scope, candIdx) {
65
+ const out = [];
66
+ for (let j = scope.idx + 1; j < nodes.length; j++) {
67
+ if (nodes[j].depth <= scope.node.depth)
68
+ break;
69
+ if (j !== candIdx && nodes[j].name && nodes[j].name.trim())
70
+ out.push(nodes[j].name);
71
+ }
72
+ return out;
73
+ }
74
+ /**
75
+ * Choose WHICH text to store as `near` for a target candidate (the matcher's twin —
76
+ * used at BOTH record-time and step-5 heal so they never diverge). Collect every
77
+ * text in the candidate's clean scope that UNIQUELY resolves to it, then pick the MOST
78
+ * STABLE (nearStability desc; doc order tiebreak). null = honest "can't make unique" flag
79
+ * (a truly content-identical sibling) → the caller escalates.
80
+ */
81
+ export function deriveNear(nodes, candIdx, role, name) {
82
+ const cands = nodes.map((n, i) => ({ n, i })).filter((x) => x.n.role === role && x.n.name === name && x.n.ref).map((x) => x.i);
83
+ if (cands.length <= 1)
84
+ return null; // unique by role+name → no near needed
85
+ if (!cands.includes(candIdx))
86
+ return null;
87
+ const scope = anchorScope(nodes, candIdx, new Set(cands));
88
+ if (!scope)
89
+ return null;
90
+ const qualifying = scopeTexts(nodes, scope, candIdx).filter((t) => {
91
+ const hits = resolveByNear(nodes, cands, t);
92
+ return hits.length === 1 && hits[0] === candIdx;
93
+ });
94
+ if (qualifying.length === 0)
95
+ return null; // honest flag → escalate
96
+ qualifying.sort((a, b) => nearStability(b) - nearStability(a)); // prefer durable anchors (D3)
97
+ return qualifying[0];
98
+ }
99
+ /**
100
+ * Recover a durable ElementFingerprint for the element at `ref` in a live snapshot —
101
+ * role + accessible name + (when the element has same-(role,name) siblings) a `near`
102
+ * content anchor. Used at record-time (the clicked element) and at heal-time (the
103
+ * agent-chosen element) so both produce the same durable key. Returns null if the ref
104
+ * isn't in the snapshot.
105
+ */
106
+ export function recoverFingerprint(nodes, ref) {
107
+ const idx = nodes.findIndex((n) => n.ref === ref);
108
+ if (idx < 0)
109
+ return null;
110
+ const node = nodes[idx];
111
+ return { role: node.role, name: node.name, near: deriveNear(nodes, idx, node.role, node.name) };
112
+ }
113
+ // ─── resolution (the read path) ──────────────────────────────────────────────
114
+ /**
115
+ * Resolve an ElementFingerprint to a live element ref, deterministically (zero-LLM).
116
+ * Strict order:
117
+ * 1. testId (when present) + role match → unique? return it. (Inert in v1: a11y snapshots
118
+ * carry no testid; testId never overrides the durable role+name key.)
119
+ * 2. candidates = role==fp.role AND name==fp.name. 1 → return; 0 → null (drift); >1 → step 3.
120
+ * 3. fp.near present → resolveByNear; exactly 1 → return; else null (escalate).
121
+ * Returns null on no/ambiguous match — the caller escalates to the agent (#5a).
122
+ */
123
+ export function resolveByFingerprint(fp, nodes) {
124
+ if (fp.testId) {
125
+ const hits = nodes.filter((n) => n.ref && n.testId === fp.testId && n.role === fp.role);
126
+ if (hits.length === 1)
127
+ return hits[0].ref;
128
+ // else fall through — testId is a hint, never an override of role+name
129
+ }
130
+ const cands = nodes.map((n, i) => ({ n, i })).filter((x) => x.n.ref && x.n.role === fp.role && x.n.name === fp.name);
131
+ if (cands.length === 1)
132
+ return cands[0].n.ref;
133
+ if (cands.length === 0)
134
+ return null;
135
+ if (!fp.near)
136
+ return null; // genuinely ambiguous, no anchor → escalate
137
+ const hits = resolveByNear(nodes, cands.map((c) => c.i), fp.near);
138
+ return hits.length === 1 ? nodes[hits[0]].ref : null;
139
+ }
@@ -0,0 +1,15 @@
1
+ export interface LockDeps {
2
+ readlink: (p: string) => string;
3
+ isWebnavChrome: (pid: number) => boolean;
4
+ kill: (pid: number) => void;
5
+ unlink: (p: string) => void;
6
+ }
7
+ /** Parse the holder pid from a SingletonLock symlink target ("<host>-<pid>"). */
8
+ export declare function lockHolderPid(target: string): number | null;
9
+ /** Reap a live webnav-owned holder + clear stale Singleton* so a launch starts clean. */
10
+ export declare function prepProfileWith(dir: string, deps: LockDeps): void;
11
+ /** Is pid a WEBNAV-launched Chrome (safe to kill), not the user's real one?
12
+ * Gate on the profiles path in argv — the user's real Chrome never has that. */
13
+ export declare function isWebnavChromePid(pid: number): boolean;
14
+ /** Production wiring. */
15
+ export declare function prepProfile(dir: string): void;
@@ -0,0 +1,51 @@
1
+ // A Chrome profile dir can be opened by only ONE process. A prior webnav Chrome
2
+ // that didn't fully die leaves a SingletonLock (symlink "<host>-<pid>") and keeps
3
+ // the dir held → the next launch fails or hands off to the orphan ("Opening in
4
+ // existing browser session"). prepProfile reaps a LIVE webnav-owned holder and
5
+ // clears the (now stale) Singleton* files before a launch. Deps injected for tests.
6
+ import { readlinkSync, rmSync } from 'node:fs';
7
+ import { execSync } from 'node:child_process';
8
+ import { join } from 'node:path';
9
+ /** Parse the holder pid from a SingletonLock symlink target ("<host>-<pid>"). */
10
+ export function lockHolderPid(target) {
11
+ const n = Number(target.split('-').pop());
12
+ return Number.isInteger(n) && n > 0 ? n : null;
13
+ }
14
+ /** Reap a live webnav-owned holder + clear stale Singleton* so a launch starts clean. */
15
+ export function prepProfileWith(dir, deps) {
16
+ try {
17
+ const pid = lockHolderPid(deps.readlink(join(dir, 'SingletonLock')));
18
+ if (pid && deps.isWebnavChrome(pid))
19
+ deps.kill(pid);
20
+ }
21
+ catch { /* no lock / not a symlink → nothing to reap */ }
22
+ for (const f of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
23
+ try {
24
+ deps.unlink(join(dir, f));
25
+ }
26
+ catch { /* */ }
27
+ }
28
+ }
29
+ /** Is pid a WEBNAV-launched Chrome (safe to kill), not the user's real one?
30
+ * Gate on the profiles path in argv — the user's real Chrome never has that. */
31
+ export function isWebnavChromePid(pid) {
32
+ try {
33
+ const cmd = execSync('ps -p ' + pid + ' -o command=', { encoding: 'utf8' });
34
+ return cmd.includes('.webnav/profiles') && /chrom/i.test(cmd);
35
+ }
36
+ catch {
37
+ return false;
38
+ } // ps says gone → dead holder (stale lock)
39
+ }
40
+ /** Production wiring. */
41
+ export function prepProfile(dir) {
42
+ prepProfileWith(dir, {
43
+ readlink: (p) => readlinkSync(p),
44
+ isWebnavChrome: isWebnavChromePid,
45
+ kill: (pid) => { try {
46
+ process.kill(pid, 'SIGTERM');
47
+ }
48
+ catch { /* */ } },
49
+ unlink: (p) => rmSync(p, { force: true }),
50
+ });
51
+ }
@@ -0,0 +1,72 @@
1
+ export interface SessionInfo {
2
+ name: string;
3
+ live: boolean;
4
+ ageMs: number;
5
+ pid?: number;
6
+ }
7
+ export interface ReapOpts {
8
+ all?: boolean;
9
+ maxAgeMs?: number;
10
+ exclude?: string;
11
+ }
12
+ /** Session name out of a daemon command line (`…--daemon-session=/…/<name>.session`). */
13
+ export declare function sessionNameFromPs(line: string): string | null;
14
+ /** Leading PID from a `ps -eo pid,command` line (or null if it has none). */
15
+ export declare function pidFromPs(line: string): number | null;
16
+ /**
17
+ * Merge on-disk session files with the live daemon process list (`ps -eo pid,command`
18
+ * lines) into one inventory. `live` = a daemon references the session (browser is up);
19
+ * a file with no daemon is an ORPHAN; a live daemon with no file still surfaces. Live
20
+ * sessions carry their daemon `pid` (for reap force-close). Pure — inputs injected.
21
+ */
22
+ export declare function inventorySessions(files: {
23
+ name: string;
24
+ mtimeMs: number;
25
+ }[], psLines: string[], nowMs: number): SessionInfo[];
26
+ /** Which sessions to close. Default: only orphans (dead browser). all → every session.
27
+ * maxAgeMs → also live sessions older than the TTL. exclude → never the current session. */
28
+ export declare function planReap(inv: SessionInfo[], opts: ReapOpts): SessionInfo[];
29
+ export declare const DEFAULT_MAX_SESSIONS = 16;
30
+ /** Resolve the ceiling from `WEBNAV_MAX_SESSIONS`; default 16; garbage/≤0 → default. */
31
+ export declare function ceilingFor(envValue: string | undefined): number;
32
+ /** May a NEW daemonized session open? Pure soft-cap inequality — no per-session judgment
33
+ * (#5a). `max <= 0` / NaN ⇒ unlimited. Best-effort: with no cross-process lock, concurrent
34
+ * openers can briefly overshoot by the number racing — acceptable. */
35
+ export declare function canOpen(liveCount: number, max: number): boolean;
36
+ /** Delete every on-disk `.session` file for `name` (a session can appear under more than
37
+ * one daemon subdir). Best-effort: missing files / unreadable dirs are ignored. This is
38
+ * what actually clears an ORPHAN from the inventory — `playwright-cli close` only removes
39
+ * the file of a LIVE daemon it can talk to, so a dead daemon's stale file lingers forever
40
+ * (the reap-says-done-but-count-unchanged bug) unless we unlink it directly. `root` is the
41
+ * daemon cache dir (injectable for tests; defaults to the real one). */
42
+ export declare function removeSessionFiles(name: string, root?: string): void;
43
+ /** Live inventory of all playwright-cli sessions on this machine. */
44
+ export declare function listSessions(nowMs: number): Promise<SessionInfo[]>;
45
+ /**
46
+ * Close one session: graceful `playwright-cli close` first; if that leaves the daemon ALIVE
47
+ * (wedged daemons ignore close — the case that needed a manual kill), force-kill the daemon
48
+ * process group (negative pid → the whole tree, reaping its chrome children too). `pid` is
49
+ * the daemon pid from the inventory; omit for orphans (graceful-only). Returns true if the
50
+ * session is gone after.
51
+ */
52
+ export declare function closeSession(name: string, pid?: number): Promise<boolean>;
53
+ /** Reap per `opts`; returns the names actually closed. */
54
+ /** Close a session BY NAME: look up its daemon pid from the live inventory (so the
55
+ * force-kill path works if graceful close wedges), then closeSession. */
56
+ export declare function closeByName(name: string): Promise<boolean>;
57
+ export declare function reapSessions(nowMs: number, opts: ReapOpts): Promise<string[]>;
58
+ /**
59
+ * Enforce the live-session ceiling before a DAEMONIZED verb (walk / use navigate / record)
60
+ * opens a NEW browser. First frees what it safely can — orphans (dead-browser) AND
61
+ * abandoned paused-walk browsers older than the stale-walk TTL (the real leak: a `needs-*`
62
+ * pause leaves a LIVE daemon nothing else collects) — then checks `canOpen`. Returns
63
+ * `{ ok:true }` to proceed, or `{ ok:false, reason }` to refuse with a clear message.
64
+ * `staleWalkBrowsers` is the set of browser_session ids of paused walks older than the TTL
65
+ * (supplied by the caller, which has the WalkSessionStore); never reaps `currentSession`.
66
+ * Errors degrade to ok:true — the ceiling must never wedge a legitimate command shut on a
67
+ * housekeeping failure.
68
+ */
69
+ export declare function ensureCanOpen(currentSession: string, staleWalkBrowsers?: string[]): Promise<{
70
+ ok: boolean;
71
+ reason?: string;
72
+ }>;
@@ -0,0 +1,229 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readdirSync, statSync, rmSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { wireSessionName } from './adapter.js';
7
+ const execFileAsync = promisify(execFile);
8
+ /** Session name out of a daemon command line (`…--daemon-session=/…/<name>.session`). */
9
+ export function sessionNameFromPs(line) {
10
+ const m = line.match(/--daemon-session=\S*?\/([^/]+)\.session\b/);
11
+ return m ? m[1] : null;
12
+ }
13
+ /** Leading PID from a `ps -eo pid,command` line (or null if it has none). */
14
+ export function pidFromPs(line) {
15
+ const m = line.trim().match(/^(\d+)\s/);
16
+ return m ? Number(m[1]) : null;
17
+ }
18
+ /**
19
+ * Merge on-disk session files with the live daemon process list (`ps -eo pid,command`
20
+ * lines) into one inventory. `live` = a daemon references the session (browser is up);
21
+ * a file with no daemon is an ORPHAN; a live daemon with no file still surfaces. Live
22
+ * sessions carry their daemon `pid` (for reap force-close). Pure — inputs injected.
23
+ */
24
+ export function inventorySessions(files, psLines, nowMs) {
25
+ const livePid = new Map();
26
+ for (const line of psLines) {
27
+ const name = sessionNameFromPs(line);
28
+ if (name)
29
+ livePid.set(name, pidFromPs(line) ?? undefined);
30
+ }
31
+ const byName = new Map();
32
+ for (const f of files) {
33
+ const live = livePid.has(f.name);
34
+ byName.set(f.name, { name: f.name, live, ageMs: nowMs - f.mtimeMs, ...(live ? { pid: livePid.get(f.name) } : {}) });
35
+ }
36
+ for (const [name, pid] of livePid) {
37
+ if (!byName.has(name))
38
+ byName.set(name, { name, live: true, ageMs: Infinity, pid });
39
+ }
40
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
41
+ }
42
+ /** Which sessions to close. Default: only orphans (dead browser). all → every session.
43
+ * maxAgeMs → also live sessions older than the TTL. exclude → never the current session. */
44
+ export function planReap(inv, opts) {
45
+ return inv.filter((s) => {
46
+ if (opts.exclude !== undefined && s.name === opts.exclude)
47
+ return false; // never our own browser
48
+ if (opts.all)
49
+ return true;
50
+ if (!s.live)
51
+ return true; // orphan: always reap
52
+ if (opts.maxAgeMs !== undefined && s.ageMs >= opts.maxAgeMs)
53
+ return true;
54
+ return false;
55
+ });
56
+ }
57
+ // ─── live-session ceiling (prevents the browser-count explosion) ─────────────
58
+ export const DEFAULT_MAX_SESSIONS = 16; // clears the sanctioned 11-agent fan-out with headroom
59
+ /** Resolve the ceiling from `WEBNAV_MAX_SESSIONS`; default 16; garbage/≤0 → default. */
60
+ export function ceilingFor(envValue) {
61
+ if (!envValue)
62
+ return DEFAULT_MAX_SESSIONS;
63
+ const n = Number(envValue);
64
+ return Number.isInteger(n) && n > 0 ? n : DEFAULT_MAX_SESSIONS;
65
+ }
66
+ /** May a NEW daemonized session open? Pure soft-cap inequality — no per-session judgment
67
+ * (#5a). `max <= 0` / NaN ⇒ unlimited. Best-effort: with no cross-process lock, concurrent
68
+ * openers can briefly overshoot by the number racing — acceptable. */
69
+ export function canOpen(liveCount, max) {
70
+ if (!Number.isFinite(max) || max <= 0)
71
+ return true;
72
+ return liveCount < max;
73
+ }
74
+ // ─── live wrappers (not unit-tested; thin shells over fs/ps) ──────────────────
75
+ const DAEMON_DIR = join(homedir(), 'Library', 'Caches', 'ms-playwright', 'daemon');
76
+ function listSessionFiles() {
77
+ const out = [];
78
+ let dirs = [];
79
+ try {
80
+ dirs = readdirSync(DAEMON_DIR);
81
+ }
82
+ catch {
83
+ return out;
84
+ }
85
+ for (const d of dirs) {
86
+ let entries = [];
87
+ try {
88
+ entries = readdirSync(join(DAEMON_DIR, d));
89
+ }
90
+ catch {
91
+ continue;
92
+ }
93
+ for (const e of entries) {
94
+ if (!e.endsWith('.session'))
95
+ continue;
96
+ try {
97
+ const mtimeMs = statSync(join(DAEMON_DIR, d, e)).mtimeMs;
98
+ out.push({ name: e.replace(/\.session$/, ''), mtimeMs });
99
+ }
100
+ catch { /* file vanished mid-scan */ }
101
+ }
102
+ }
103
+ return out;
104
+ }
105
+ /** Delete every on-disk `.session` file for `name` (a session can appear under more than
106
+ * one daemon subdir). Best-effort: missing files / unreadable dirs are ignored. This is
107
+ * what actually clears an ORPHAN from the inventory — `playwright-cli close` only removes
108
+ * the file of a LIVE daemon it can talk to, so a dead daemon's stale file lingers forever
109
+ * (the reap-says-done-but-count-unchanged bug) unless we unlink it directly. `root` is the
110
+ * daemon cache dir (injectable for tests; defaults to the real one). */
111
+ export function removeSessionFiles(name, root = DAEMON_DIR) {
112
+ let dirs = [];
113
+ try {
114
+ dirs = readdirSync(root);
115
+ }
116
+ catch {
117
+ return;
118
+ }
119
+ for (const d of dirs) {
120
+ try {
121
+ rmSync(join(root, d, `${name}.session`), { force: true });
122
+ }
123
+ catch { /* gone */ }
124
+ }
125
+ }
126
+ async function listDaemonPs() {
127
+ try {
128
+ const { stdout } = await execFileAsync('ps', ['-eo', 'pid,command'], { maxBuffer: 8 * 1024 * 1024 });
129
+ return stdout.split('\n').filter((l) => l.includes('--daemon-session='));
130
+ }
131
+ catch {
132
+ return [];
133
+ }
134
+ }
135
+ /** Live inventory of all playwright-cli sessions on this machine. */
136
+ export async function listSessions(nowMs) {
137
+ return inventorySessions(listSessionFiles(), await listDaemonPs(), nowMs);
138
+ }
139
+ /** Is a daemon pid still alive? (signal 0 = existence check, doesn't actually signal.) */
140
+ function pidAlive(pid) {
141
+ try {
142
+ process.kill(pid, 0);
143
+ return true;
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
149
+ /**
150
+ * Close one session: graceful `playwright-cli close` first; if that leaves the daemon ALIVE
151
+ * (wedged daemons ignore close — the case that needed a manual kill), force-kill the daemon
152
+ * process group (negative pid → the whole tree, reaping its chrome children too). `pid` is
153
+ * the daemon pid from the inventory; omit for orphans (graceful-only). Returns true if the
154
+ * session is gone after.
155
+ */
156
+ export async function closeSession(name, pid) {
157
+ try {
158
+ await execFileAsync('playwright-cli', [`-s=${wireSessionName(name)}`, 'close'], { maxBuffer: 1024 * 1024 });
159
+ }
160
+ catch { /* graceful close failed; fall through to force-kill if we have a pid */ }
161
+ if (pid === undefined) {
162
+ removeSessionFiles(name);
163
+ return true;
164
+ } // orphan: unlink the stale file
165
+ if (!pidAlive(pid)) {
166
+ removeSessionFiles(name);
167
+ return true;
168
+ } // graceful close worked
169
+ // Wedged: kill the daemon's process GROUP so its chrome children die too. Fall back to the
170
+ // bare pid if the group kill isn't permitted.
171
+ try {
172
+ process.kill(-pid, 'SIGTERM');
173
+ }
174
+ catch {
175
+ try {
176
+ process.kill(pid, 'SIGTERM');
177
+ }
178
+ catch { /* gone */ }
179
+ }
180
+ removeSessionFiles(name); // and sweep the on-disk file regardless
181
+ return !pidAlive(pid);
182
+ }
183
+ /** Reap per `opts`; returns the names actually closed. */
184
+ /** Close a session BY NAME: look up its daemon pid from the live inventory (so the
185
+ * force-kill path works if graceful close wedges), then closeSession. */
186
+ export async function closeByName(name) {
187
+ const pid = (await listSessions(Date.now())).find((s) => s.name === name)?.pid;
188
+ return closeSession(name, pid);
189
+ }
190
+ export async function reapSessions(nowMs, opts) {
191
+ const targets = planReap(await listSessions(nowMs), opts);
192
+ const closed = [];
193
+ for (const t of targets)
194
+ if (await closeSession(t.name, t.pid))
195
+ closed.push(t.name);
196
+ return closed;
197
+ }
198
+ /**
199
+ * Enforce the live-session ceiling before a DAEMONIZED verb (walk / use navigate / record)
200
+ * opens a NEW browser. First frees what it safely can — orphans (dead-browser) AND
201
+ * abandoned paused-walk browsers older than the stale-walk TTL (the real leak: a `needs-*`
202
+ * pause leaves a LIVE daemon nothing else collects) — then checks `canOpen`. Returns
203
+ * `{ ok:true }` to proceed, or `{ ok:false, reason }` to refuse with a clear message.
204
+ * `staleWalkBrowsers` is the set of browser_session ids of paused walks older than the TTL
205
+ * (supplied by the caller, which has the WalkSessionStore); never reaps `currentSession`.
206
+ * Errors degrade to ok:true — the ceiling must never wedge a legitimate command shut on a
207
+ * housekeeping failure.
208
+ */
209
+ export async function ensureCanOpen(currentSession, staleWalkBrowsers = []) {
210
+ const max = ceilingFor(process.env.WEBNAV_MAX_SESSIONS);
211
+ try {
212
+ const now = Date.now();
213
+ let inv = await listSessions(now);
214
+ // free orphans + abandoned stale paused-walk browsers (never the current one)
215
+ const toFree = inv.filter((s) => s.name !== currentSession && (!s.live || staleWalkBrowsers.includes(s.name)));
216
+ for (const s of toFree)
217
+ await closeSession(s.name, s.pid);
218
+ if (toFree.length)
219
+ inv = await listSessions(Date.now());
220
+ const liveOthers = inv.filter((s) => s.live && s.name !== currentSession).length;
221
+ if (!canOpen(liveOthers, max)) {
222
+ return { ok: false, reason: `session ceiling reached (${liveOthers} live, max ${max}); close some with \`webnav dev sessions reap\` or raise WEBNAV_MAX_SESSIONS` };
223
+ }
224
+ return { ok: true };
225
+ }
226
+ catch {
227
+ return { ok: true }; // never wedge a real command shut on a housekeeping error
228
+ }
229
+ }
@@ -0,0 +1,10 @@
1
+ export interface SnapNode {
2
+ role: string;
3
+ name: string | null;
4
+ ref: string | null;
5
+ url: string | null;
6
+ raw: string;
7
+ depth: number;
8
+ }
9
+ export declare function parseSnapshot(yml: string): SnapNode[];
10
+ export declare function findByRoleAndName(nodes: SnapNode[], role: string, name?: string): SnapNode | undefined;
@@ -0,0 +1,52 @@
1
+ // We parse line-by-line with regex rather than the `yaml` dependency on purpose:
2
+ // playwright-cli's snapshot output is YAML-ISH but not valid YAML. Lines carry
3
+ // non-YAML trailing tokens (`link "Learn more" [ref=e6] [cursor=pointer]`) and
4
+ // bare keys like `/url:`, which would make `yaml.parse` choke. Do NOT swap this
5
+ // for the yaml dep without first confirming it handles those trailing tokens.
6
+ // Role token is matched case-insensitively (`\w[\w-]*`): playwright-cli emits
7
+ // both lowercase roles (`link`, `searchbox`) and capitalized ones
8
+ // (`StaticText`, `RootWebArea`, `WebArea`).
9
+ const NODE_RE = /^(\w[\w-]*)\s*(?:"([^"]*)")?/;
10
+ const REF_RE = /\[ref=(e\d+)\]/;
11
+ // Any bracketed attribute, e.g. [ref=e6], [level=1], [cursor=pointer].
12
+ const ATTR_RE = /\[[^\]]+\]/;
13
+ const URL_RE = /^\/url:\s*(.+)$/;
14
+ export function parseSnapshot(yml) {
15
+ const lines = yml.split('\n');
16
+ const nodes = [];
17
+ for (const line of lines) {
18
+ const depth = line.length - line.replace(/^ */, '').length; // leading-space count, BEFORE trim
19
+ const trimmed = line.trim().replace(/^-\s*/, '');
20
+ if (!trimmed)
21
+ continue;
22
+ // A `/url:` line declares the destination of the node ON THE LINE BEFORE it.
23
+ // We attach it to the most-recently-pushed node, assuming the `/url:` line
24
+ // immediately follows its owning node (true for playwright-cli output).
25
+ const urlMatch = trimmed.match(URL_RE);
26
+ if (urlMatch && nodes.length > 0) {
27
+ nodes[nodes.length - 1].url = urlMatch[1].trim();
28
+ continue;
29
+ }
30
+ const m = trimmed.match(NODE_RE);
31
+ if (!m || !m[1])
32
+ continue;
33
+ // A line is a real node only if it carries structure: a quoted "name", a
34
+ // bracketed attribute (`[ref=eN]`, `[level=N]`, `[cursor=...]`), or both. A
35
+ // bare word with none of those is wrapped prose continuation -> skip it.
36
+ // (Capitalization is NOT a signal: capitalized roles are valid nodes.)
37
+ const hasName = m[2] !== undefined;
38
+ const hasAttr = ATTR_RE.test(trimmed);
39
+ if (!hasName && !hasAttr)
40
+ continue;
41
+ const refMatch = trimmed.match(REF_RE);
42
+ // NOTE: names with escaped quotes (e.g. `"say \"hi\""`) are not handled;
43
+ // NODE_RE stops the name at the first inner quote. Out of scope for v1.
44
+ nodes.push({
45
+ role: m[1], name: m[2] ?? null, ref: refMatch ? refMatch[1] : null, url: null, raw: trimmed, depth,
46
+ });
47
+ }
48
+ return nodes;
49
+ }
50
+ export function findByRoleAndName(nodes, role, name) {
51
+ return nodes.find((n) => n.role === role && (name === undefined || n.name === name));
52
+ }
@@ -0,0 +1,14 @@
1
+ export declare const DEFAULT_HOST_INTERVAL_MS = 1000;
2
+ /** Min interval (ms) between opens to one host, from `WEBNAV_HOST_INTERVAL_MS`. 0/garbage → 0 (off). */
3
+ export declare function intervalMs(envValue: string | undefined): number;
4
+ /** How long to wait before opening `host` again. Pure. last=null (never seen) or interval≤0 → 0. */
5
+ export declare function delayFor(lastOpenMs: number | null, nowMs: number, interval: number): number;
6
+ /** Host of a URL, or null for globs / about:blank / non-URLs (→ skip the throttle). Never throws. */
7
+ export declare function hostOf(url: string): string | null;
8
+ /**
9
+ * Throttle an open to `url`'s host: read the last-open time, sleep the remaining interval if
10
+ * any, then record now as the new last-open (atomic UPSERT under the busy_timeout lock).
11
+ * No-ops for hostless urls (globs/about:blank) and when the interval is 0. Best-effort: any
12
+ * db error degrades to no-throttle so housekeeping never breaks the actual open.
13
+ */
14
+ export declare function throttleOpen(url: string, nowMs?: number): Promise<void>;
@@ -0,0 +1,76 @@
1
+ import Database from 'better-sqlite3';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ // Per-host politeness throttle: a minimum interval between page-OPENS to the same host, so a
5
+ // burst of CLI invocations can't hammer one site into a bot-wall (a live incident on a test site).
6
+ // This is POLITENESS, not evasion — it slows webnav down, it never disguises it (the hard
7
+ // no-evasion line, CLAUDE.md). Bot-walls are still detected + escalated; this just stops us
8
+ // provoking one. Each CLI call is a separate PROCESS, so the last-open time must persist
9
+ // CROSS-process — backed by sqlite (file-locked UPSERT) rather than a lockfree JSON that
10
+ // would race under the exact 100-process burst it targets.
11
+ export const DEFAULT_HOST_INTERVAL_MS = 1000;
12
+ /** Min interval (ms) between opens to one host, from `WEBNAV_HOST_INTERVAL_MS`. 0/garbage → 0 (off). */
13
+ export function intervalMs(envValue) {
14
+ if (!envValue)
15
+ return DEFAULT_HOST_INTERVAL_MS;
16
+ const n = Number(envValue);
17
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_HOST_INTERVAL_MS;
18
+ }
19
+ /** How long to wait before opening `host` again. Pure. last=null (never seen) or interval≤0 → 0. */
20
+ export function delayFor(lastOpenMs, nowMs, interval) {
21
+ if (lastOpenMs === null || interval <= 0)
22
+ return 0;
23
+ const elapsed = nowMs - lastOpenMs;
24
+ return elapsed >= interval ? 0 : interval - elapsed;
25
+ }
26
+ /** Host of a URL, or null for globs / about:blank / non-URLs (→ skip the throttle). Never throws. */
27
+ export function hostOf(url) {
28
+ if (!url || url.startsWith('about:') || url.includes('*'))
29
+ return null;
30
+ try {
31
+ const h = new URL(url).host;
32
+ return h || null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ // ─── sqlite persistence (cross-process, file-locked) ──────────────────────────
39
+ function throttleDbPath() {
40
+ // sibling of the creds/config dir; its own tiny db so it never contends with the map db
41
+ return join(homedir(), '.webnav', 'throttle.db');
42
+ }
43
+ let _db = null;
44
+ function db() {
45
+ if (_db)
46
+ return _db;
47
+ const d = new Database(throttleDbPath());
48
+ d.pragma('journal_mode = WAL'); // concurrent readers + a writer
49
+ d.pragma('busy_timeout = 2000'); // wait out a lock under the burst rather than throw
50
+ d.exec('CREATE TABLE IF NOT EXISTS host_last_open (host TEXT PRIMARY KEY, ts INTEGER NOT NULL)');
51
+ _db = d;
52
+ return d;
53
+ }
54
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
55
+ /**
56
+ * Throttle an open to `url`'s host: read the last-open time, sleep the remaining interval if
57
+ * any, then record now as the new last-open (atomic UPSERT under the busy_timeout lock).
58
+ * No-ops for hostless urls (globs/about:blank) and when the interval is 0. Best-effort: any
59
+ * db error degrades to no-throttle so housekeeping never breaks the actual open.
60
+ */
61
+ export async function throttleOpen(url, nowMs = Date.now()) {
62
+ const interval = intervalMs(process.env.WEBNAV_HOST_INTERVAL_MS);
63
+ const host = hostOf(url);
64
+ if (!host || interval <= 0)
65
+ return;
66
+ try {
67
+ const d = db();
68
+ const row = d.prepare('SELECT ts FROM host_last_open WHERE host=?').get(host);
69
+ const wait = delayFor(row ? row.ts : null, nowMs, interval);
70
+ if (wait > 0)
71
+ await sleep(wait);
72
+ d.prepare('INSERT INTO host_last_open (host, ts) VALUES (?, ?) ON CONFLICT(host) DO UPDATE SET ts=excluded.ts')
73
+ .run(host, Date.now());
74
+ }
75
+ catch { /* never break a real open on a throttle-bookkeeping failure */ }
76
+ }