@ai-digital-factory/lmt-web-cli 0.1.3 → 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 +74 -16
  2. package/dist/index.js +338 -77
  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.
@@ -133,18 +135,70 @@ the page or taking a final screenshot. It also works after the browser window ha
133
135
  been closed manually. Supply the session ID returned by `start`; other sessions
134
136
  remain running. Like `close`, it does not log out or stop Broker activity.
135
137
 
136
- `start` opens the Home Page and returns a session ID. Every later command needs
137
- that ID and talks to the same local browser process without reloading the page.
138
- Concurrent commands within a session run in order. Different session IDs have
139
- separate in-memory browser contexts and storage. A session is owned by the local
140
- user who started it; closing it, losing its owner process, or restarting the
141
- machine requires a new session. The Web CLI does not import `lmt` cookies or
142
- save browser authentication. Closing a browser does not log out, revoke an
143
- 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.
144
197
 
145
198
  Each command writes one versioned JSON result to stdout and diagnostics to
146
199
  stderr. Results contain the action outcome, current URL, visible page text and
147
- controls, emulated device and actual viewport, and screenshot outcome. Images
200
+ controls, `authentication`, emulated device and actual viewport, and screenshot
201
+ outcome. Images
148
202
  are written to unique absolute paths in an owner-only temporary directory;
149
203
  screenshots solid-mask only Zerodha access-token value fields marked with
150
204
  `data-lmt-sensitive="zerodha-access-token"`; other controls and metadata stay visible.
@@ -155,8 +209,11 @@ session-loading or busy indicators, fonts, visible images, and finite animations
155
209
  to finish. The page must render text, controls, or visual content; an empty app
156
210
  root is not ready. Pages that remain empty reach the command deadline without a
157
211
  captured screenshot. Infinite animations do not block capture. `--timeout` sets one deadline for the command's browser action and
158
- screenshot, from `1ms` through `10m`. If an operation exceeds it, the session is
159
- 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.
160
217
  The JSON `error.code` is machine-readable:
161
218
 
162
219
  | Code | Exit | Meaning |
@@ -165,7 +222,8 @@ The JSON `error.code` is machine-readable:
165
222
  | `browser_unavailable` | 1 | Install Chromium with `lmt-web install-browser`, then retry. |
166
223
  | `browser_start_failed` | 1 | The local browser owner exited before its session became ready. |
167
224
  | `service_unavailable` | 1 | The selected Web URL could not be reached; start/check that service. |
168
- | `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. |
169
227
  | `deadline_exceeded`, `action_failed` | 1 | The action failed or exceeded its finite deadline. |
170
228
 
171
229
  Screenshots cover the viewport by default. `inspect --full-page` and
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" }) },
@@ -548,14 +689,14 @@ function isControlAction(action) {
548
689
 
549
690
  // src/config.ts
550
691
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
551
- import path3 from "node:path";
692
+ import path4 from "node:path";
552
693
  import { fileURLToPath as fileURLToPath2 } from "node:url";
553
- var repositoryRoot = path3.resolve(
554
- path3.dirname(fileURLToPath2(import.meta.url)),
694
+ var repositoryRoot = path4.resolve(
695
+ path4.dirname(fileURLToPath2(import.meta.url)),
555
696
  "../../../../"
556
697
  );
557
698
  var packagedConfig = fileURLToPath2(new URL("../lmt-web.config.json", import.meta.url));
558
- 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");
559
700
  function canonicalOrigin(value, environment) {
560
701
  if (typeof value !== "string" || !value.trim()) {
561
702
  throw new Error(`lmt-web.config.json does not contain the ${environment} Web URL.`);
@@ -605,6 +746,7 @@ function failureResult(action, code, message, sessionId = null) {
605
746
  sessionId,
606
747
  action,
607
748
  outcome: "failure",
749
+ authentication: { status: "unknown" },
608
750
  url: null,
609
751
  visibleState: null,
610
752
  device: null,
@@ -615,10 +757,10 @@ function failureResult(action, code, message, sessionId = null) {
615
757
 
616
758
  // src/session.ts
617
759
  import { randomBytes as randomBytes2 } from "node:crypto";
618
- 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";
619
761
  import net from "node:net";
620
- import os from "node:os";
621
- import path4 from "node:path";
762
+ import os2 from "node:os";
763
+ import path5 from "node:path";
622
764
  import { chromium as chromium2 } from "playwright";
623
765
 
624
766
  // ../../packages/crawler/src/full-content.ts
@@ -1829,7 +1971,7 @@ async function operatorLogout(page, timeoutMs, onSubmit = () => void 0) {
1829
1971
  }
1830
1972
 
1831
1973
  // src/session.ts
1832
- 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"}`);
1833
1975
  var SESSION_ID_PATTERN = /^[a-f0-9]{32}$/;
