@ai-digital-factory/lmt-web-cli 0.1.2 → 0.1.4

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 (3) hide show
  1. package/README.md +112 -17
  2. package/dist/index.js +506 -92
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -25,14 +25,15 @@ separately from `lmt-cli` and does not use profiles:
25
25
  npx --yes @ai-digital-factory/lmt-web-cli@latest credential provision --env dev --credential-stdin
26
26
  ```
27
27
 
28
- Start a fresh desktop session against Dev and keep the returned `sessionId` for every
28
+ Start or restore a desktop session against Dev and keep the returned `sessionId` for every
29
29
  later command:
30
30
 
31
31
  ```sh
32
32
  npx --yes @ai-digital-factory/lmt-web-cli@latest start --env dev --device desktop
33
33
  ```
34
34
 
35
- The session starts on the Home Page. Use the same session to log into the Operator;
35
+ A new session starts on the Home Page; a restored session follows Web’s existing
36
+ authentication state. Use the same session to log into the Operator when required;
36
37
  if a Zerodha login is required, start that flow and submit the current OTP through
37
38
  standard input only, using the `attemptId` returned by `zerodha-login`:
38
39
 
@@ -68,7 +69,7 @@ complete command list.
68
69
 
69
70
  ## Install
70
71
 
71
- Requires Node.js 22 or later, Chromium, and a macOS or Linux session owner.
72
+ Requires Node.js 22.13 or later, Chromium, and a macOS or Linux session owner.
72
73
  Install the CLI and its browser:
73
74
 
74
75
  ```sh
@@ -106,7 +107,8 @@ Use `--device desktop` for the 1440 × 900 desktop preset or `--headed` to show
106
107
  the browser window. Desktop uses scale 1, no touch emulation, and the installed
107
108
  Chromium runtime's desktop user agent. Results report the actual preset,
108
109
  viewport, scale, touch support, and user agent. Changing device or browser
109
- visibility requires a new session; these options are accepted only by `start`.
110
+ visibility is selected by `start`. Mobile and desktop keep independent saved sessions.
111
+ To change visibility for an already-live device, close its browser first or use `--fresh`.
110
112
  A visible browser requires an active desktop display. All automated browser tests
111
113
  run headlessly, including mobile and desktop acceptance. `pnpm test` requires no
112
114
  desktop display or Xvfb.
@@ -115,6 +117,11 @@ Quitting the browser from its Dock menu or closing its session window also ends
115
117
  the browser session and its local owner. This does not log out the Operator or
116
118
  stop Broker activity. A later `inspect` reports `session_not_found`.
117
119
 