1834
1976
  var OWNER_RESPONSE_GRACE_MS = 1e3;
1835
1977
  var OWNER_CLEANUP_GRACE_MS = 2e3;
@@ -1844,19 +1986,19 @@ async function ensureSessionDirectory() {
1844
1986
  if (process.platform === "win32") {
1845
1987
  throw new Error("lmt-web session sockets are supported on macOS and Linux.");
1846
1988
  }
1847
- await mkdir2(SOCKET_DIRECTORY, { recursive: true, mode: 448 });
1989
+ await mkdir3(SOCKET_DIRECTORY, { recursive: true, mode: 448 });
1848
1990
  await chmod2(SOCKET_DIRECTORY, 448);
1849
- const directory = await lstat2(SOCKET_DIRECTORY);
1991
+ const directory = await lstat3(SOCKET_DIRECTORY);
1850
1992
  const uid = process.getuid?.();
1851
1993
  if (!directory.isDirectory() || uid !== void 0 && directory.uid !== uid) {
1852
1994
  throw new Error("The lmt-web session directory is not owned by the invoking user.");
1853
1995
  }
1854
1996
  }
1855
1997
  function socketPathFor(sessionId) {
1856
- return path4.join(SOCKET_DIRECTORY, `${sessionId}.sock`);
1998
+ return path5.join(SOCKET_DIRECTORY, `${sessionId}.sock`);
1857
1999
  }
1858
2000
  function artifactsDirectory(sessionId) {
1859
- return path4.join(SOCKET_DIRECTORY, "artifacts", sessionId);
2001
+ return path5.join(SOCKET_DIRECTORY, "artifacts", sessionId);
1860
2002
  }