120
+ On Dev Deployment, Dev Operator opens the Web UI with `?captureSafe=1` on the first
121
+ navigation (for example `https://dev-live-market-terminal.piyush-arora.in/?captureSafe=1`)
122
+ so Capture-safe Mode stays enabled for that tab through navigation and refresh.
123
+ `lmt-web` screenshots still mask access-token value fields automatically without that flag.
124
+
118
125
  ```sh
119
126
  lmt-web start --env dev
120
127
  lmt-web inspect --session SESSION_ID
@@ -128,27 +135,85 @@ the page or taking a final screenshot. It also works after the browser window ha
128
135
  been closed manually. Supply the session ID returned by `start`; other sessions
129
136
  remain running. Like `close`, it does not log out or stop Broker activity.
130
137
 
131
- `start` opens the Home Page and returns a session ID. Every later command needs
132
- that ID and talks to the same local browser process without reloading the page.
133
- Concurrent commands within a session run in order. Different session IDs have
134
- separate in-memory browser contexts and storage. A session is owned by the local
135
- user who started it; closing it, losing its owner process, or restarting the
136
- machine requires a new session. The Web CLI does not import `lmt` cookies or
137
- save browser authentication. Closing a browser does not log out, revoke an
138
- Operator Session, or stop Broker activity.
138
+ `start` automatically restores saved authentication for the selected environment,
139
+ Web Origin, and device, or reuses that device's live browser and returns its session
140
+ ID. Every later command needs that ID. Reusing a live browser preserves its current
141
+ page; concurrent commands run in order. Separate mobile and desktop sessions each
142
+ need their own initial login and restore independently thereafter.
143
+
144
+ Closing, stopping, losing the owner process, or restarting the machine preserves
145
+ saved authentication. A subsequent `start` creates a new browser owner and restores
146
+ the same Operator Session cookies when still valid. Gate's expiry, daily-visit
147
+ renewal, revocation, and per-Operator-Session Zerodha authentication remain authoritative.
148
+ Restoration never submits credentials or starts an OTP Challenge. Results include
149
+ `authentication.status` when a recognized Web surface is visible: `operator_login_required`,
150
+ `zerodha_login_required`, or `authenticated`. Follow that state before logging in.
151
+
152
+ Use `start --env dev --device desktop --fresh` for temporary, isolated login-flow
153
+ testing. It neither reads nor overwrites the normal saved session, and its own
154
+ login is not retained after closure. Closing a browser does not log out, revoke an
155
+ Operator Session, or stop Broker activity. Explicit Zerodha logout is retained;
156
+ Operator logout removes that browser's saved Operator Session. Neither logs out
157
+ another device.
158
+
159
+ The CLI saves only cookies applicable to the configured Web Origin, during the
160
+ live session and before successful commands return. It does not persist page
161
+ content, passwords, OTPs, localStorage, or third-party Kite cookies, and never imports
162
+ `lmt` authentication. Saved cookies are replayable authentication stored in private
163
+ SQLite files under `~/.lmt-web/sessions` (directory mode 0700, files 0600). Anyone
164
+ acting as the same OS user can reuse them; keychain encryption is not required.
165
+ `LMT_WEB_STATE_DIRECTORY` overrides the private parent directory for isolated setups.
166
+ Unsafe ownership/permissions, symlinks, or corrupt state fail explicitly with
167
+ `saved_session_unavailable`; there is no silent fallback that overwrites bad state.
168
+ Cookies already committed before a process failure can be restored; a failure between
169
+ a server change and its local checkpoint can still require login. Server-side logout
170
+ or revocation cannot be reversed by an older saved cookie.
171
+
172
+ Results distinguish action completion from the browser's authentication state.
173
+ For example, a successful `operator-login` can return:
174
+
175
+ ```json
176
+ {"outcome":"success","authentication":{"status":"zerodha_login_required","nextAction":"zerodha-login"}}
177
+ ```
178
+
179
+ `authentication.status` is `operator_login_required`, `zerodha_login_required`,
180
+ `zerodha_session_restoring`, `authenticated`, or `unknown`. It observes the
181
+ rendered session surface, including Settings, rather than inferring admission
182
+ from a URL or saved token presence.
183
+ Unrecognized, conflicting, or unavailable page observations return `unknown`;
184
+ a generic loading screen that exceeds the command deadline also returns
185
+ `unknown` alongside the existing action error. `stop` has no page observation
186
+ and returns `unknown`.
187
+
188
+ When present, `authentication.nextAction` names `operator-login`, `zerodha-login`,
189
+ or `zerodha-otp`. The OTP action includes `authentication.attemptId` for the active
190
+ challenge, also available through `inspect`. It is omitted while an attempt is
191
+ submitting or is no longer accepting OTPs; inspect the page and its error before
192
+ deciding to retry. Supply the OTP through `--stdin` with that attempt ID in the
193
+ same browser session. No login or OTP submission is performed by inspection.
194
+ After successful OTP submission, `authentication.status` is `authenticated`.
195
+ Keep that session alive for Market Watch and pairing QA; `lmt` authentication
196
+ does not transfer to a new `lmt-web` session.
139
197
 
140
198
  Each command writes one versioned JSON result to stdout and diagnostics to
141
199
  stderr. Results contain the action outcome, current URL, visible page text and
142
- controls, emulated device and actual viewport, and screenshot outcome. Images
200
+ controls, `authentication`, emulated device and actual viewport, and screenshot
201
+ outcome. Images
143
202
  are written to unique absolute paths in an owner-only temporary directory;
144
- screenshots preserve the rendered page without added blur or masking. Field values
203
+ screenshots solid-mask only Zerodha access-token value fields marked with
204
+ `data-lmt-sensitive="zerodha-access-token"`; other controls and metadata stay visible.
205
+ If protected capture fails, the screenshot outcome is `failed` with no unmasked fallback.
206
+ Field values
145
207
  are omitted from JSON observations. Captures wait for document loading, visible
146
208
  session-loading or busy indicators, fonts, visible images, and finite animations
147
209
  to finish. The page must render text, controls, or visual content; an empty app
148
210
  root is not ready. Pages that remain empty reach the command deadline without a
149
211
  captured screenshot. Infinite animations do not block capture. `--timeout` sets one deadline for the command's browser action and
150
- screenshot, from `1ms` through `10m`. If an operation exceeds it, the session is
151
- closed. Action failures return nonzero; invalid commands and targets return exit code 2.
212
+ screenshot, from `1ms` through `10m`. A recoverable action timeout stops pending loading, waits for the operation to
213
+ settle, and retains a responsive browser. Unsettled work, an unresponsive renderer,
214
+ or an overall inspection/capture deadline can close the owner; use `start` to
215
+ restore saved authentication. Submitted actions remain uncertain and are never
216
+ automatically replayed. Action failures return nonzero; invalid commands and targets return exit code 2.
152
217
  The JSON `error.code` is machine-readable:
153
218
 
154
219
  | Code | Exit | Meaning |
@@ -157,7 +222,8 @@ The JSON `error.code` is machine-readable:
157
222
  | `browser_unavailable` | 1 | Install Chromium with `lmt-web install-browser`, then retry. |
158
223
  | `browser_start_failed` | 1 | The local browser owner exited before its session became ready. |
159
224
  | `service_unavailable` | 1 | The selected Web URL could not be reached; start/check that service. |
160
- | `session_not_found`, `session_unavailable` | 1 | The browser owner ended; start a fresh session. |
225
+ | `session_not_found`, `session_unavailable` | 1 | The browser owner ended; use `start` to restore it. |
226
+ | `saved_session_unavailable` | 1 | Check ownership, private permissions, and integrity of saved-session storage. |
161
227
  | `deadline_exceeded`, `action_failed` | 1 | The action failed or exceeded its finite deadline. |
162
228
 
163
229
  Screenshots cover the viewport by default. `inspect --full-page` and
@@ -321,3 +387,32 @@ sets up its own Chromium runtime, verifies packaged runtime modules/configuratio
321
387
  and reuses all browser acceptance tests. Installed commands and their live owners
322
388
  run outside the checkout. Test-only fixture servers provide repeatable responses;
323
389
  this check performs no npm publication or live dev login.
390
+
391
+ ## Explicit values, equality, scrolling, and full content
392
+
393
+ ```sh
394
+ lmt-web read --session ID --ref FIELD
395
+ lmt-web clipboard --session ID
396
+ lmt-web check --session ID --ref FIELD --stdin < /path/to/protected-expectation
397
+ lmt-web clipboard-check --session ID --ref FIELD
398
+ lmt-web scroll --session ID --ref FIELD
399
+ lmt-web scroll --session ID --y 600
400
+ lmt-web inspect --session ID --full-page
401
+ ```
402
+
403
+ read returns an ordinary field value. clipboard returns content only when it matches
404
+ a verified ordinary field on the current page; unknown or secret content is refused.
405
+ check compares the field with one stdin-supplied expectation (an optional final newline
406
+ is removed). clipboard-check compares clipboard and field inside the browser session.
407
+ Comparisons return only matches: true or matches: false. An unreadable field/clipboard
408
+ or an expired reference is an error without a matches result. Inspect/action observations
409
+ continue to redact secrets, including revealed fields.
410
+
411
+ Referenced scrolling brings the control into view through its ancestors. Pixel scrolling
412
+ targets the largest scrolling content area. Results include before/after positions and
413
+ moved, already_visible, or at_boundary. Each observation expires older references.
414
+
415
+ Full-page capture temporarily expands inner scrolling content and restores its styles
416
+ and positions afterward. A screenshot with unexpanded clipped content reports failed;
417
+ browser-action and screenshot outcomes remain separate. Screenshots retain the existing
418
+ rendered-page confidentiality behavior: protect their files as you would the visible UI.
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/index.ts
4
4
  import { spawn as spawn2 } from "node:child_process";
5
5
  import { createRequire } from "node:module";
6
- import path5 from "node:path";
6
+ import path6 from "node:path";
7
7
  import { fileURLToPath as fileURLToPath3 } from "node:url";
8
8
 
9
9
  // ../cli/src/profile-store.ts
@@ -477,6 +477,147 @@ function resolveCliTarget(env = process.env, configPath = DEFAULT_CONFIG_PATH) {
477
477
  };
478
478
  }
479
479
 
480
+ // src/saved-session.ts
481
+ import { execFileSync } from "node:child_process";
482
+ import { createHash } from "node:crypto";
483
+ import { constants as constants2 } from "node:fs";
484
+ import { lstat as lstat2, mkdir as mkdir2, open as open2 } from "node:fs/promises";
485
+ import os from "node:os";
486
+ import path3 from "node:path";
487
+ import { DatabaseSync } from "node:sqlite";
488
+ var SavedSessionError = class extends Error {
489
+ constructor() {
490
+ super("Could not access protected Web CLI session storage. Check its ownership, permissions, and integrity.");
491
+ }
492
+ };
493
+ async function assertPrivate(filename, directory) {
494
+ const entry = await lstat2(filename);
495
+ if ((directory ? !entry.isDirectory() : !entry.isFile()) || entry.isSymbolicLink() || entry.uid !== process.getuid?.() || (entry.mode & 511) !== (directory ? 448 : 384)) {
496
+ throw new SavedSessionError();
497
+ }
498
+ }
499
+ var SavedSession = class _SavedSession {
500
+ constructor(database, target) {
501
+ this.database = database;
502
+ this.target = target;
503
+ }
504
+ database;
505
+ target;
506
+ static async open(target) {
507
+ let database;
508
+ try {
509
+ const root = process.env.LMT_WEB_STATE_DIRECTORY || path3.join(os.homedir(), ".lmt-web");
510
+ await mkdir2(root, { recursive: true, mode: 448 });
511
+ await assertPrivate(root, true);
512
+ const directory = path3.join(root, "sessions");
513
+ await mkdir2(directory, { mode: 448 }).catch((error) => {
514
+ if (error.code !== "EEXIST") throw error;
515
+ });
516
+ await assertPrivate(directory, true);
517
+ const key = createHash("sha256").update(JSON.stringify([target.environment, target.origin, target.mode])).digest("hex");
518
+ const filename = path3.join(directory, `${key}.sqlite`);
519
+ const file = await open2(filename, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY | constants2.O_NOFOLLOW, 384).catch((error) => {
520
+ if (error.code !== "EEXIST") throw error;
521
+ });
522
+ await file?.close();
523
+ await assertPrivate(filename, false);
524
+ database = new DatabaseSync(filename);
525
+ database.exec("PRAGMA busy_timeout = 25; PRAGMA secure_delete = ON;");
526
+ const store = new _SavedSession(database, target);
527
+ await store.reserve(Date.now() + 5e3, () => {
528
+ database.exec("CREATE TABLE IF NOT EXISTS session (id INTEGER PRIMARY KEY CHECK(id=1), state TEXT, owner TEXT)");
529
+ database.exec("INSERT OR IGNORE INTO session VALUES (1, NULL, NULL)");
530
+ });
531
+ return store;
532
+ } catch {
533
+ database?.close();
534
+ throw new SavedSessionError();
535
+ }
536
+ }
537
+ async reserve(deadlineAt, action) {
538
+ while (true) {
539
+ try {
540
+ this.database.exec("BEGIN IMMEDIATE");
541
+ break;
542
+ } catch (error) {
543
+ if (!/locked|busy/i.test(String(error)) || Date.now() >= deadlineAt) throw new SavedSessionError();
544
+ await new Promise((resolve) => setTimeout(resolve, 20));
545
+ }
546
+ }
547
+ try {
548
+ const result = action();
549
+ this.database.exec("COMMIT");
550
+ return result;
551
+ } catch (error) {
552
+ this.database.exec("ROLLBACK");
553
+ throw error;
554
+ }
555
+ }
556
+ owner() {
557
+ try {
558
+ const row = this.database.prepare("SELECT owner FROM session WHERE id=1").get();
559
+ if (!row?.owner) return void 0;
560
+ const owner = JSON.parse(String(row.owner));
561
+ if (!/^[a-f0-9]{32}$/.test(owner.sessionId) || !Number.isSafeInteger(owner.pid) || owner.pid < 1 || typeof owner.headless !== "boolean" || typeof owner.processStartedAt !== "string") throw new SavedSessionError();
562
+ try {
563
+ process.kill(owner.pid, 0);
564
+ } catch (error) {
565
+ if (error.code !== "ESRCH") throw error;
566
+ return void 0;
567
+ }
568
+ try {
569
+ return this.processStartedAt(owner.pid) === owner.processStartedAt ? owner : void 0;
570
+ } catch {
571
+ try {
572
+ process.kill(owner.pid, 0);
573
+ } catch (error) {
574
+ if (error.code === "ESRCH") return void 0;
575
+ }
576
+ throw new SavedSessionError();
577
+ }
578
+ } catch {
579
+ throw new SavedSessionError();
580
+ }
581
+ }
582
+ processStartedAt(pid) {
583
+ const startedAt = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
584
+ encoding: "utf8",
585
+ timeout: 1e3,
586
+ stdio: ["ignore", "pipe", "ignore"],
587
+ env: { ...process.env, LC_ALL: "C", TZ: "UTC" }
588
+ }).trim();
589
+ if (!startedAt) throw new SavedSessionError();
590
+ return startedAt;
591
+ }
592
+ setOwner(owner) {
593
+ this.database.prepare("UPDATE session SET owner=? WHERE id=1").run(JSON.stringify({ ...owner, processStartedAt: this.processStartedAt(owner.pid) }));
594
+ }
595
+ load() {
596
+ try {
597
+ const row = this.database.prepare("SELECT state FROM session WHERE id=1").get();
598
+ if (!row?.state) return void 0;
599
+ const state = JSON.parse(String(row.state));
600
+ if (!Array.isArray(state.cookies) || !Array.isArray(state.origins)) throw new Error();
601
+ return state;
602
+ } catch {
603
+ throw new SavedSessionError();
604
+ }
605
+ }
606
+ async save(state) {
607
+ const hostname = new URL(this.target.origin).hostname;
608
+ const scoped = {
609
+ cookies: state.cookies.filter((cookie) => cookie.domain === hostname || cookie.domain === `.${hostname}`),
610
+ origins: state.origins.filter((origin) => origin.origin === this.target.origin)
611
+ };
612
+ await this.reserve(Date.now() + 2e3, () => {
613
+ this.database.prepare("UPDATE session SET state=? WHERE id=1").run(JSON.stringify(scoped));
614
+ });
615
+ }
616
+ close() {
617
+ this.database.close();
618
+ }
619
+ };
620
+
480
621
  // src/actions.ts
481
622
  var InvocationError = class extends Error {
482
623
  constructor(message, code = "invalid_invocation", action) {
@@ -493,7 +634,7 @@ function required(input, option) {
493
634
  return value;
494
635
  }
495
636
  var ACTIONS = {
496
- start: { control: false, flags: ["--headed"], values: ["--env", "--device"], build: () => ({ action: "start" }) },
637
+ start: { control: false, flags: ["--headed", "--fresh"], values: ["--env", "--device"], build: () => ({ action: "start" }) },
497
638
  inspect: { control: false, flags: [], values: [], build: () => ({ action: "inspect" }) },
498
639
  close: { control: false, flags: [], values: [], build: () => ({ action: "close" }) },
499
640
  stop: { control: false, flags: [], values: [], build: () => ({ action: "stop" }) },
@@ -511,6 +652,13 @@ var ACTIONS = {
511
652
  return { action: "zerodha-otp", attemptId, otp: await input.readOtp() };
512
653
  }
513
654
  },
655
+ read: { control: true, flags: [], values: ["--ref"], build: (input) => ({ action: "read", reference: required(input, "--ref") }) },
656
+ clipboard: { control: true, flags: [], values: [], build: () => ({ action: "clipboard" }) },
657
+ check: { control: true, flags: ["--stdin"], values: ["--ref"], build: async (input) => {
658
+ if (!input.flags.has("--stdin")) throw new InvocationError("check requires --stdin.");
659
+ return { action: "check", reference: required(input, "--ref"), expected: await input.readInput() };
660
+ } },
661
+ "clipboard-check": { control: true, flags: [], values: ["--ref"], build: (input) => ({ action: "clipboard-check", reference: required(input, "--ref") }) },
514
662
  click: { control: true, flags: [], values: ["--ref"], build: (input) => ({ action: "click", reference: required(input, "--ref") }) },
515
663
  fill: {
516
664
  control: true,
@@ -541,14 +689,14 @@ function isControlAction(action) {
541
689
 
542
690
  // src/config.ts
543
691
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
544
- import path3 from "node:path";
692
+ import path4 from "node:path";
545
693
  import { fileURLToPath as fileURLToPath2 } from "node:url";
546
- var repositoryRoot = path3.resolve(
547
- path3.dirname(fileURLToPath2(import.meta.url)),
694
+ var repositoryRoot = path4.resolve(
695
+ path4.dirname(fileURLToPath2(import.meta.url)),
548
696
  "../../../../"
549
697
  );
550
698
  var packagedConfig = fileURLToPath2(new URL("../lmt-web.config.json", import.meta.url));
551
- var DEFAULT_CONFIG_PATH2 = existsSync2(packagedConfig) ? packagedConfig : path3.join(repositoryRoot, "lmt.config.json");
699
+ var DEFAULT_CONFIG_PATH2 = existsSync2(packagedConfig) ? packagedConfig : path4.join(repositoryRoot, "lmt.config.json");
552
700
  function canonicalOrigin(value, environment) {
553
701
  if (typeof value !== "string" || !value.trim()) {
554
702
  throw new Error(`lmt-web.config.json does not contain the ${environment} Web URL.`);
@@ -598,6 +746,7 @@ function failureResult(action, code, message, sessionId = null) {
598
746
  sessionId,
599
747
  action,
600
748
  outcome: "failure",
749
+ authentication: { status: "unknown" },
601
750
  url: null,
602
751
  visibleState: null,
603
752
  device: null,
@@ -608,12 +757,58 @@ function failureResult(action, code, message, sessionId = null) {
608
757
 
609
758
  // src/session.ts
610
759
  import { randomBytes as randomBytes2 } from "node:crypto";
611
- import { chmod as chmod2, lstat as lstat2, mkdir as mkdir2, unlink as unlink2 } from "node:fs/promises";
760
+ import { chmod as chmod2, lstat as lstat3, mkdir as mkdir3, unlink as unlink2 } from "node:fs/promises";
612
761
  import net from "node:net";
613
- import os from "node:os";
614
- import path4 from "node:path";
762
+ import os2 from "node:os";
763
+ import path5 from "node:path";
615
764
  import { chromium as chromium2 } from "playwright";
616
765
 
766
+ // ../../packages/crawler/src/full-content.ts
767
+ async function captureFullContent(browserPage, options) {
768
+ const page = browserPage;
769
+ if (!page.evaluate) throw new Error("Full-content capture is unavailable.");
770
+ try {
771
+ await page.evaluate(() => {
772
+ const state = [];
773
+ window.__lmtCapture = state;
774
+ const scrolling = Array.from(document.querySelectorAll("body *")).filter((element) => {
775
+ const style = getComputedStyle(element);
776
+ return /(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight;
777
+ });
778
+ const expand = /* @__PURE__ */ new Set();
779
+ for (const element of scrolling) {
780
+ for (let parent = element; parent; parent = parent.parentElement) expand.add(parent);
781
+ }
782
+ for (const element of expand) {
783
+ state.push({ element, style: element.getAttribute("style"), x: element.scrollLeft, y: element.scrollTop });
784
+ element.style.setProperty("height", "auto", "important");
785
+ element.style.setProperty("max-height", "none", "important");
786
+ element.style.setProperty("overflow", "visible", "important");
787
+ element.style.setProperty("flex-shrink", "0", "important");
788
+ element.scrollTop = 0;
789
+ }
790
+ });
791
+ await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
792
+ const complete = await page.evaluate(() => Array.from(document.querySelectorAll("body *")).every((element) => {
793
+ const style = getComputedStyle(element);
794
+ return style.display === "none" || element.clientHeight === 0 || !/(auto|scroll|hidden|clip)/.test(style.overflowY) || element.scrollHeight <= element.clientHeight + 1;
795
+ }));
796
+ if (!complete) throw new Error("Full-content capture could not expand all clipped content.");
797
+ return await page.screenshot({ ...options, fullPage: true });
798
+ } finally {
799
+ await page.evaluate(() => {
800
+ const owner = window;
801
+ for (const { element, style, x, y } of (owner.__lmtCapture ?? []).reverse()) {
802
+ if (style === null) element.removeAttribute("style");
803
+ else element.setAttribute("style", style);
804
+ element.scrollLeft = x;
805
+ element.scrollTop = y;
806
+ }
807
+ delete owner.__lmtCapture;
808
+ }).catch(() => void 0);
809
+ }
810
+ }
811
+
617
812
  // ../../packages/crawler/src/controls.ts
618
813
  import { randomBytes } from "node:crypto";
619
814
  var ControlError = class extends Error {
@@ -623,6 +818,7 @@ var ControlError = class extends Error {
623
818
  }
624
819
  code;
625
820
  };
821
+ var SENSITIVE_FIELD_PATTERN = "password|credential|secret|token|otp|one.time|username|user.?id|api.?key|pin";
626
822
  function controlSignature(node) {
627
823
  if (!node.isConnected) return null;
628
824
  return JSON.stringify([
@@ -657,8 +853,8 @@ var PageControls = class {
657
853
  return result;
658
854
  }
659
855
  async learnSecrets() {
660
- const values = await this.page.evaluate(() => {
661
- const sensitive = /password|credential|secret|token|otp|one.time|username|user.?id|api.?key|pin/i;
856
+ const values = await this.page.evaluate((pattern) => {
857
+ const sensitive = new RegExp(pattern, "i");
662
858
  const found = Array.from(document.querySelectorAll("input, textarea")).filter((element) => sensitive.test([
663
859
  element.outerHTML.replace(/value=("[^"]*"|'[^']*')/g, ""),
664
860
  ...Array.from(element.labels ?? []).map((label) => label.innerText)
@@ -667,15 +863,16 @@ var PageControls = class {
667
863
  if (match[1]) found.push(match[1]);
668
864
  }
669
865
  return found;
670
- });
866
+ }, SENSITIVE_FIELD_PATTERN);
671
867
  this.registerSecrets(...values);
672
868
  }
673
869
  async screenshotMasks() {
870
+ const stable = this.page.locator('[data-lmt-sensitive="zerodha-access-token"]');
674
871
  try {
675
872
  await this.learnSecrets();
676
873
  } catch (error) {
677
874
  if (this.secrets.size) throw error;
678
- else return [];
875
+ else return [stable];
679
876
  }
680
877
  await this.page.evaluate((secrets) => {
681
878
  document.querySelectorAll("[data-lmt-secret-mask]").forEach((element) => element.removeAttribute("data-lmt-secret-mask"));
@@ -685,7 +882,7 @@ var PageControls = class {
685
882
  if (secrets.some((secret) => directText.includes(secret) || (element.textContent ?? "").includes(secret) && !Array.from(element.children).some((child) => (child.textContent ?? "").includes(secret)))) element.setAttribute("data-lmt-secret-mask", "");
686
883
  }
687
884
  }, [...this.secrets]);
688
- return [this.page.locator("[data-lmt-secret-mask]")];
885
+ return [stable, this.page.locator("[data-lmt-secret-mask]")];
689
886
  }
690
887
  async inspect() {
691
888
  await this.learnSecrets();
@@ -735,9 +932,52 @@ var PageControls = class {
735
932
  async act(command, timeoutMs, onSubmit = () => void 0) {
736
933
  this.page.setDefaultTimeout(timeoutMs);
737
934
  this.page.setDefaultNavigationTimeout(timeoutMs);
935
+ if (command.action === "read" || command.action === "check" || command.action === "clipboard-check" || command.action === "clipboard") {
936
+ await this.learnSecrets();
937
+ if (command.action === "check") this.registerSecrets(command.expected);
938
+ try {
939
+ const field = command.action === "clipboard" ? void 0 : await this.resolve(command.reference);
940
+ if (command.action === "clipboard-check" || command.action === "clipboard") await this.page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: this.origin });
941
+ if (command.action === "clipboard-check") {
942
+ const matches = await field.evaluate(async (node) => {
943
+ if (!(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement)) throw new Error("Unreadable field");
944
+ return await navigator.clipboard.readText() === node.value;
945
+ });
946
+ return { matches };
947
+ }
948
+ const value = command.action === "clipboard" ? await this.page.evaluate(() => navigator.clipboard.readText()) : await field.evaluate((node) => {
949
+ if (!(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement)) throw new Error("Unreadable field");
950
+ return node.value;
951
+ });
952
+ if (command.action === "check") {
953
+ this.registerSecrets(value);
954
+ return { matches: value === command.expected };
955
+ }
956
+ const sensitive = field && await field.evaluate((node, pattern) => new RegExp(pattern, "i").test(node.outerHTML.replace(/value=("[^"]*"|'[^']*')/g, "") + Array.from(node.labels ?? []).map((label) => label.innerText).join(" ")), SENSITIVE_FIELD_PATTERN);
957
+ if (command.action === "clipboard") {
958
+ const ordinary = await this.page.evaluate(({ value: value2, pattern }) => {
959
+ const sensitive2 = new RegExp(pattern, "i");
960
+ return Array.from(document.querySelectorAll("input,textarea,select")).some((node) => {
961
+ if (!(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement)) return false;
962
+ const description = node.outerHTML.replace(/value=("[^"]*"|'[^']*')/g, "") + Array.from(node.labels ?? []).map((label) => label.innerText).join(" ");
963
+ return !sensitive2.test(description) && node.value === value2;
964
+ });
965
+ }, { value, pattern: SENSITIVE_FIELD_PATTERN });
966
+ if (!ordinary) {
967
+ this.registerSecrets(value);
968
+ throw new ControlError("secret_read_refused", "Clipboard content has no verified ordinary field source. Use clipboard-check for secret Copy.");
969
+ }
970
+ }
971
+ if (sensitive || [...this.secrets].some((secret) => value.includes(secret))) throw new ControlError("secret_read_refused", "Use an equality check for secret values.");
972
+ return { value };
973
+ } catch (error) {
974
+ if (error instanceof ControlError) throw error;
975
+ throw new ControlError("value_unreadable", "The field or clipboard could not be read. No comparison result is available.");
976
+ }
977
+ }
738
978
  if (command.action === "fill") {
739
979
  const element = await this.resolve(command.reference);
740
- const sensitive = await element.evaluate((node) => /password|credential|secret|token|otp|one.time|username|user.?id|api.?key|pin/i.test(node.outerHTML + Array.from(node.labels ?? []).map((label) => label.innerText).join(" ")));
980
+ const sensitive = await element.evaluate((node, pattern) => new RegExp(pattern, "i").test(node.outerHTML.replace(/value=("[^"]*"|'[^']*')/g, "") + Array.from(node.labels ?? []).map((label) => label.innerText).join(" ")), SENSITIVE_FIELD_PATTERN);
741
981
  if (command.secret || sensitive) this.registerSecrets(command.value);
742
982
  await element.fill(command.value, { timeout: timeoutMs });
743
983
  return;
@@ -747,6 +987,7 @@ var PageControls = class {
747
987
  return;
748
988
  }
749
989
  if (command.action === "click") {
990
+ await this.page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: this.origin });
750
991
  const element = await this.resolve(command.reference);
751
992
  const deadlineAt = Date.now() + timeoutMs;
752
993
  await element.click({ trial: true, timeout: timeoutMs });
@@ -768,12 +1009,33 @@ var PageControls = class {
768
1009
  }
769
1010
  if (command.action === "scroll") {
770
1011
  if (!Number.isFinite(command.x) || !Number.isFinite(command.y)) throw new ControlError("invalid_invocation", "Scroll offsets must be finite pixel values.");
771
- if (command.reference) {
772
- await (await this.resolve(command.reference)).evaluate((node, offsets) => node.scrollBy({ left: offsets.x, top: offsets.y, behavior: "instant" }), command);
773
- } else {
774
- await this.page.evaluate((offsets) => window.scrollBy({ left: offsets.x, top: offsets.y, behavior: "instant" }), command);
775
- }
1012
+ const element = command.reference ? await this.resolve(command.reference) : void 0;
1013
+ const scroll = await this.page.evaluate(({ element: node, offsets }) => {
1014
+ const scrollable = (item) => {
1015
+ const style = getComputedStyle(item);
1016
+ return /(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX) && (item.scrollHeight > item.clientHeight || item.scrollWidth > item.clientWidth);
1017
+ };
1018
+ const containers = [];
1019
+ if (node) {
1020
+ for (let parent = node.parentElement; parent; parent = parent.parentElement) if (scrollable(parent)) containers.push(parent);
1021
+ if (document.scrollingElement && !containers.includes(document.scrollingElement)) containers.push(document.scrollingElement);
1022
+ } else {
1023
+ const candidates = Array.from(document.querySelectorAll("body *")).filter(scrollable);
1024
+ const root = document.scrollingElement;
1025
+ if (root && !/(hidden|clip)/.test(getComputedStyle(root).overflowY) && (root.scrollHeight > root.clientHeight || root.scrollWidth > root.clientWidth)) candidates.push(root);
1026
+ candidates.sort((a, b) => b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight);
1027
+ containers.push(candidates[0] ?? document.scrollingElement);
1028
+ }
1029
+ const positions = () => containers.map((item) => ({ x: item.scrollLeft, y: item.scrollTop }));
1030
+ const before = positions();
1031
+ if (node) node.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" });
1032
+ else containers[0].scrollBy({ left: offsets.x, top: offsets.y, behavior: "instant" });
1033
+ const after = positions();
1034
+ const moved = before.some((position, i) => position.x !== after[i].x || position.y !== after[i].y);
1035
+ return { outcome: moved ? "moved" : node ? "already_visible" : "at_boundary", before, after };
1036
+ }, { element: element ?? null, offsets: command });
776
1037
  await this.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
1038
+ return { scroll };
777
1039
  }
778
1040
  }
779
1041
  };
@@ -1709,7 +1971,7 @@ async function operatorLogout(page, timeoutMs, onSubmit = () => void 0) {
1709
1971
  }
1710
1972
 
1711
1973
  // src/session.ts
1712
- var SOCKET_DIRECTORY = path4.join(os.tmpdir(), `lmt-web-${process.getuid?.() ?? "user"}`);
1974
+ var SOCKET_DIRECTORY = path5.join(os2.tmpdir(), `lmt-web-${process.getuid?.() ?? "user"}`);
1713
1975
  var SESSION_ID_PATTERN = /^[a-f0-9]{32}$/;
1714
1976
  var OWNER_RESPONSE_GRACE_MS = 1e3;
1715
1977
  var OWNER_CLEANUP_GRACE_MS = 2e3;
@@ -1724,19 +1986,19 @@ async function ensureSessionDirectory() {
1724
1986
  if (process.platform === "win32") {
1725
1987
  throw new Error("lmt-web session sockets are supported on macOS and Linux.");
1726
1988
  }
1727
- await mkdir2(SOCKET_DIRECTORY, { recursive: true, mode: 448 });
1989
+ await mkdir3(SOCKET_DIRECTORY, { recursive: true, mode: 448 });
1728
1990
  await chmod2(SOCKET_DIRECTORY, 448);
1729
- const directory = await lstat2(SOCKET_DIRECTORY);
1991
+ const directory = await lstat3(SOCKET_DIRECTORY);
1730
1992
  const uid = process.getuid?.();
1731
1993
  if (!directory.isDirectory() || uid !== void 0 && directory.uid !== uid) {
1732
1994
  throw new Error("The lmt-web session directory is not owned by the invoking user.");
1733
1995
  }
1734
1996
  }
1735
1997
  function socketPathFor(sessionId) {
1736
- return path4.join(SOCKET_DIRECTORY, `${sessionId}.sock`);
1998
+ return path5.join(SOCKET_DIRECTORY, `${sessionId}.sock`);
1737
1999
  }
1738
2000
  function artifactsDirectory(sessionId) {
1739
- return path4.join(SOCKET_DIRECTORY, "artifacts", sessionId);
2001
+ return path5.join(SOCKET_DIRECTORY, "artifacts", sessionId);
1740
2002
  }
1741
2003
  function redactSecrets(value) {
1742
2004
  return value.replace(
@@ -1754,6 +2016,7 @@ function safeUrl(value) {
1754
2016
  }
1755
2017
  }
1756
2018
  function failureCode(error) {
2019
+ if (error instanceof SavedSessionError) return "saved_session_unavailable";
1757
2020
  if (error instanceof ControlError) return error.code;
1758
2021
  const message = error instanceof Error ? error.message : "";
1759
2022
  if (/timed out|timeout|deadline/i.test(message)) return "deadline_exceeded";
@@ -1873,6 +2136,26 @@ async function inspectPage(page, mode, crawler) {
1873
2136
  const style = getComputedStyle(element);
1874
2137
  return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
1875
2138
  };
2139
+ const hasVisible = (selector) => Array.from(document.querySelectorAll(selector)).some(visible);
2140
+ const operatorLogin2 = hasVisible('[data-operator-login-gate="true"]');
2141
+ const zerodhaLogin = hasVisible('[data-zerodha-login-page="true"]');
2142
+ const operatorPage = hasVisible('[data-operator-page="true"]');
2143
+ const restoring = hasVisible('[data-zerodha-restoration="pending"]');
2144
+ let authentication = { status: "unknown" };
2145
+ if (Number(operatorLogin2) + Number(zerodhaLogin) + Number(operatorPage) + Number(restoring) === 1 && !hasVisible('[data-session-loading], [aria-busy="true"]')) {
2146
+ if (operatorLogin2) authentication = { status: "operator_login_required", nextAction: "operator-login" };
2147
+ if (operatorPage) authentication = { status: "authenticated" };
2148
+ if (restoring) authentication = { status: "zerodha_session_restoring" };
2149
+ if (zerodhaLogin) {
2150
+ authentication = { status: "zerodha_login_required" };
2151
+ const attempt = document.querySelector('[data-zerodha-login-page="true"] [data-operator-login-attempt]');
2152
+ if (!attempt) authentication = { status: "zerodha_login_required", nextAction: "zerodha-login" };
2153
+ else if (visible(attempt) && attempt.getAttribute("data-operator-login-status") === "awaiting_otp" && attempt.getAttribute("data-operator-login-submitting-otp") !== "true") {
2154
+ const attemptId = attempt.getAttribute("data-operator-login-attempt-id");
2155
+ if (attemptId) authentication = { status: "zerodha_login_required", nextAction: "zerodha-otp", attemptId };
2156
+ }
2157
+ }
2158
+ }
1876
2159
  const controls2 = Array.from(
1877
2160
  document.querySelectorAll("button, a[href], input, select, textarea, [role=button]")
1878
2161
  ).filter(visible).slice(0, 100).map((element, index) => {
@@ -1886,6 +2169,7 @@ async function inspectPage(page, mode, crawler) {
1886
2169
  };
1887
2170
  });
1888
2171
  return {
2172
+ authentication,
1889
2173
  title: document.title,
1890
2174
  text: document.body?.innerText ?? "",
1891
2175
  controls: controls2,
@@ -1899,6 +2183,7 @@ async function inspectPage(page, mode, crawler) {
1899
2183
  const redact = (value) => redactSecrets(crawler?.controls().redact(value) ?? value);
1900
2184
  const viewport = page.viewportSize?.() ?? result.viewport;
1901
2185
  return {
2186
+ authentication: "attemptId" in result.authentication ? { ...result.authentication, attemptId: redact(result.authentication.attemptId) } : result.authentication,
1902
2187
  url: safeUrl(redact(page.url())),
1903
2188
  visibleState: {
1904
2189
  title: redact(result.title).slice(0, 300),
@@ -1915,27 +2200,37 @@ async function inspectPage(page, mode, crawler) {
1915
2200
  }
1916
2201
  };
1917
2202
  }
2203
+ var ACCESS_TOKEN_SCREENSHOT_MASK = '[data-lmt-sensitive="zerodha-access-token"]';
2204
+ async function screenshotMaskOptions(page) {
2205
+ const playwrightPage = page;
2206
+ const locator = playwrightPage.locator(ACCESS_TOKEN_SCREENSHOT_MASK);
2207
+ if (!await locator.count()) return {};
2208
+ return { mask: [locator], maskColor: "#000000" };
2209
+ }
1918
2210
  async function captureScreenshot(page, sessionId, action, fullPage, timeoutMs, onTimeout) {
1919
2211
  if (!page) return { outcome: "unavailable", reason: "No browser page was available." };
1920
2212
  if (!page.screenshot) return { outcome: "failed", error: "The browser does not support screenshots." };
1921
2213
  const directory = artifactsDirectory(sessionId);
1922
2214
  const filename = `${Date.now()}-${action}-${randomBytes2(4).toString("hex")}.png`;
1923
- const screenshotPath = path4.resolve(directory, filename);
2215
+ const screenshotPath = path5.resolve(directory, filename);
1924
2216
  if (timeoutMs < 1) {
1925
2217
  return { outcome: "failed", error: "The action deadline elapsed before screenshot capture." };
1926
2218
  }
1927
2219
  try {
1928
- await mkdir2(directory, { recursive: true, mode: 448 });
2220
+ await mkdir3(directory, { recursive: true, mode: 448 });
1929
2221
  await chmod2(directory, 448);
1930
2222
  await withDeadline(
1931
2223
  (async () => {
1932
2224
  await waitForRenderedPage(page);
1933
- return page.screenshot({
2225
+ const maskOptions = await screenshotMaskOptions(page);
2226
+ const options = {
1934
2227
  path: screenshotPath,
1935
2228
  fullPage,
1936
2229
  animations: "disabled",
1937
- timeout: timeoutMs
1938
- });
2230
+ timeout: timeoutMs,
2231
+ ...maskOptions
2232
+ };
2233
+ return fullPage ? captureFullContent(page, options) : page.screenshot(options);
1939
2234
  })(),
1940
2235
  timeoutMs,
1941
2236
  onTimeout
@@ -1973,6 +2268,23 @@ async function runSessionOwner(options) {
1973
2268
  await ensureSessionDirectory();
1974
2269
  const socketPath = socketPathFor(options.sessionId);
1975
2270
  const device = deviceContext(options.mode);
2271
+ const saved = options.persist ? await SavedSession.open({ environment: options.environment ?? "local", origin: options.origin, mode: options.mode }) : void 0;
2272
+ const storageState = saved?.load();
2273
+ let checkpointQueue = Promise.resolve();
2274
+ let checkpointError;
2275
+ function checkpoint() {
2276
+ if (!saved || !page) return Promise.resolve();
2277
+ const context = page.context();
2278
+ const save = async () => {
2279
+ const cookies = await context.cookies(options.origin);
2280
+ await saved.save({ cookies, origins: [] });
2281
+ };
2282
+ checkpointQueue = checkpointQueue.then(save, save);
2283
+ checkpointQueue.catch(() => {
2284
+ checkpointError = new SavedSessionError();
2285
+ });
2286
+ return checkpointQueue;
2287
+ }
1976
2288
  let crawler;
1977
2289
  let page;
1978
2290
  let startupError;
@@ -1982,6 +2294,7 @@ async function runSessionOwner(options) {
1982
2294
  let operationQueue = Promise.resolve();
1983
2295
  let shutdownPromise;
1984
2296
  let initializationPromise;
2297
+ let startupLease;
1985
2298
  let crawlerClosePromise;
1986
2299
  let expirationCleanup;
1987
2300
  let forceClosePendingBrowser;
@@ -2034,7 +2347,12 @@ async function runSessionOwner(options) {
2034
2347
  return;
2035
2348
  }
2036
2349
  const run = async () => {
2037
- const response = await handleRequest(request);
2350
+ let response;
2351
+ try {
2352
+ response = await handleRequest(request);
2353
+ } catch (error) {
2354
+ response = failureResult(request.action, error instanceof SavedSessionError ? "saved_session_unavailable" : "action_failed", "The browser action could not complete safely. Inspect before retrying.", options.sessionId);
2355
+ }
2038
2356
  socket.end(`${JSON.stringify(response)}