1861
2003
  function redactSecrets(value) {
1862
2004
  return value.replace(
@@ -1874,6 +2016,7 @@ function safeUrl(value) {
1874
2016
  }
1875
2017
  }
1876
2018
  function failureCode(error) {
2019
+ if (error instanceof SavedSessionError) return "saved_session_unavailable";
1877
2020
  if (error instanceof ControlError) return error.code;
1878
2021
  const message = error instanceof Error ? error.message : "";
1879
2022
  if (/timed out|timeout|deadline/i.test(message)) return "deadline_exceeded";
@@ -1993,6 +2136,26 @@ async function inspectPage(page, mode, crawler) {
1993
2136
  const style = getComputedStyle(element);
1994
2137
  return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
1995
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
+ }
1996
2159
  const controls2 = Array.from(
1997
2160
  document.querySelectorAll("button, a[href], input, select, textarea, [role=button]")
1998
2161
  ).filter(visible).slice(0, 100).map((element, index) => {
@@ -2006,6 +2169,7 @@ async function inspectPage(page, mode, crawler) {
2006
2169
  };
2007
2170
  });
2008
2171
  return {
2172
+ authentication,
2009
2173
  title: document.title,
2010
2174
  text: document.body?.innerText ?? "",
2011
2175
  controls: controls2,
@@ -2019,6 +2183,7 @@ async function inspectPage(page, mode, crawler) {
2019
2183
  const redact = (value) => redactSecrets(crawler?.controls().redact(value) ?? value);
2020
2184
  const viewport = page.viewportSize?.() ?? result.viewport;
2021
2185
  return {
2186
+ authentication: "attemptId" in result.authentication ? { ...result.authentication, attemptId: redact(result.authentication.attemptId) } : result.authentication,
2022
2187
  url: safeUrl(redact(page.url())),
2023
2188
  visibleState: {
2024
2189
  title: redact(result.title).slice(0, 300),
@@ -2047,12 +2212,12 @@ async function captureScreenshot(page, sessionId, action, fullPage, timeoutMs, o
2047
2212
  if (!page.screenshot) return { outcome: "failed", error: "The browser does not support screenshots." };
2048
2213
  const directory = artifactsDirectory(sessionId);
2049
2214
  const filename = `${Date.now()}-${action}-${randomBytes2(4).toString("hex")}.png`;
2050
- const screenshotPath = path4.resolve(directory, filename);
2215
+ const screenshotPath = path5.resolve(directory, filename);
2051
2216
  if (timeoutMs < 1) {
2052
2217
  return { outcome: "failed", error: "The action deadline elapsed before screenshot capture." };
2053
2218
  }
2054
2219
  try {
2055
- await mkdir2(directory, { recursive: true, mode: 448 });
2220
+ await mkdir3(directory, { recursive: true, mode: 448 });
2056
2221
  await chmod2(directory, 448);
2057
2222
  await withDeadline(
2058
2223
  (async () => {
@@ -2103,6 +2268,23 @@ async function runSessionOwner(options) {
2103
2268
  await ensureSessionDirectory();
2104
2269
  const socketPath = socketPathFor(options.sessionId);
2105
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
+ }
2106
2288
  let crawler;
2107
2289
  let page;
2108
2290
  let startupError;
@@ -2112,6 +2294,7 @@ async function runSessionOwner(options) {
2112
2294
  let operationQueue = Promise.resolve();
2113
2295
  let shutdownPromise;
2114
2296
  let initializationPromise;
2297
+ let startupLease;
2115
2298
  let crawlerClosePromise;
2116
2299
  let expirationCleanup;
2117
2300
  let forceClosePendingBrowser;
@@ -2164,7 +2347,12 @@ async function runSessionOwner(options) {
2164
2347
  return;
2165
2348
  }
2166
2349
  const run = async () => {
2167
- 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
+ }
2168
2356
  socket.end(`${JSON.stringify(response)}
2169
2357
  `);
2170
2358
  if (closing) socket.once("close", () => void shutdown());
@@ -2178,7 +2366,7 @@ async function runSessionOwner(options) {
2178
2366
  baseUrl: options.origin,
2179
2367
  headless: options.headless,
2180
2368
  timeoutMs: Math.min(options.timeoutMs, Math.max(1, remainingMs(deadlineAt))),
2181
- contextOptions: device.contextOptions,
2369
+ contextOptions: { ...device.contextOptions, ...storageState ? { storageState } : {} },
2182
2370
  launch: () => {
2183
2371
  const onServerReady = (forceClose) => {
2184
2372
  forceClosePendingBrowser = forceClose;
@@ -2193,11 +2381,23 @@ async function runSessionOwner(options) {
2193
2381
  }
2194
2382
  });
2195
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
+ }
2196
2395
  if (closing) {
2197
2396
  await closeCrawler();
2198
2397
  return;
2199
2398
  }
2200
2399
  await crawler.openHomePage();
2400
+ await withDeadline(checkpoint(), remainingMs(deadlineAt), expireSession);
2201
2401
  } catch (error) {
2202
2402
  startupError = error;
2203
2403
  }
@@ -2206,17 +2406,16 @@ async function runSessionOwner(options) {
2206
2406
  if (!request || request.sessionId !== options.sessionId || !isAction(request.action) || !Number.isFinite(request.timeoutMs) || request.timeoutMs < 1 || !Number.isFinite(request.deadlineAt)) {
2207
2407
  return failureResult("inspect", "invalid_request", "The session request was invalid.", options.sessionId);
2208
2408
  }
2409
+ if (closing) return failureResult(request.action, "session_unavailable", "The browser owner is closing. Start to restore the saved session.", options.sessionId);
2209
2410
  const deadlineAt = request.deadlineAt;
2210
2411
  if (remainingMs(deadlineAt) === 0) {
2211
2412
  return failureResult(request.action, "deadline_exceeded", "The browser action exceeded its deadline.", options.sessionId);
2212
2413
  }
2213
2414
  if (request.action === "start" && startComplete) {
2214
- return failureResult("start", "session_already_started", "This browser session has already started.", options.sessionId);
2215
- }
2216
- if (!startComplete && request.action !== "start") {
2217
- return failureResult(request.action, "session_starting", "The browser session is not ready yet.", options.sessionId);
2415
+ return { ...await handleRequest({ ...request, action: "inspect" }), action: "start" };
2218
2416
  }
2219
2417
  if (request.action === "stop") {
2418
+ await withDeadline(checkpoint(), Math.min(500, Math.max(1, remainingMs(deadlineAt)))).catch(() => void 0);
2220
2419
  closing = true;
2221
2420
  abortOnShutdown = true;
2222
2421
  try {
@@ -2227,6 +2426,7 @@ async function runSessionOwner(options) {
2227
2426
  sessionId: options.sessionId,
2228
2427
  action: "stop",
2229
2428
  outcome: "success",
2429
+ authentication: { status: "unknown" },
2230
2430
  url: null,
2231
2431
  visibleState: null,
2232
2432
  device: null,
@@ -2236,7 +2436,12 @@ async function runSessionOwner(options) {
2236
2436
  return failureResult("stop", failureCode(error2), messageFor(error2), options.sessionId);
2237
2437
  }
2238
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;
2239
2443
  if (!startComplete) {
2444
+ clearTimeout(startupLease);
2240
2445
  initializationPromise ??= initialize(deadlineAt);
2241
2446
  try {
2242
2447
  await withDeadline(initializationPromise, remainingMs(deadlineAt), expireSession);
@@ -2265,6 +2470,7 @@ async function runSessionOwner(options) {
2265
2470
  sessionId: options.sessionId,
2266
2471
  action: "start",
2267
2472
  outcome: actionError2 ? "failure" : "success",
2473
+ authentication: inspected2?.authentication ?? { status: "unknown" },
2268
2474
  url: inspected2?.url ?? (startupError ? safeUrl(options.origin) : page ? safeUrl(crawler?.controls().redact(page.url()) ?? page.url()) : null),
2269
2475
  visibleState: inspected2?.visibleState ?? null,
2270
2476
  device: inspected2?.device ?? null,
@@ -2291,12 +2497,14 @@ async function runSessionOwner(options) {
2291
2497
  }
2292
2498
  let guidedError;
2293
2499
  let submitted = false;
2294
- const actionDeadlineAt = deadlineAt - Math.min(1500, Math.floor(remainingMs(deadlineAt) / 4));
2295
- let actionTimedOut = false;
2296
- const actionTimeout = () => Math.max(1, remainingMs(actionDeadlineAt));
2297
- const actionExpired = () => {
2298
- 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);
2299
2506
  };
2507
+ const actionTimeout = () => Math.max(1, remainingMs(actionDeadlineAt));
2300
2508
  page.setDefaultTimeout?.(actionTimeout());
2301
2509
  page.setDefaultNavigationTimeout?.(actionTimeout());
2302
2510
  let loginAttempt;
@@ -2305,16 +2513,16 @@ async function runSessionOwner(options) {
2305
2513
  };
2306
2514
  const zerodha = new PlaywrightZerodhaLoginPage(page, actionTimeout());
2307
2515
  try {
2308
- 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);
2309
- if (request.action === "operator-logout") await withDeadline(operatorLogout(page, actionTimeout(), onSubmit), actionTimeout(), actionExpired);
2310
- 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());
2311
2519
  if (request.action === "zerodha-otp") {
2312
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.");
2313
2521
  crawler.controls().registerSecrets(request.otp);
2314
- 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());
2315
2523
  if (result.status !== "SUCCESS") throw new OperatorActionError("otp_rejected", result.message);
2316
2524
  }
2317
- 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());
2318
2526
  } catch (error2) {
2319
2527
  guidedError = error2;
2320
2528
  }
@@ -2324,9 +2532,9 @@ async function runSessionOwner(options) {
2324
2532
  try {
2325
2533
  if (isControlAction(request)) {
2326
2534
  const budget = Math.max(1, remainingMs(deadlineAt) - Math.min(2e3, request.timeoutMs / 4));
2327
- controlResult = await withDeadline(crawler.controls().act(request, budget, () => {
2535
+ controlResult = await runAction(crawler.controls().act(request, budget, () => {
2328
2536
  submitted = true;
2329
- }), budget, () => void 0);
2537
+ }), budget);
2330
2538
  }
2331
2539
  } catch (error2) {
2332
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;
@@ -2334,6 +2542,25 @@ async function runSessionOwner(options) {
2334
2542
  await withDeadline(crawler.controls().stopLoading(), Math.min(500, remainingMs(deadlineAt))).catch(() => void 0);
2335
2543
  }
2336
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
+ }
2337
2564
  try {
2338
2565
  inspected = await withDeadline(inspectPage(page, options.mode, crawler), remainingMs(deadlineAt), expireSession);
2339
2566
  } catch (error2) {
@@ -2359,13 +2586,13 @@ async function runSessionOwner(options) {
2359
2586
  }
2360
2587
  }
2361
2588
  }
2362
- if (actionTimedOut) closing = true;
2363
2589
  const error = guidedError ?? actionError ?? closeError;
2364
2590
  return {
2365
2591
  version: RESULT_VERSION,
2366
2592
  sessionId: options.sessionId,
2367
2593
  action: request.action,
2368
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" },
2369
2596
  ...!actionError ? controlResult : {},
2370
2597
  ...loginAttempt ? { loginAttempt: { attemptId: loginAttempt.attemptId, status: loginAttempt.status } } : {},
2371
2598
  url: inspected?.url ?? (page ? safeUrl(crawler?.controls().redact(page.url()) ?? page.url()) : null),
@@ -2378,6 +2605,7 @@ async function runSessionOwner(options) {
2378
2605
  function shutdown() {
2379
2606
  if (!closing) return Promise.resolve();
2380
2607
  if (shutdownPromise) return shutdownPromise;
2608
+ clearTimeout(startupLease);
2381
2609
  shutdownPromise = (async () => {
2382
2610
  for (const socket of sockets) socket.destroy();
2383
2611
  await unlink2(socketPath).catch(() => void 0);
@@ -2404,6 +2632,7 @@ async function runSessionOwner(options) {
2404
2632
  }
2405
2633
  if (crawler) {
2406
2634
  try {
2635
+ await withDeadline(checkpoint(), OWNER_CLEANUP_GRACE_MS);
2407
2636
  await closeCrawler();
2408
2637
  } catch {
2409
2638
  await crawler.forceClose?.().catch(() => void 0);
@@ -2411,6 +2640,8 @@ async function runSessionOwner(options) {
2411
2640
  } else if (forceClosePendingBrowser) {
2412
2641
  await forceCloseBrowser().catch(() => void 0);
2413
2642
  }
2643
+ await withDeadline(checkpointQueue, OWNER_CLEANUP_GRACE_MS).catch(() => void 0);
2644
+ saved?.close();
2414
2645
  process.removeListener("SIGTERM", onSignal);
2415
2646
  process.removeListener("SIGINT", onSignal);
2416
2647
  })();
@@ -2423,6 +2654,11 @@ async function runSessionOwner(options) {
2423
2654
  void chmod2(socketPath, 384).then(resolve, reject);
2424
2655
  });
2425
2656
  });
2657
+ startupLease = setTimeout(() => {
2658
+ if (initializationPromise) return;
2659
+ expireSession();
2660
+ void shutdown();
2661
+ }, options.timeoutMs);
2426
2662
  const onSignal = () => {
2427
2663
  closing = true;
2428
2664
  void shutdown();
@@ -2434,7 +2670,7 @@ async function wait(milliseconds) {
2434
2670
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
2435
2671
  }
2436
2672
  function assertOwnedSocket(socketPath) {
2437
- return lstat2(socketPath).then((entry) => {
2673
+ return lstat3(socketPath).then((entry) => {
2438
2674
  const uid = process.getuid?.();
2439
2675
  if (!entry.isSocket() || uid !== void 0 && entry.uid !== uid) {
2440
2676
  throw new Error("The lmt-web session socket is not owned by the invoking user.");
@@ -2515,7 +2751,7 @@ var HELP = `lmt-web \u2014 browser interaction with Live Market Terminal Web
2515
2751
 
2516
2752
  Usage:
2517
2753
  lmt-web install-browser [--with-deps] [--dry-run]
2518
- 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]
2519
2755
  lmt-web inspect --session ID [--full-page] [--timeout 30s]
2520
2756
  lmt-web click --session ID --ref REF
2521
2757
  lmt-web fill --session ID --ref REF --value TEXT [--secret]
@@ -2535,7 +2771,8 @@ Usage:
2535
2771
  lmt-web close --session ID [--full-page] [--timeout 30s]
2536
2772
  lmt-web stop --session ID [--timeout 30s]
2537
2773
 
2538
- 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
2539
2776
  and headless mode are the defaults. Inspect and close require the returned session ID.
2540
2777
  Stop terminates the session's browser and owner without capturing a final screenshot.
2541
2778
  Each command writes one versioned JSON result to stdout; diagnostics go to stderr.
@@ -2601,6 +2838,7 @@ async function parseCommand(argv) {
2601
2838
  environment: values.get("--env") ?? "",
2602
2839
  mode,
2603
2840
  headless: !flags.has("--headed"),
2841
+ fresh: flags.has("--fresh"),
2604
2842
  timeoutMs
2605
2843
  };
2606
2844
  }
@@ -2664,47 +2902,69 @@ async function startSession(command) {
2664
2902
  throw new InvocationError(error instanceof Error ? error.message : "The selected target is invalid.", "invalid_target");
2665
2903
  }
2666
2904
  await ensureSessionDirectory();
2667
- const sessionId = createSessionId();
2668
- const scriptPath = path5.resolve(process.argv[1] || fileURLToPath3(import.meta.url));
2669
- const child = spawn2(
2670
- process.execPath,
2671
- [
2672
- scriptPath,
2673
- "__owner",
2674
- sessionId,
2675
- target.origin,
2676
- command.mode,
2677
- command.headless ? "headless" : "headed",
2678
- String(command.timeoutMs),
2679
- target.environment
2680
- ],
2681
- {
2682
- detached: true,
2683
- 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));
2684
2952
  }
2685
- );
2686
- let ownerAlive = true;
2687
- child.once("error", () => {
2688
- ownerAlive = false;
2689
- });
2690
- child.once("exit", () => {
2691
- ownerAlive = false;
2692
- });
2693
- child.unref();
2694
- const result = await requestSession({ sessionId, action: "start", timeoutMs: command.timeoutMs }, {
2695
- waitForSocket: true,
2696
- ownerAlive: () => ownerAlive
2697
- });
2698
- if (result.outcome === "failure" && result.error?.code === "deadline_exceeded") {
2699
- try {
2700
- process.kill(child.pid, "SIGTERM");
2701
- } 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
+ }
2702
2960
  }
2961
+ throw error;
2962
+ } finally {
2963
+ store?.close();
2703
2964
  }
2704
- return result;
2705
2965
  }
2706
2966
  async function runOwner(argv) {
2707
- const [sessionId, origin, modeValue, headlessValue, timeoutValue, environment] = argv;
2967
+ const [sessionId, origin, modeValue, headlessValue, timeoutValue, environment, persistence] = argv;
2708
2968
  if (!isSessionId(sessionId) || !origin || modeValue !== "mobile" && modeValue !== "desktop" || headlessValue !== "headless" && headlessValue !== "headed" || !timeoutValue || !/^\d+$/.test(timeoutValue)) {
2709
2969
  process.exitCode = 2;
2710
2970
  return;
@@ -2715,7 +2975,8 @@ async function runOwner(argv) {
2715
2975
  environment: environment === "dev" ? "dev" : "local",
2716
2976
  mode: modeValue,
2717
2977
  headless: headlessValue === "headless",
2718
- timeoutMs: Math.min(Number(timeoutValue), MAX_TIMEOUT_MS)
2978
+ timeoutMs: Math.min(Number(timeoutValue), MAX_TIMEOUT_MS),
2979
+ persist: persistence === "saved"
2719
2980
  });
2720
2981
  }
2721
2982
  async function installBrowser(options) {
@@ -2728,7 +2989,7 @@ async function installBrowser(options) {
2728
2989
  }
2729
2990
  try {
2730
2991
  const require2 = createRequire(import.meta.url);
2731
- 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");
2732
2993
  const code = await new Promise((resolve, reject) => {
2733
2994
  const child = spawn2(process.execPath, [browserCli, "install", ...options, "chromium"], {
2734
2995
  stdio: ["ignore", "pipe", "pipe"]
@@ -2824,7 +3085,7 @@ async function runCli(argv) {
2824
3085
  }
2825
3086
  } catch (error) {
2826
3087
  const action = command?.action ?? (error instanceof InvocationError ? error.action : void 0) ?? (argv[0] === "inspect" || argv[0] === "close" ? argv[0] : "start");
2827
- 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";
2828
3089
  const message = error instanceof Error ? error.message : "The lmt-web command failed.";
2829
3090
  const result = failureResult(action, code, message);
2830
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.3",
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",