2039
2357
  `);
2040
2358
  if (closing) socket.once("close", () => void shutdown());
@@ -2048,7 +2366,7 @@ async function runSessionOwner(options) {
2048
2366
  baseUrl: options.origin,
2049
2367
  headless: options.headless,
2050
2368
  timeoutMs: Math.min(options.timeoutMs, Math.max(1, remainingMs(deadlineAt))),
2051
- contextOptions: device.contextOptions,
2369
+ contextOptions: { ...device.contextOptions, ...storageState ? { storageState } : {} },
2052
2370
  launch: () => {
2053
2371
  const onServerReady = (forceClose) => {
2054
2372
  forceClosePendingBrowser = forceClose;
@@ -2063,11 +2381,23 @@ async function runSessionOwner(options) {
2063
2381
  }
2064
2382
  });
2065
2383
  page = crawler.getPage();
2384
+ if (saved) {
2385
+ page.context().on("requestfinished", (request) => {
2386
+ if (new URL(request.url()).origin !== options.origin) return;
2387
+ void request.response().then(async (response) => {
2388
+ const headers = await response?.allHeaders();
2389
+ if (headers?.["set-cookie"] && !closing) await checkpoint();
2390
+ }).catch(() => {
2391
+ checkpointError = new SavedSessionError();
2392
+ });
2393
+ });
2394
+ }
2066
2395
  if (closing) {
2067
2396
  await closeCrawler();
2068
2397
  return;
2069
2398
  }
2070
2399
  await crawler.openHomePage();
2400
+ await withDeadline(checkpoint(), remainingMs(deadlineAt), expireSession);
2071
2401
  } catch (error) {
2072
2402
  startupError = error;
2073
2403
  }
@@ -2076,17 +2406,16 @@ async function runSessionOwner(options) {
2076
2406
  if (!request || request.sessionId !== options.sessionId || !isAction(request.action) || !Number.isFinite(request.timeoutMs) || request.timeoutMs < 1 || !Number.isFinite(request.deadlineAt)) {
2077
2407
  return failureResult("inspect", "invalid_request", "The session request was invalid.", options.sessionId);
2078
2408
  }
2409
+ if (closing) return failureResult(request.action, "session_unavailable", "The browser owner is closing. Start to restore the saved session.", options.sessionId);
2079
2410
  const deadlineAt = request.deadlineAt;
2080
2411
  if (remainingMs(deadlineAt) === 0) {
2081
2412
  return failureResult(request.action, "deadline_exceeded", "The browser action exceeded its deadline.", options.sessionId);
2082
2413
  }
2083
2414
  if (request.action === "start" && startComplete) {
2084
- return failureResult("start", "session_already_started", "This browser session has already started.", options.sessionId);
2085
- }
2086
- if (!startComplete && request.action !== "start") {
2087
- return failureResult(request.action, "session_starting", "The browser session is not ready yet.", options.sessionId);
2415
+ return { ...await handleRequest({ ...request, action: "inspect" }), action: "start" };
2088
2416
  }
2089
2417
  if (request.action === "stop") {
2418
+ await withDeadline(checkpoint(), Math.min(500, Math.max(1, remainingMs(deadlineAt)))).catch(() => void 0);
2090
2419
  closing = true;
2091
2420
  abortOnShutdown = true;
2092
2421
  try {
@@ -2097,6 +2426,7 @@ async function runSessionOwner(options) {
2097
2426
  sessionId: options.sessionId,
2098
2427
  action: "stop",
2099
2428
  outcome: "success",
2429
+ authentication: { status: "unknown" },
2100
2430
  url: null,
2101
2431
  visibleState: null,
2102
2432
  device: null,
@@ -2106,7 +2436,12 @@ async function runSessionOwner(options) {
2106
2436
  return failureResult("stop", failureCode(error2), messageFor(error2), options.sessionId);
2107
2437
  }
2108
2438
  }
2439
+ if (!startComplete && request.action !== "start") {
2440
+ return failureResult(request.action, "session_starting", "The browser session is not ready yet.", options.sessionId);
2441
+ }
2442
+ if (checkpointError) throw checkpointError;
2109
2443
  if (!startComplete) {
2444
+ clearTimeout(startupLease);
2110
2445
  initializationPromise ??= initialize(deadlineAt);
2111
2446
  try {
2112
2447
  await withDeadline(initializationPromise, remainingMs(deadlineAt), expireSession);
@@ -2135,6 +2470,7 @@ async function runSessionOwner(options) {
2135
2470
  sessionId: options.sessionId,
2136
2471
  action: "start",
2137
2472
  outcome: actionError2 ? "failure" : "success",
2473
+ authentication: inspected2?.authentication ?? { status: "unknown" },
2138
2474
  url: inspected2?.url ?? (startupError ? safeUrl(options.origin) : page ? safeUrl(crawler?.controls().redact(page.url()) ?? page.url()) : null),
2139
2475
  visibleState: inspected2?.visibleState ?? null,
2140
2476
  device: inspected2?.device ?? null,
@@ -2161,12 +2497,14 @@ async function runSessionOwner(options) {
2161
2497
  }
2162
2498
  let guidedError;
2163
2499
  let submitted = false;
2164
- const actionDeadlineAt = deadlineAt - Math.min(1500, Math.floor(remainingMs(deadlineAt) / 4));
2165
- let actionTimedOut = false;
2166
- const actionTimeout = () => Math.max(1, remainingMs(actionDeadlineAt));
2167
- const actionExpired = () => {
2168
- actionTimedOut = true;
2500
+ const actionDeadlineAt = deadlineAt - Math.min(1500, Math.floor(remainingMs(deadlineAt) / 2));
2501
+ const pendingActions = /* @__PURE__ */ new Set();
2502
+ const runAction = (operation, timeoutMs) => {
2503
+ pendingActions.add(operation);
2504
+ operation.then(() => pendingActions.delete(operation), () => pendingActions.delete(operation));
2505
+ return withDeadline(operation, timeoutMs);
2169
2506
  };
2507
+ const actionTimeout = () => Math.max(1, remainingMs(actionDeadlineAt));
2170
2508
  page.setDefaultTimeout?.(actionTimeout());
2171
2509
  page.setDefaultNavigationTimeout?.(actionTimeout());
2172
2510
  let loginAttempt;
@@ -2175,27 +2513,28 @@ async function runSessionOwner(options) {
2175
2513
  };
2176
2514
  const zerodha = new PlaywrightZerodhaLoginPage(page, actionTimeout());
2177
2515
  try {
2178
- if (request.action === "operator-login") await withDeadline(operatorLogin({ page, environment: options.environment ?? "local", origin: options.origin, timeoutMs: actionTimeout(), onSubmit, registerSecrets: (...values) => crawler.controls().registerSecrets(...values) }), actionTimeout(), actionExpired);
2179
- if (request.action === "operator-logout") await withDeadline(operatorLogout(page, actionTimeout(), onSubmit), actionTimeout(), actionExpired);
2180
- if (request.action === "zerodha-login") loginAttempt = await withDeadline(zerodha.startLoginAttempt(onSubmit), actionTimeout(), actionExpired);
2516
+ if (request.action === "operator-login") await runAction(operatorLogin({ page, environment: options.environment ?? "local", origin: options.origin, timeoutMs: actionTimeout(), onSubmit, registerSecrets: (...values) => crawler.controls().registerSecrets(...values) }), actionTimeout());
2517
+ if (request.action === "operator-logout") await runAction(operatorLogout(page, actionTimeout(), onSubmit), actionTimeout());
2518
+ if (request.action === "zerodha-login") loginAttempt = await runAction(zerodha.startLoginAttempt(onSubmit), actionTimeout());
2181
2519
  if (request.action === "zerodha-otp") {
2182
2520
  if (!request.attemptId || !request.otp || !/^\d{6}$/.test(request.otp)) throw new OperatorActionError("invalid_otp", "Supply a six-digit OTP and the attempt ID returned by zerodha-login.");
2183
2521
  crawler.controls().registerSecrets(request.otp);
2184
- const result = await withDeadline(zerodha.submitActiveOtp(request.attemptId, request.otp, onSubmit), actionTimeout(), actionExpired);
2522
+ const result = await runAction(zerodha.submitActiveOtp(request.attemptId, request.otp, onSubmit), actionTimeout());
2185
2523
  if (result.status !== "SUCCESS") throw new OperatorActionError("otp_rejected", result.message);
2186
2524
  }
2187
- if (request.action === "zerodha-logout") await withDeadline(new PlaywrightOperatorPage(page, actionTimeout()).clickLogoutZerodha(onSubmit), actionTimeout(), actionExpired);
2525
+ if (request.action === "zerodha-logout") await runAction(new PlaywrightOperatorPage(page, actionTimeout()).clickLogoutZerodha(onSubmit), actionTimeout());
2188
2526
  } catch (error2) {
2189
2527
  guidedError = error2;
2190
2528
  }
2191
2529
  let inspected = null;
2530
+ let controlResult;
2192
2531
  let actionError;
2193
2532
  try {
2194
2533
  if (isControlAction(request)) {
2195
2534
  const budget = Math.max(1, remainingMs(deadlineAt) - Math.min(2e3, request.timeoutMs / 4));
2196
- await withDeadline(crawler.controls().act(request, budget, () => {
2535
+ controlResult = await runAction(crawler.controls().act(request, budget, () => {
2197
2536
  submitted = true;
2198
- }), budget, () => void 0);
2537
+ }), budget);
2199
2538
  }
2200
2539
  } catch (error2) {
2201
2540
  actionError = request.action === "click" && submitted && failureCode(error2) === "deadline_exceeded" ? new ControlError("action_uncertain", "The click may have been submitted but its completion was not confirmed. Inspect before deciding whether to retry.") : error2;
@@ -2203,6 +2542,25 @@ async function runSessionOwner(options) {
2203
2542
  await withDeadline(crawler.controls().stopLoading(), Math.min(500, remainingMs(deadlineAt))).catch(() => void 0);
2204
2543
  }
2205
2544
  }
2545
+ if (pendingActions.size || failureCode(actionError ?? guidedError) === "deadline_exceeded") {
2546
+ try {
2547
+ await withDeadline((async () => {
2548
+ await crawler.controls().stopLoading();
2549
+ await Promise.allSettled([...pendingActions]);
2550
+ await page.evaluate?.(() => document.readyState);
2551
+ })(), Math.min(500, Math.max(1, remainingMs(deadlineAt))), expireSession);
2552
+ } catch {
2553
+ expireSession();
2554
+ return { ...failureResult(request.action, submitted ? "action_uncertain" : "deadline_exceeded", "The browser could not safely recover from the action deadline. Start to restore the saved session; do not replay an uncertain action.", options.sessionId), outcome: submitted ? "uncertain" : "failure" };
2555
+ }
2556
+ }
2557
+ if (saved) {
2558
+ try {
2559
+ await withDeadline(checkpoint(), Math.max(1, remainingMs(deadlineAt)), expireSession);
2560
+ } catch (error2) {
2561
+ actionError ??= error2;
2562
+ }
2563
+ }
2206
2564
  try {
2207
2565
  inspected = await withDeadline(inspectPage(page, options.mode, crawler), remainingMs(deadlineAt), expireSession);
2208
2566
  } catch (error2) {
@@ -2228,13 +2586,14 @@ async function runSessionOwner(options) {
2228
2586
  }
2229
2587
  }
2230
2588
  }
2231
- if (actionTimedOut) closing = true;
2232
2589
  const error = guidedError ?? actionError ?? closeError;
2233
2590
  return {
2234
2591
  version: RESULT_VERSION,
2235
2592
  sessionId: options.sessionId,
2236
2593
  action: request.action,
2237
2594
  outcome: error instanceof ControlError && error.code === "action_uncertain" ? "uncertain" : error ? submitted && !(error instanceof OperatorActionError) && !(error instanceof LoginAttemptError) ? "uncertain" : "failure" : "success",
2595
+ authentication: inspected?.authentication ?? { status: "unknown" },
2596
+ ...!actionError ? controlResult : {},
2238
2597
  ...loginAttempt ? { loginAttempt: { attemptId: loginAttempt.attemptId, status: loginAttempt.status } } : {},
2239
2598
  url: inspected?.url ?? (page ? safeUrl(crawler?.controls().redact(page.url()) ?? page.url()) : null),
2240
2599
  visibleState: inspected?.visibleState ?? null,
@@ -2246,6 +2605,7 @@ async function runSessionOwner(options) {
2246
2605
  function shutdown() {
2247
2606
  if (!closing) return Promise.resolve();
2248
2607
  if (shutdownPromise) return shutdownPromise;
2608
+ clearTimeout(startupLease);
2249
2609
  shutdownPromise = (async () => {
2250
2610
  for (const socket of sockets) socket.destroy();
2251
2611
  await unlink2(socketPath).catch(() => void 0);
@@ -2272,6 +2632,7 @@ async function runSessionOwner(options) {
2272
2632
  }
2273
2633
  if (crawler) {
2274
2634
  try {
2635
+ await withDeadline(checkpoint(), OWNER_CLEANUP_GRACE_MS);
2275
2636
  await closeCrawler();
2276
2637
  } catch {
2277
2638
  await crawler.forceClose?.().catch(() => void 0);
@@ -2279,6 +2640,8 @@ async function runSessionOwner(options) {
2279
2640
  } else if (forceClosePendingBrowser) {
2280
2641
  await forceCloseBrowser().catch(() => void 0);
2281
2642
  }
2643
+ await withDeadline(checkpointQueue, OWNER_CLEANUP_GRACE_MS).catch(() => void 0);
2644
+ saved?.close();
2282
2645
  process.removeListener("SIGTERM", onSignal);
2283
2646
  process.removeListener("SIGINT", onSignal);
2284
2647
  })();
@@ -2291,6 +2654,11 @@ async function runSessionOwner(options) {
2291
2654
  void chmod2(socketPath, 384).then(resolve, reject);
2292
2655
  });
2293
2656
  });
2657
+ startupLease = setTimeout(() => {
2658
+ if (initializationPromise) return;
2659
+ expireSession();
2660
+ void shutdown();
2661
+ }, options.timeoutMs);
2294
2662
  const onSignal = () => {
2295
2663
  closing = true;
2296
2664
  void shutdown();
@@ -2302,7 +2670,7 @@ async function wait(milliseconds) {
2302
2670
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
2303
2671
  }
2304
2672
  function assertOwnedSocket(socketPath) {
2305
- return lstat2(socketPath).then((entry) => {
2673
+ return lstat3(socketPath).then((entry) => {
2306
2674
  const uid = process.getuid?.();
2307
2675
  if (!entry.isSocket() || uid !== void 0 && entry.uid !== uid) {
2308
2676
  throw new Error("The lmt-web session socket is not owned by the invoking user.");
@@ -2383,10 +2751,14 @@ var HELP = `lmt-web \u2014 browser interaction with Live Market Terminal Web
2383
2751
 
2384
2752
  Usage:
2385
2753
  lmt-web install-browser [--with-deps] [--dry-run]
2386
- lmt-web start --env local|dev [--device mobile|desktop] [--headed] [--timeout 30s]
2754
+ lmt-web start --env local|dev [--device mobile|desktop] [--headed] [--fresh] [--timeout 30s]
2387
2755
  lmt-web inspect --session ID [--full-page] [--timeout 30s]
2388
2756
  lmt-web click --session ID --ref REF
2389
2757
  lmt-web fill --session ID --ref REF --value TEXT [--secret]
2758
+ lmt-web read --session ID --ref REF
2759
+ lmt-web clipboard --session ID
2760
+ lmt-web check --session ID --ref REF --stdin
2761
+ lmt-web clipboard-check --session ID --ref REF
2390
2762
  lmt-web select --session ID --ref REF --value OPTION
2391
2763
  lmt-web scroll --session ID [--ref REF] [--x PIXELS] [--y PIXELS]
2392
2764
  lmt-web navigate --session ID --url PATH_OR_URL
@@ -2399,7 +2771,8 @@ Usage:
2399
2771
  lmt-web close --session ID [--full-page] [--timeout 30s]
2400
2772
  lmt-web stop --session ID [--timeout 30s]
2401
2773
 
2402
- Start opens the configured Home Page in a fresh browser session. Mobile emulation
2774
+ Start restores the saved session or reuses its live browser. --fresh is temporary.
2775
+ Mobile emulation
2403
2776
  and headless mode are the defaults. Inspect and close require the returned session ID.
2404
2777
  Stop terminates the session's browser and owner without capturing a final screenshot.
2405
2778
  Each command writes one versioned JSON result to stdout; diagnostics go to stderr.
@@ -2465,6 +2838,7 @@ async function parseCommand(argv) {
2465
2838
  environment: values.get("--env") ?? "",
2466
2839
  mode,
2467
2840
  headless: !flags.has("--headed"),
2841
+ fresh: flags.has("--fresh"),
2468
2842
  timeoutMs
2469
2843
  };
2470
2844
  }
@@ -2474,7 +2848,7 @@ async function parseCommand(argv) {
2474
2848
  }
2475
2849
  let payload;
2476
2850
  try {
2477
- payload = await definition.build({ values, flags, readOtp: () => readOtp(timeoutMs) });
2851
+ payload = await definition.build({ values, flags, readOtp: () => readOtp(timeoutMs), readInput: () => readInput(timeoutMs) });
2478
2852
  } catch (error) {
2479
2853
  throw new InvocationError(error instanceof Error ? error.message : "The action input could not be read.", error instanceof InvocationError ? error.code : "action_failed", action);
2480
2854
  }
@@ -2487,6 +2861,23 @@ function emit(result, diagnostic) {
2487
2861
  if (diagnostic) process.stderr.write(`${diagnostic}
2488
2862
  `);
2489
2863
  }
2864
+ async function readInput(timeoutMs) {
2865
+ if (process.stdin.isTTY) throw new InvocationError("Pipe the expected value on stdin.");
2866
+ let input = "";
2867
+ const timer = setTimeout(() => process.stdin.destroy(new Error("Input deadline exceeded.")), timeoutMs);
2868
+ try {
2869
+ for await (const chunk of process.stdin) {
2870
+ input += chunk.toString();
2871
+ if (Buffer.byteLength(input) > 16384) throw new InvocationError("Expected value is too large.");
2872
+ }
2873
+ return input.replace(/\r?\n$/, "");
2874
+ } catch {
2875
+ throw new InvocationError("Expected value could not be read from stdin.");
2876
+ } finally {
2877
+ input = "";
2878
+ clearTimeout(timer);
2879
+ }
2880
+ }
2490
2881
  async function readOtp(timeoutMs) {
2491
2882
  let input = "";
2492
2883
  const timer = setTimeout(() => process.stdin.destroy(new Error("OTP input exceeded its deadline.")), timeoutMs);
@@ -2511,47 +2902,69 @@ async function startSession(command) {
2511
2902
  throw new InvocationError(error instanceof Error ? error.message : "The selected target is invalid.", "invalid_target");
2512
2903
  }
2513
2904
  await ensureSessionDirectory();
2514
- const sessionId = createSessionId();
2515
- const scriptPath = path5.resolve(process.argv[1] || fileURLToPath3(import.meta.url));
2516
- const child = spawn2(
2517
- process.execPath,
2518
- [
2519
- scriptPath,
2520
- "__owner",
2521
- sessionId,
2522
- target.origin,
2523
- command.mode,
2524
- command.headless ? "headless" : "headed",
2525
- String(command.timeoutMs),
2526
- target.environment
2527
- ],
2528
- {
2529
- detached: true,
2530
- stdio: "ignore"
2905
+ const deadlineAt = Date.now() + command.timeoutMs;
2906
+ const store = command.fresh ? void 0 : await SavedSession.open({ ...target, mode: command.mode });
2907
+ let spawned;
2908
+ try {
2909
+ store?.load();
2910
+ const selectOwner = () => {
2911
+ const existing = store?.owner();
2912
+ if (existing) {
2913
+ if (existing.headless !== command.headless) throw new InvocationError("The saved session is already live with different visibility. Close it before changing --headed, or use --fresh.");
2914
+ return existing;
2915
+ }
2916
+ const sessionId = createSessionId();
2917
+ const scriptPath = path6.resolve(process.argv[1] || fileURLToPath3(import.meta.url));
2918
+ const child = spawn2(process.execPath, [
2919
+ scriptPath,
2920
+ "__owner",
2921
+ sessionId,
2922
+ target.origin,
2923
+ command.mode,
2924
+ command.headless ? "headless" : "headed",
2925
+ String(command.timeoutMs),
2926
+ target.environment,
2927
+ command.fresh ? "fresh" : "saved"
2928
+ ], { detached: true, stdio: "ignore" });
2929
+ spawned = child;
2930
+ child.on("error", () => void 0);
2931
+ child.unref();
2932
+ if (!child.pid) throw new Error("Could not start the browser owner.");
2933
+ const owner = { sessionId, pid: child.pid, headless: command.headless };
2934
+ store?.setOwner(owner);
2935
+ return owner;
2936
+ };
2937
+ while (Date.now() < deadlineAt) {
2938
+ const owner = store ? await store.reserve(deadlineAt, selectOwner) : selectOwner();
2939
+ const result = await requestSession({ sessionId: owner.sessionId, action: "start", timeoutMs: Math.max(1, deadlineAt - Date.now()) }, {
2940
+ waitForSocket: true,
2941
+ ownerAlive: () => {
2942
+ try {
2943
+ process.kill(owner.pid, 0);
2944
+ return true;
2945
+ } catch {
2946
+ return false;
2947
+ }
2948
+ }
2949
+ });
2950
+ if (!store || !["session_not_found", "session_unavailable", "browser_start_failed"].includes(result.error?.code ?? "")) return result;
2951
+ await new Promise((resolve) => setTimeout(resolve, 40));
2531
2952
  }
2532
- );
2533
- let ownerAlive = true;
2534
- child.once("error", () => {
2535
- ownerAlive = false;
2536
- });
2537
- child.once("exit", () => {
2538
- ownerAlive = false;
2539
- });
2540
- child.unref();
2541
- const result = await requestSession({ sessionId, action: "start", timeoutMs: command.timeoutMs }, {
2542
- waitForSocket: true,
2543
- ownerAlive: () => ownerAlive
2544
- });
2545
- if (result.outcome === "failure" && result.error?.code === "deadline_exceeded") {
2546
- try {
2547
- process.kill(child.pid, "SIGTERM");
2548
- } catch {
2953
+ return failureResult("start", "deadline_exceeded", "The saved browser owner did not become available before the deadline.");
2954
+ } catch (error) {
2955
+ if (spawned?.pid) {
2956
+ try {
2957
+ process.kill(spawned.pid, "SIGTERM");
2958
+ } catch {
2959
+ }
2549
2960
  }
2961
+ throw error;
2962
+ } finally {
2963
+ store?.close();
2550
2964
  }
2551
- return result;
2552
2965
  }
2553
2966
  async function runOwner(argv) {
2554
- const [sessionId, origin, modeValue, headlessValue, timeoutValue, environment] = argv;
2967
+ const [sessionId, origin, modeValue, headlessValue, timeoutValue, environment, persistence] = argv;
2555
2968
  if (!isSessionId(sessionId) || !origin || modeValue !== "mobile" && modeValue !== "desktop" || headlessValue !== "headless" && headlessValue !== "headed" || !timeoutValue || !/^\d+$/.test(timeoutValue)) {
2556
2969
  process.exitCode = 2;
2557
2970
  return;
@@ -2562,7 +2975,8 @@ async function runOwner(argv) {
2562
2975
  environment: environment === "dev" ? "dev" : "local",
2563
2976
  mode: modeValue,
2564
2977
  headless: headlessValue === "headless",
2565
- timeoutMs: Math.min(Number(timeoutValue), MAX_TIMEOUT_MS)
2978
+ timeoutMs: Math.min(Number(timeoutValue), MAX_TIMEOUT_MS),
2979
+ persist: persistence === "saved"
2566
2980
  });
2567
2981
  }
2568
2982
  async function installBrowser(options) {
@@ -2575,7 +2989,7 @@ async function installBrowser(options) {
2575
2989
  }
2576
2990
  try {
2577
2991
  const require2 = createRequire(import.meta.url);
2578
- const browserCli = path5.join(path5.dirname(require2.resolve("playwright/package.json")), "cli.js");
2992
+ const browserCli = path6.join(path6.dirname(require2.resolve("playwright/package.json")), "cli.js");
2579
2993
  const code = await new Promise((resolve, reject) => {
2580
2994
  const child = spawn2(process.execPath, [browserCli, "install", ...options, "chromium"], {
2581
2995
  stdio: ["ignore", "pipe", "pipe"]
@@ -2671,7 +3085,7 @@ async function runCli(argv) {
2671
3085
  }
2672
3086
  } catch (error) {
2673
3087
  const action = command?.action ?? (error instanceof InvocationError ? error.action : void 0) ?? (argv[0] === "inspect" || argv[0] === "close" ? argv[0] : "start");
2674
- const code = error instanceof InvocationError ? error.code : "action_failed";
3088
+ const code = error instanceof SavedSessionError ? "saved_session_unavailable" : error instanceof InvocationError ? error.code : "action_failed";
2675
3089
  const message = error instanceof Error ? error.message : "The lmt-web command failed.";
2676
3090
  const result = failureResult(action, code, message);
2677
3091
  emit(result, `${code}: ${message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-digital-factory/lmt-web-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "lmt-web": "dist/index.js"
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "description": "Standalone browser session CLI for Live Market Terminal Web.",
12
12
  "engines": {
13
- "node": ">=22"
13
+ "node": ">=22.13"
14
14
  },
15
15
  "files": [
16
16
  "dist",