opensteer 0.5.3 → 0.5.5

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.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,10 @@
14
14
  health checks to remove startup races across concurrent commands.
15
15
  - Added strict in-daemon request serialization for session commands, while
16
16
  keeping `ping` out of the queue for reliable liveness checks.
17
+ - Breaking: CLI daemon routing is now scoped by canonical `cwd`
18
+ (`realpath(cwd)`) + logical session (`--session`/`OPENSTEER_SESSION`) rather
19
+ than machine-wide session id matching; the same logical session can run in
20
+ parallel across different directories.
17
21
  - Breaking: removed legacy `ai` config from `OpensteerConfig`; use top-level `model` instead.
18
22
  - Breaking: `OPENSTEER_AI_MODEL` is no longer supported; use `OPENSTEER_MODEL`.
19
23
  - Breaking: `OPENSTEER_RUNTIME` is no longer supported; use `OPENSTEER_MODE`.
package/README.md CHANGED
@@ -111,6 +111,8 @@ opensteer close --session demo
111
111
  ```
112
112
 
113
113
  For non-interactive runs, set `OPENSTEER_SESSION` or `OPENSTEER_CLIENT_ID`.
114
+ Runtime daemon routing for `OPENSTEER_SESSION` is scoped by canonical `cwd`
115
+ (`realpath(cwd)`) + logical session id.
114
116
 
115
117
  ## For AI Agents
116
118
 
@@ -134,12 +136,6 @@ Install the Opensteer skill pack:
134
136
  opensteer skills install
135
137
  ```
136
138
 
137
- Fallback (direct upstream `skills` CLI):
138
-
139
- ```bash
140
- npx skills add https://github.com/steerlabs/opensteer-skills --skill opensteer
141
- ```
142
-
143
139
  Claude Code marketplace plugin:
144
140
 
145
141
  ```text
package/bin/opensteer.mjs CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  closeSync,
7
7
  existsSync,
8
8
  openSync,
9
+ realpathSync,
9
10
  readFileSync,
10
11
  readdirSync,
11
12
  unlinkSync,
@@ -55,11 +56,13 @@ const RUNTIME_PREFIX = 'opensteer-'
55
56
  const SOCKET_SUFFIX = '.sock'
56
57
  const PID_SUFFIX = '.pid'
57
58
  const LOCK_SUFFIX = '.lock'
59
+ const METADATA_SUFFIX = '.meta.json'
58
60
  const CLIENT_BINDING_PREFIX = `${RUNTIME_PREFIX}client-`
59
61
  const CLIENT_BINDING_SUFFIX = '.session'
60
62
  const CLOSE_ALL_REQUEST = { id: 1, command: 'close', args: {} }
61
63
  const PING_REQUEST = { id: 1, command: 'ping', args: {} }
62
64
  const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/
65
+ const RUNTIME_SESSION_PREFIX = 'sc-'
63
66
 
64
67
  function getVersion() {
65
68
  try {
@@ -214,7 +217,20 @@ function isInteractiveTerminal() {
214
217
  return Boolean(process.stdin.isTTY && process.stdout.isTTY)
215
218
  }
216
219
 
217
- function resolveSession(flags) {
220
+ function resolveScopeDir() {
221
+ const cwd = process.cwd()
222
+ try {
223
+ return realpathSync(cwd)
224
+ } catch {
225
+ return cwd
226
+ }
227
+ }
228
+
229
+ function buildRuntimeSession(scopeDir, logicalSession) {
230
+ return `${RUNTIME_SESSION_PREFIX}${hashKey(`${scopeDir}:${logicalSession}`).slice(0, 24)}`
231
+ }
232
+
233
+ function resolveSession(flags, scopeDir) {
218
234
  if (flags.session !== undefined) {
219
235
  if (flags.session === true) {
220
236
  throw new Error('--session requires a session id value.')
@@ -244,7 +260,7 @@ function resolveSession(flags) {
244
260
  process.env.OPENSTEER_CLIENT_ID.trim().length > 0
245
261
  ) {
246
262
  const clientId = process.env.OPENSTEER_CLIENT_ID.trim()
247
- const clientKey = `client:${process.cwd()}:${clientId}`
263
+ const clientKey = `client:${scopeDir}:${clientId}`
248
264
  const bound = readClientBinding(clientKey)
249
265
  if (bound) {
250
266
  return { session: bound, source: 'client_binding' }
@@ -256,7 +272,7 @@ function resolveSession(flags) {
256
272
  }
257
273
 
258
274
  if (isInteractiveTerminal()) {
259
- const ttyKey = `tty:${process.cwd()}:${process.ppid}`
275
+ const ttyKey = `tty:${scopeDir}:${process.ppid}`
260
276
  const bound = readClientBinding(ttyKey)
261
277
  if (bound) {
262
278
  return { session: bound, source: 'tty_default' }
@@ -284,6 +300,10 @@ function getLockPath(session) {
284
300
  return join(tmpdir(), `${RUNTIME_PREFIX}${session}${LOCK_SUFFIX}`)
285
301
  }
286
302
 
303
+ function getMetadataPath(session) {
304
+ return join(tmpdir(), `${RUNTIME_PREFIX}${session}${METADATA_SUFFIX}`)
305
+ }
306
+
287
307
  function buildRequest(command, flags, positional) {
288
308
  const id = 1
289
309
  const globalFlags = {}
@@ -430,20 +450,80 @@ function cleanStaleFiles(session, options = {}) {
430
450
  unlinkSync(getPidPath(session))
431
451
  } catch { }
432
452
  }
453
+
454
+ try {
455
+ unlinkSync(getMetadataPath(session))
456
+ } catch { }
433
457
  }
434
458
 
435
- function startServer(session) {
459
+ function startServer(runtimeSession, logicalSession, scopeDir) {
436
460
  const child = spawn('node', [SERVER_SCRIPT], {
437
461
  detached: true,
438
462
  stdio: ['ignore', 'ignore', 'ignore'],
439
463
  env: {
440
464
  ...process.env,
441
- OPENSTEER_SESSION: session,
465
+ OPENSTEER_SESSION: runtimeSession,
466
+ OPENSTEER_LOGICAL_SESSION: logicalSession,
467
+ OPENSTEER_SCOPE_DIR: scopeDir,
442
468
  },
443
469
  })
444
470
  child.unref()
445
471
  }
446
472
 
473
+ function readMetadata(session) {
474
+ const metadataPath = getMetadataPath(session)
475
+ if (!existsSync(metadataPath)) {
476
+ return null
477
+ }
478
+
479
+ try {
480
+ const raw = JSON.parse(readFileSync(metadataPath, 'utf-8'))
481
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
482
+ return null
483
+ }
484
+
485
+ if (
486
+ typeof raw.logicalSession !== 'string' ||
487
+ !raw.logicalSession.trim() ||
488
+ typeof raw.scopeDir !== 'string' ||
489
+ !raw.scopeDir.trim() ||
490
+ typeof raw.runtimeSession !== 'string' ||
491
+ !raw.runtimeSession.trim()
492
+ ) {
493
+ return null
494
+ }
495
+
496
+ return {
497
+ logicalSession: raw.logicalSession.trim(),
498
+ scopeDir: raw.scopeDir,
499
+ runtimeSession: raw.runtimeSession.trim(),
500
+ createdAt:
501
+ typeof raw.createdAt === 'number' ? raw.createdAt : undefined,
502
+ updatedAt:
503
+ typeof raw.updatedAt === 'number' ? raw.updatedAt : undefined,
504
+ }
505
+ } catch {
506
+ return null
507
+ }
508
+ }
509
+
510
+ function writeMetadata(runtimeSession, logicalSession, scopeDir) {
511
+ const metadataPath = getMetadataPath(runtimeSession)
512
+ const existing = readMetadata(runtimeSession)
513
+ const now = Date.now()
514
+ const payload = {
515
+ runtimeSession,
516
+ logicalSession,
517
+ scopeDir,
518
+ createdAt: existing?.createdAt ?? now,
519
+ updatedAt: now,
520
+ }
521
+
522
+ try {
523
+ writeFileSync(metadataPath, JSON.stringify(payload, null, 2))
524
+ } catch { }
525
+ }
526
+
447
527
  function sendCommand(socketPath, request, timeoutMs = RESPONSE_TIMEOUT) {
448
528
  return new Promise((resolve, reject) => {
449
529
  const socket = connect(socketPath)
@@ -606,8 +686,14 @@ async function waitForServerReady(session, timeout) {
606
686
  throw new Error(`Timed out waiting for server '${session}' to become healthy.`)
607
687
  }
608
688
 
609
- async function ensureServer(session) {
610
- if (await isServerHealthy(session)) {
689
+ async function ensureServer(context) {
690
+ const runtimeSession = context.runtimeSession
691
+ if (await isServerHealthy(runtimeSession)) {
692
+ writeMetadata(
693
+ runtimeSession,
694
+ context.logicalSession,
695
+ context.scopeDir
696
+ )
611
697
  return
612
698
  }
613
699
 
@@ -620,31 +706,45 @@ async function ensureServer(session) {
620
706
  const deadline = Date.now() + CONNECT_TIMEOUT
621
707
 
622
708
  while (Date.now() < deadline) {
623
- if (await isServerHealthy(session)) {
709
+ if (await isServerHealthy(runtimeSession)) {
710
+ writeMetadata(
711
+ runtimeSession,
712
+ context.logicalSession,
713
+ context.scopeDir
714
+ )
624
715
  return
625
716
  }
626
717
 
627
- const existingPid = readPid(getPidPath(session))
718
+ const existingPid = readPid(getPidPath(runtimeSession))
628
719
  if (existingPid && isPidAlive(existingPid)) {
629
720
  await sleep(POLL_INTERVAL)
630
721
  continue
631
722
  }
632
723
 
633
- recoverStaleStartLock(session)
724
+ recoverStaleStartLock(runtimeSession)
634
725
 
635
- if (acquireStartLock(session)) {
726
+ if (acquireStartLock(runtimeSession)) {
636
727
  try {
637
- if (!(await isServerHealthy(session))) {
638
- startServer(session)
728
+ if (!(await isServerHealthy(runtimeSession))) {
729
+ startServer(
730
+ runtimeSession,
731
+ context.logicalSession,
732
+ context.scopeDir
733
+ )
639
734
  }
640
735
 
641
736
  await waitForServerReady(
642
- session,
737
+ runtimeSession,
643
738
  Math.max(500, deadline - Date.now())
644
739
  )
740
+ writeMetadata(
741
+ runtimeSession,
742
+ context.logicalSession,
743
+ context.scopeDir
744
+ )
645
745
  return
646
746
  } finally {
647
- releaseStartLock(session)
747
+ releaseStartLock(runtimeSession)
648
748
  }
649
749
  }
650
750
 
@@ -652,7 +752,7 @@ async function ensureServer(session) {
652
752
  }
653
753
 
654
754
  throw new Error(
655
- `Failed to start server for session '${session}' within ${CONNECT_TIMEOUT}ms.`
755
+ `Failed to start server for session '${context.logicalSession}' in cwd scope '${context.scopeDir}' within ${CONNECT_TIMEOUT}ms.`
656
756
  )
657
757
  }
658
758
 
@@ -665,24 +765,39 @@ function listSessions() {
665
765
  continue
666
766
  }
667
767
 
668
- const name = entry.slice(
768
+ const runtimeSession = entry.slice(
669
769
  RUNTIME_PREFIX.length,
670
770
  entry.length - PID_SUFFIX.length
671
771
  )
672
- if (!name) {
772
+ if (!runtimeSession) {
673
773
  continue
674
774
  }
675
775
 
676
776
  const pid = readPid(join(tmpdir(), entry))
677
777
  if (!pid || !isPidAlive(pid)) {
678
- cleanStaleFiles(name)
778
+ cleanStaleFiles(runtimeSession)
679
779
  continue
680
780
  }
681
781
 
682
- sessions.push({ name, pid })
782
+ const metadata = readMetadata(runtimeSession)
783
+ sessions.push({
784
+ name: metadata?.logicalSession || runtimeSession,
785
+ logicalSession: metadata?.logicalSession || runtimeSession,
786
+ runtimeSession,
787
+ scopeDir: metadata?.scopeDir || null,
788
+ pid,
789
+ })
683
790
  }
684
791
 
685
- sessions.sort((a, b) => a.name.localeCompare(b.name))
792
+ sessions.sort((a, b) => {
793
+ const scopeA = a.scopeDir || ''
794
+ const scopeB = b.scopeDir || ''
795
+ if (scopeA !== scopeB) {
796
+ return scopeA.localeCompare(scopeB)
797
+ }
798
+
799
+ return a.logicalSession.localeCompare(b.logicalSession)
800
+ })
686
801
  return sessions
687
802
  }
688
803
 
@@ -692,9 +807,9 @@ async function closeAllSessions() {
692
807
  const failures = []
693
808
 
694
809
  for (const session of sessions) {
695
- const socketPath = getSocketPath(session.name)
810
+ const socketPath = getSocketPath(session.runtimeSession)
696
811
  if (!existsSync(socketPath)) {
697
- cleanStaleFiles(session.name)
812
+ cleanStaleFiles(session.runtimeSession)
698
813
  continue
699
814
  }
700
815
 
@@ -704,12 +819,12 @@ async function closeAllSessions() {
704
819
  closed.push(session)
705
820
  } else {
706
821
  failures.push(
707
- `${session.name}: ${response?.error || 'unknown close error'}`
822
+ `${session.logicalSession} (${session.scopeDir || 'unknown scope'}): ${response?.error || 'unknown close error'}`
708
823
  )
709
824
  }
710
825
  } catch (err) {
711
826
  failures.push(
712
- `${session.name}: ${err instanceof Error ? err.message : String(err)}`
827
+ `${session.logicalSession} (${session.scopeDir || 'unknown scope'}): ${err instanceof Error ? err.message : String(err)}`
713
828
  )
714
829
  }
715
830
  }
@@ -817,7 +932,7 @@ Navigation:
817
932
 
818
933
  Sessions:
819
934
  sessions List active session-scoped daemons
820
- status Show resolved session/name and session state
935
+ status Show resolved logical/runtime session and session state
821
936
 
822
937
  Observation:
823
938
  snapshot [--mode action] Get page snapshot
@@ -868,7 +983,7 @@ Skills:
868
983
  skills --help Show skills installer help
869
984
 
870
985
  Global Flags:
871
- --session <id> Runtime session id for daemon/browser routing
986
+ --session <id> Logical session id (scoped by canonical cwd)
872
987
  --name <namespace> Selector namespace for cache storage on 'open'
873
988
  --headless Launch browser in headless mode
874
989
  --connect-url <url> Connect to a running browser (e.g. http://localhost:9222)
@@ -881,7 +996,7 @@ Global Flags:
881
996
  --version, -v Show version
882
997
 
883
998
  Environment:
884
- OPENSTEER_SESSION Runtime session id (equivalent to --session)
999
+ OPENSTEER_SESSION Logical session id (equivalent to --session)
885
1000
  OPENSTEER_CLIENT_ID Stable client identity for default session binding
886
1001
  OPENSTEER_NAME Default selector namespace for 'open' when --name is omitted
887
1002
  OPENSTEER_MODE Runtime routing: "local" (default) or "cloud"
@@ -925,27 +1040,37 @@ async function main() {
925
1040
 
926
1041
  let resolvedSession
927
1042
  let resolvedName
1043
+ const scopeDir = resolveScopeDir()
928
1044
  try {
929
- resolvedSession = resolveSession(flags)
1045
+ resolvedSession = resolveSession(flags, scopeDir)
930
1046
  resolvedName = resolveName(flags, resolvedSession.session)
931
1047
  } catch (err) {
932
1048
  error(err instanceof Error ? err.message : 'Failed to resolve session')
933
1049
  }
934
1050
 
935
- const session = resolvedSession.session
1051
+ const logicalSession = resolvedSession.session
1052
+ const runtimeSession = buildRuntimeSession(scopeDir, logicalSession)
936
1053
  const sessionSource = resolvedSession.source
937
1054
  const name = resolvedName.name
938
1055
  const nameSource = resolvedName.source
939
- const socketPath = getSocketPath(session)
1056
+ const socketPath = getSocketPath(runtimeSession)
1057
+ const routingContext = {
1058
+ logicalSession,
1059
+ runtimeSession,
1060
+ scopeDir,
1061
+ }
940
1062
 
941
1063
  if (command === 'status') {
942
1064
  output({
943
1065
  ok: true,
944
- resolvedSession: session,
1066
+ resolvedSession: logicalSession,
1067
+ logicalSession,
1068
+ runtimeSession,
1069
+ scopeDir,
945
1070
  sessionSource,
946
1071
  resolvedName: name,
947
1072
  nameSource,
948
- serverRunning: await isServerHealthy(session),
1073
+ serverRunning: await isServerHealthy(runtimeSession),
949
1074
  socketPath,
950
1075
  sessions: listSessions(),
951
1076
  })
@@ -961,20 +1086,20 @@ async function main() {
961
1086
  request.args.name = name
962
1087
  }
963
1088
 
964
- if (!(await isServerHealthy(session))) {
1089
+ if (!(await isServerHealthy(runtimeSession))) {
965
1090
  if (command !== 'open') {
966
1091
  error(
967
- `No server running for session '${session}' (resolved from ${sessionSource}). Run 'opensteer open' first or use 'opensteer sessions' to see active sessions.`
1092
+ `No server running for session '${logicalSession}' in cwd scope '${scopeDir}' (resolved from ${sessionSource}). Run 'opensteer open' first or use 'opensteer sessions' to see active sessions.`
968
1093
  )
969
1094
  }
970
1095
 
971
1096
  try {
972
- await ensureServer(session)
1097
+ await ensureServer(routingContext)
973
1098
  } catch (err) {
974
1099
  error(
975
1100
  err instanceof Error
976
1101
  ? err.message
977
- : `Failed to start server for session '${session}'.`
1102
+ : `Failed to start server for session '${logicalSession}' in cwd scope '${scopeDir}'.`
978
1103
  )
979
1104
  }
980
1105
  }
@@ -992,7 +1117,7 @@ async function main() {
992
1117
  error(
993
1118
  formatTransportFailure(
994
1119
  err,
995
- `Failed to run '${command}' for session '${session}'`
1120
+ `Failed to run '${command}' for session '${logicalSession}' in cwd scope '${scopeDir}'`
996
1121
  )
997
1122
  )
998
1123
  }
@@ -2999,8 +2999,56 @@ function applyCleaner(mode, html) {
2999
2999
  return cleanForAction(html);
3000
3000
  }
3001
3001
  }
3002
+ function canonicalizeDuplicateNodeIds($) {
3003
+ const occurrencesByNodeId = /* @__PURE__ */ new Map();
3004
+ let order = 0;
3005
+ $("*").each(function() {
3006
+ const element = this;
3007
+ const nodeId = $(element).attr(OS_NODE_ID_ATTR);
3008
+ if (!nodeId) {
3009
+ order += 1;
3010
+ return;
3011
+ }
3012
+ const list = occurrencesByNodeId.get(nodeId) || [];
3013
+ list.push({
3014
+ element,
3015
+ order
3016
+ });
3017
+ occurrencesByNodeId.set(nodeId, list);
3018
+ order += 1;
3019
+ });
3020
+ for (const occurrences of occurrencesByNodeId.values()) {
3021
+ if (occurrences.length <= 1) continue;
3022
+ const canonical = pickCanonicalNodeIdOccurrence($, occurrences);
3023
+ for (const occurrence of occurrences) {
3024
+ if (occurrence.element === canonical.element) continue;
3025
+ $(occurrence.element).removeAttr(OS_NODE_ID_ATTR);
3026
+ }
3027
+ }
3028
+ }
3029
+ function pickCanonicalNodeIdOccurrence($, occurrences) {
3030
+ let best = occurrences[0];
3031
+ let bestScore = scoreNodeIdOccurrence($, best.element);
3032
+ for (let i = 1; i < occurrences.length; i += 1) {
3033
+ const candidate = occurrences[i];
3034
+ const candidateScore = scoreNodeIdOccurrence($, candidate.element);
3035
+ if (candidateScore > bestScore || candidateScore === bestScore && candidate.order < best.order) {
3036
+ best = candidate;
3037
+ bestScore = candidateScore;
3038
+ }
3039
+ }
3040
+ return best;
3041
+ }
3042
+ function scoreNodeIdOccurrence($, element) {
3043
+ const el = $(element);
3044
+ const descendantCount = el.find("*").length;
3045
+ const normalizedTextLength = el.text().replace(/\s+/g, " ").trim().length;
3046
+ const attributeCount = Object.keys(el.attr() || {}).length;
3047
+ return descendantCount * 100 + normalizedTextLength * 10 + attributeCount;
3048
+ }
3002
3049
  async function assignCounters(page, html, nodePaths, nodeMeta) {
3003
3050
  const $ = cheerio3.load(html, { xmlMode: false });
3051
+ canonicalizeDuplicateNodeIds($);
3004
3052
  const counterIndex = /* @__PURE__ */ new Map();
3005
3053
  let nextCounter = 1;
3006
3054
  const assignedByNodeId = /* @__PURE__ */ new Map();
@@ -3182,44 +3230,63 @@ function stripNodeIds(html) {
3182
3230
  $(`[${OS_NODE_ID_ATTR}]`).removeAttr(OS_NODE_ID_ATTR);
3183
3231
  return $.html();
3184
3232
  }
3233
+ function isLiveCounterSyncFailure(error) {
3234
+ if (!(error instanceof Error)) return false;
3235
+ return error.message.startsWith(
3236
+ "Failed to synchronize snapshot counters with the live DOM:"
3237
+ );
3238
+ }
3185
3239
  async function prepareSnapshot(page, options = {}) {
3186
3240
  const mode = options.mode ?? "action";
3187
3241
  const withCounters = options.withCounters ?? true;
3188
3242
  const shouldMarkInteractive = options.markInteractive ?? true;
3189
- if (shouldMarkInteractive) {
3190
- await markInteractiveElements(page);
3191
- }
3192
- const serialized = await serializePageHTML(page);
3193
- const rawHtml = serialized.html;
3194
- const processedHtml = rawHtml;
3195
- const reducedHtml = applyCleaner(mode, processedHtml);
3196
- let cleanedHtml = reducedHtml;
3197
- let counterIndex = null;
3198
- if (withCounters) {
3199
- const counted = await assignCounters(
3200
- page,
3243
+ const maxAttempts = withCounters ? 4 : 1;
3244
+ let lastCounterSyncError = null;
3245
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
3246
+ if (shouldMarkInteractive) {
3247
+ await markInteractiveElements(page);
3248
+ }
3249
+ const serialized = await serializePageHTML(page);
3250
+ const rawHtml = serialized.html;
3251
+ const processedHtml = rawHtml;
3252
+ const reducedHtml = applyCleaner(mode, processedHtml);
3253
+ let cleanedHtml = reducedHtml;
3254
+ let counterIndex = null;
3255
+ if (withCounters) {
3256
+ try {
3257
+ const counted = await assignCounters(
3258
+ page,
3259
+ reducedHtml,
3260
+ serialized.nodePaths,
3261
+ serialized.nodeMeta
3262
+ );
3263
+ cleanedHtml = counted.html;
3264
+ counterIndex = counted.counterIndex;
3265
+ } catch (error) {
3266
+ if (attempt < maxAttempts && isLiveCounterSyncFailure(error)) {
3267
+ lastCounterSyncError = error;
3268
+ continue;
3269
+ }
3270
+ throw error;
3271
+ }
3272
+ } else {
3273
+ cleanedHtml = stripNodeIds(cleanedHtml);
3274
+ }
3275
+ if (mode === "extraction") {
3276
+ const $unwrap = cheerio3.load(cleanedHtml, { xmlMode: false });
3277
+ cleanedHtml = $unwrap("body").html()?.trim() || cleanedHtml;
3278
+ }
3279
+ return {
3280
+ mode,
3281
+ url: page.url(),
3282
+ rawHtml,
3283
+ processedHtml,
3201
3284
  reducedHtml,
3202
- serialized.nodePaths,
3203
- serialized.nodeMeta
3204
- );
3205
- cleanedHtml = counted.html;
3206
- counterIndex = counted.counterIndex;
3207
- } else {
3208
- cleanedHtml = stripNodeIds(cleanedHtml);
3209
- }
3210
- if (mode === "extraction") {
3211
- const $unwrap = cheerio3.load(cleanedHtml, { xmlMode: false });
3212
- cleanedHtml = $unwrap("body").html()?.trim() || cleanedHtml;
3285
+ cleanedHtml,
3286
+ counterIndex
3287
+ };
3213
3288
  }
3214
- return {
3215
- mode,
3216
- url: page.url(),
3217
- rawHtml,
3218
- processedHtml,
3219
- reducedHtml,
3220
- cleanedHtml,
3221
- counterIndex
3222
- };
3289
+ throw lastCounterSyncError || new Error("Failed to prepare snapshot after retrying counter sync.");
3223
3290
  }
3224
3291
 
3225
3292
  // src/element-path/errors.ts
@@ -4219,8 +4219,56 @@ function applyCleaner(mode, html) {
4219
4219
  return cleanForAction(html);
4220
4220
  }
4221
4221
  }
4222
+ function canonicalizeDuplicateNodeIds($) {
4223
+ const occurrencesByNodeId = /* @__PURE__ */ new Map();
4224
+ let order = 0;
4225
+ $("*").each(function() {
4226
+ const element = this;
4227
+ const nodeId = $(element).attr(OS_NODE_ID_ATTR);
4228
+ if (!nodeId) {
4229
+ order += 1;
4230
+ return;
4231
+ }
4232
+ const list = occurrencesByNodeId.get(nodeId) || [];
4233
+ list.push({
4234
+ element,
4235
+ order
4236
+ });
4237
+ occurrencesByNodeId.set(nodeId, list);
4238
+ order += 1;
4239
+ });
4240
+ for (const occurrences of occurrencesByNodeId.values()) {
4241
+ if (occurrences.length <= 1) continue;
4242
+ const canonical = pickCanonicalNodeIdOccurrence($, occurrences);
4243
+ for (const occurrence of occurrences) {
4244
+ if (occurrence.element === canonical.element) continue;
4245
+ $(occurrence.element).removeAttr(OS_NODE_ID_ATTR);
4246
+ }
4247
+ }
4248
+ }
4249
+ function pickCanonicalNodeIdOccurrence($, occurrences) {
4250
+ let best = occurrences[0];
4251
+ let bestScore = scoreNodeIdOccurrence($, best.element);
4252
+ for (let i = 1; i < occurrences.length; i += 1) {
4253
+ const candidate = occurrences[i];
4254
+ const candidateScore = scoreNodeIdOccurrence($, candidate.element);
4255
+ if (candidateScore > bestScore || candidateScore === bestScore && candidate.order < best.order) {
4256
+ best = candidate;
4257
+ bestScore = candidateScore;
4258
+ }
4259
+ }
4260
+ return best;
4261
+ }
4262
+ function scoreNodeIdOccurrence($, element) {
4263
+ const el = $(element);
4264
+ const descendantCount = el.find("*").length;
4265
+ const normalizedTextLength = el.text().replace(/\s+/g, " ").trim().length;
4266
+ const attributeCount = Object.keys(el.attr() || {}).length;
4267
+ return descendantCount * 100 + normalizedTextLength * 10 + attributeCount;
4268
+ }
4222
4269
  async function assignCounters(page, html, nodePaths, nodeMeta) {
4223
4270
  const $ = cheerio3.load(html, { xmlMode: false });
4271
+ canonicalizeDuplicateNodeIds($);
4224
4272
  const counterIndex = /* @__PURE__ */ new Map();
4225
4273
  let nextCounter = 1;
4226
4274
  const assignedByNodeId = /* @__PURE__ */ new Map();
@@ -4402,44 +4450,63 @@ function stripNodeIds(html) {
4402
4450
  $(`[${OS_NODE_ID_ATTR}]`).removeAttr(OS_NODE_ID_ATTR);
4403
4451
  return $.html();
4404
4452
  }
4453
+ function isLiveCounterSyncFailure(error) {
4454
+ if (!(error instanceof Error)) return false;
4455
+ return error.message.startsWith(
4456
+ "Failed to synchronize snapshot counters with the live DOM:"
4457
+ );
4458
+ }
4405
4459
  async function prepareSnapshot(page, options = {}) {
4406
4460
  const mode = options.mode ?? "action";
4407
4461
  const withCounters = options.withCounters ?? true;
4408
4462
  const shouldMarkInteractive = options.markInteractive ?? true;
4409
- if (shouldMarkInteractive) {
4410
- await markInteractiveElements(page);
4411
- }
4412
- const serialized = await serializePageHTML(page);
4413
- const rawHtml = serialized.html;
4414
- const processedHtml = rawHtml;
4415
- const reducedHtml = applyCleaner(mode, processedHtml);
4416
- let cleanedHtml = reducedHtml;
4417
- let counterIndex = null;
4418
- if (withCounters) {
4419
- const counted = await assignCounters(
4420
- page,
4463
+ const maxAttempts = withCounters ? 4 : 1;
4464
+ let lastCounterSyncError = null;
4465
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
4466
+ if (shouldMarkInteractive) {
4467
+ await markInteractiveElements(page);
4468
+ }
4469
+ const serialized = await serializePageHTML(page);
4470
+ const rawHtml = serialized.html;
4471
+ const processedHtml = rawHtml;
4472
+ const reducedHtml = applyCleaner(mode, processedHtml);
4473
+ let cleanedHtml = reducedHtml;
4474
+ let counterIndex = null;
4475
+ if (withCounters) {
4476
+ try {
4477
+ const counted = await assignCounters(
4478
+ page,
4479
+ reducedHtml,
4480
+ serialized.nodePaths,
4481
+ serialized.nodeMeta
4482
+ );
4483
+ cleanedHtml = counted.html;
4484
+ counterIndex = counted.counterIndex;
4485
+ } catch (error) {
4486
+ if (attempt < maxAttempts && isLiveCounterSyncFailure(error)) {
4487
+ lastCounterSyncError = error;
4488
+ continue;
4489
+ }
4490
+ throw error;
4491
+ }
4492
+ } else {
4493
+ cleanedHtml = stripNodeIds(cleanedHtml);
4494
+ }
4495
+ if (mode === "extraction") {
4496
+ const $unwrap = cheerio3.load(cleanedHtml, { xmlMode: false });
4497
+ cleanedHtml = $unwrap("body").html()?.trim() || cleanedHtml;
4498
+ }
4499
+ return {
4500
+ mode,
4501
+ url: page.url(),
4502
+ rawHtml,
4503
+ processedHtml,
4421
4504
  reducedHtml,
4422
- serialized.nodePaths,
4423
- serialized.nodeMeta
4424
- );
4425
- cleanedHtml = counted.html;
4426
- counterIndex = counted.counterIndex;
4427
- } else {
4428
- cleanedHtml = stripNodeIds(cleanedHtml);
4429
- }
4430
- if (mode === "extraction") {
4431
- const $unwrap = cheerio3.load(cleanedHtml, { xmlMode: false });
4432
- cleanedHtml = $unwrap("body").html()?.trim() || cleanedHtml;
4505
+ cleanedHtml,
4506
+ counterIndex
4507
+ };
4433
4508
  }
4434
- return {
4435
- mode,
4436
- url: page.url(),
4437
- rawHtml,
4438
- processedHtml,
4439
- reducedHtml,
4440
- cleanedHtml,
4441
- counterIndex
4442
- };
4509
+ throw lastCounterSyncError || new Error("Failed to prepare snapshot after retrying counter sync.");
4443
4510
  }
4444
4511
 
4445
4512
  // src/element-path/errors.ts
@@ -12538,6 +12605,9 @@ function getSocketPath(session2) {
12538
12605
  function getPidPath(session2) {
12539
12606
  return (0, import_path6.join)((0, import_os2.tmpdir)(), `${prefix(session2)}.pid`);
12540
12607
  }
12608
+ function getMetadataPath(session2) {
12609
+ return (0, import_path6.join)((0, import_os2.tmpdir)(), `${prefix(session2)}.meta.json`);
12610
+ }
12541
12611
 
12542
12612
  // src/cli/commands.ts
12543
12613
  var import_promises2 = require("fs/promises");
@@ -12806,6 +12876,8 @@ if (!sessionEnv) {
12806
12876
  process.exit(1);
12807
12877
  }
12808
12878
  var session = sessionEnv;
12879
+ var logicalSession = process.env.OPENSTEER_LOGICAL_SESSION?.trim() || session;
12880
+ var scopeDir = process.env.OPENSTEER_SCOPE_DIR?.trim() || process.cwd();
12809
12881
  var socketPath = getSocketPath(session);
12810
12882
  var pidPath = getPidPath(session);
12811
12883
  function cleanup() {
@@ -12817,6 +12889,10 @@ function cleanup() {
12817
12889
  (0, import_fs4.unlinkSync)(pidPath);
12818
12890
  } catch {
12819
12891
  }
12892
+ try {
12893
+ (0, import_fs4.unlinkSync)(getMetadataPath(session));
12894
+ } catch {
12895
+ }
12820
12896
  }
12821
12897
  function beginShutdown() {
12822
12898
  if (shuttingDown) return;
@@ -12858,10 +12934,15 @@ async function handleRequest(request, socket) {
12858
12934
  sendResponse(socket, {
12859
12935
  id,
12860
12936
  ok: false,
12861
- error: `Session '${session}' is shutting down.`,
12937
+ error: `Session '${logicalSession}' is shutting down.`,
12862
12938
  errorInfo: {
12863
- message: `Session '${session}' is shutting down.`,
12864
- code: "SESSION_SHUTTING_DOWN"
12939
+ message: `Session '${logicalSession}' is shutting down.`,
12940
+ code: "SESSION_SHUTTING_DOWN",
12941
+ details: {
12942
+ session: logicalSession,
12943
+ runtimeSession: session,
12944
+ scopeDir
12945
+ }
12865
12946
  }
12866
12947
  });
12867
12948
  return;
@@ -12870,10 +12951,15 @@ async function handleRequest(request, socket) {
12870
12951
  sendResponse(socket, {
12871
12952
  id,
12872
12953
  ok: false,
12873
- error: `Session '${session}' is shutting down. Retry your command.`,
12954
+ error: `Session '${logicalSession}' is shutting down. Retry your command.`,
12874
12955
  errorInfo: {
12875
- message: `Session '${session}' is shutting down. Retry your command.`,
12876
- code: "SESSION_SHUTTING_DOWN"
12956
+ message: `Session '${logicalSession}' is shutting down. Retry your command.`,
12957
+ code: "SESSION_SHUTTING_DOWN",
12958
+ details: {
12959
+ session: logicalSession,
12960
+ runtimeSession: session,
12961
+ scopeDir
12962
+ }
12877
12963
  }
12878
12964
  });
12879
12965
  return;
@@ -12890,12 +12976,14 @@ async function handleRequest(request, socket) {
12890
12976
  sendResponse(socket, {
12891
12977
  id,
12892
12978
  ok: false,
12893
- error: `Session '${session}' is already bound to selector namespace '${selectorNamespace}'. Requested '${requestedName}' does not match. Use the same --name for this session or start a different --session.`,
12979
+ error: `Session '${logicalSession}' is already bound to selector namespace '${selectorNamespace}'. Requested '${requestedName}' does not match. Use the same --name for this session or start a different --session.`,
12894
12980
  errorInfo: {
12895
- message: `Session '${session}' is already bound to selector namespace '${selectorNamespace}'. Requested '${requestedName}' does not match. Use the same --name for this session or start a different --session.`,
12981
+ message: `Session '${logicalSession}' is already bound to selector namespace '${selectorNamespace}'. Requested '${requestedName}' does not match. Use the same --name for this session or start a different --session.`,
12896
12982
  code: "SESSION_NAMESPACE_MISMATCH",
12897
12983
  details: {
12898
- session,
12984
+ session: logicalSession,
12985
+ runtimeSession: session,
12986
+ scopeDir,
12899
12987
  activeNamespace: selectorNamespace,
12900
12988
  requestedNamespace: requestedName
12901
12989
  }
@@ -12904,9 +12992,9 @@ async function handleRequest(request, socket) {
12904
12992
  return;
12905
12993
  }
12906
12994
  if (!selectorNamespace) {
12907
- selectorNamespace = requestedName ?? session;
12995
+ selectorNamespace = requestedName ?? logicalSession;
12908
12996
  }
12909
- const activeNamespace = selectorNamespace ?? session;
12997
+ const activeNamespace = selectorNamespace ?? logicalSession;
12910
12998
  if (instance && !launchPromise) {
12911
12999
  try {
12912
13000
  if (instance.page.isClosed()) {
@@ -12943,14 +13031,17 @@ async function handleRequest(request, socket) {
12943
13031
  await launchPromise;
12944
13032
  }
12945
13033
  if (url) {
12946
- await instance.page.goto(url);
13034
+ await instance.goto(url);
12947
13035
  }
12948
13036
  sendResponse(socket, {
12949
13037
  id,
12950
13038
  ok: true,
12951
13039
  result: {
12952
13040
  url: instance.page.url(),
12953
- session,
13041
+ session: logicalSession,
13042
+ logicalSession,
13043
+ runtimeSession: session,
13044
+ scopeDir,
12954
13045
  name: activeNamespace,
12955
13046
  cloudSessionId: instance.getCloudSessionId() ?? void 0,
12956
13047
  cloudSessionUrl: instance.getCloudSessionUrl() ?? void 0
@@ -12992,12 +13083,14 @@ async function handleRequest(request, socket) {
12992
13083
  sendResponse(socket, {
12993
13084
  id,
12994
13085
  ok: false,
12995
- error: `No browser session in session '${session}'. Call 'opensteer open --session ${session}' first, or use 'opensteer sessions' to list active sessions.`,
13086
+ error: `No browser session in session '${logicalSession}' (scope '${scopeDir}'). Call 'opensteer open --session ${logicalSession}' first, or use 'opensteer sessions' to list active sessions.`,
12996
13087
  errorInfo: {
12997
- message: `No browser session in session '${session}'. Call 'opensteer open --session ${session}' first, or use 'opensteer sessions' to list active sessions.`,
13088
+ message: `No browser session in session '${logicalSession}' (scope '${scopeDir}'). Call 'opensteer open --session ${logicalSession}' first, or use 'opensteer sessions' to list active sessions.`,
12998
13089
  code: "SESSION_NOT_OPEN",
12999
13090
  details: {
13000
- session
13091
+ session: logicalSession,
13092
+ runtimeSession: session,
13093
+ scopeDir
13001
13094
  }
13002
13095
  }
13003
13096
  });
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  Opensteer,
3
3
  normalizeError
4
- } from "../chunk-SXPIGCSD.js";
4
+ } from "../chunk-XIH3WGPY.js";
5
5
  import "../chunk-3H5RRIMZ.js";
6
6
 
7
7
  // src/cli/server.ts
@@ -20,6 +20,9 @@ function getSocketPath(session2) {
20
20
  function getPidPath(session2) {
21
21
  return join(tmpdir(), `${prefix(session2)}.pid`);
22
22
  }
23
+ function getMetadataPath(session2) {
24
+ return join(tmpdir(), `${prefix(session2)}.meta.json`);
25
+ }
23
26
 
24
27
  // src/cli/commands.ts
25
28
  import { writeFile } from "fs/promises";
@@ -288,6 +291,8 @@ if (!sessionEnv) {
288
291
  process.exit(1);
289
292
  }
290
293
  var session = sessionEnv;
294
+ var logicalSession = process.env.OPENSTEER_LOGICAL_SESSION?.trim() || session;
295
+ var scopeDir = process.env.OPENSTEER_SCOPE_DIR?.trim() || process.cwd();
291
296
  var socketPath = getSocketPath(session);
292
297
  var pidPath = getPidPath(session);
293
298
  function cleanup() {
@@ -299,6 +304,10 @@ function cleanup() {
299
304
  unlinkSync(pidPath);
300
305
  } catch {
301
306
  }
307
+ try {
308
+ unlinkSync(getMetadataPath(session));
309
+ } catch {
310
+ }
302
311
  }
303
312
  function beginShutdown() {
304
313
  if (shuttingDown) return;
@@ -340,10 +349,15 @@ async function handleRequest(request, socket) {
340
349
  sendResponse(socket, {
341
350
  id,
342
351
  ok: false,
343
- error: `Session '${session}' is shutting down.`,
352
+ error: `Session '${logicalSession}' is shutting down.`,
344
353
  errorInfo: {
345
- message: `Session '${session}' is shutting down.`,
346
- code: "SESSION_SHUTTING_DOWN"
354
+ message: `Session '${logicalSession}' is shutting down.`,
355
+ code: "SESSION_SHUTTING_DOWN",
356
+ details: {
357
+ session: logicalSession,
358
+ runtimeSession: session,
359
+ scopeDir
360
+ }
347
361
  }
348
362
  });
349
363
  return;
@@ -352,10 +366,15 @@ async function handleRequest(request, socket) {
352
366
  sendResponse(socket, {
353
367
  id,
354
368
  ok: false,
355
- error: `Session '${session}' is shutting down. Retry your command.`,
369
+ error: `Session '${logicalSession}' is shutting down. Retry your command.`,
356
370
  errorInfo: {
357
- message: `Session '${session}' is shutting down. Retry your command.`,
358
- code: "SESSION_SHUTTING_DOWN"
371
+ message: `Session '${logicalSession}' is shutting down. Retry your command.`,
372
+ code: "SESSION_SHUTTING_DOWN",
373
+ details: {
374
+ session: logicalSession,
375
+ runtimeSession: session,
376
+ scopeDir
377
+ }
359
378
  }
360
379
  });
361
380
  return;
@@ -372,12 +391,14 @@ async function handleRequest(request, socket) {
372
391
  sendResponse(socket, {
373
392
  id,
374
393
  ok: false,
375
- error: `Session '${session}' is already bound to selector namespace '${selectorNamespace}'. Requested '${requestedName}' does not match. Use the same --name for this session or start a different --session.`,
394
+ error: `Session '${logicalSession}' is already bound to selector namespace '${selectorNamespace}'. Requested '${requestedName}' does not match. Use the same --name for this session or start a different --session.`,
376
395
  errorInfo: {
377
- message: `Session '${session}' is already bound to selector namespace '${selectorNamespace}'. Requested '${requestedName}' does not match. Use the same --name for this session or start a different --session.`,
396
+ message: `Session '${logicalSession}' is already bound to selector namespace '${selectorNamespace}'. Requested '${requestedName}' does not match. Use the same --name for this session or start a different --session.`,
378
397
  code: "SESSION_NAMESPACE_MISMATCH",
379
398
  details: {
380
- session,
399
+ session: logicalSession,
400
+ runtimeSession: session,
401
+ scopeDir,
381
402
  activeNamespace: selectorNamespace,
382
403
  requestedNamespace: requestedName
383
404
  }
@@ -386,9 +407,9 @@ async function handleRequest(request, socket) {
386
407
  return;
387
408
  }
388
409
  if (!selectorNamespace) {
389
- selectorNamespace = requestedName ?? session;
410
+ selectorNamespace = requestedName ?? logicalSession;
390
411
  }
391
- const activeNamespace = selectorNamespace ?? session;
412
+ const activeNamespace = selectorNamespace ?? logicalSession;
392
413
  if (instance && !launchPromise) {
393
414
  try {
394
415
  if (instance.page.isClosed()) {
@@ -425,14 +446,17 @@ async function handleRequest(request, socket) {
425
446
  await launchPromise;
426
447
  }
427
448
  if (url) {
428
- await instance.page.goto(url);
449
+ await instance.goto(url);
429
450
  }
430
451
  sendResponse(socket, {
431
452
  id,
432
453
  ok: true,
433
454
  result: {
434
455
  url: instance.page.url(),
435
- session,
456
+ session: logicalSession,
457
+ logicalSession,
458
+ runtimeSession: session,
459
+ scopeDir,
436
460
  name: activeNamespace,
437
461
  cloudSessionId: instance.getCloudSessionId() ?? void 0,
438
462
  cloudSessionUrl: instance.getCloudSessionUrl() ?? void 0
@@ -474,12 +498,14 @@ async function handleRequest(request, socket) {
474
498
  sendResponse(socket, {
475
499
  id,
476
500
  ok: false,
477
- error: `No browser session in session '${session}'. Call 'opensteer open --session ${session}' first, or use 'opensteer sessions' to list active sessions.`,
501
+ error: `No browser session in session '${logicalSession}' (scope '${scopeDir}'). Call 'opensteer open --session ${logicalSession}' first, or use 'opensteer sessions' to list active sessions.`,
478
502
  errorInfo: {
479
- message: `No browser session in session '${session}'. Call 'opensteer open --session ${session}' first, or use 'opensteer sessions' to list active sessions.`,
503
+ message: `No browser session in session '${logicalSession}' (scope '${scopeDir}'). Call 'opensteer open --session ${logicalSession}' first, or use 'opensteer sessions' to list active sessions.`,
480
504
  code: "SESSION_NOT_OPEN",
481
505
  details: {
482
- session
506
+ session: logicalSession,
507
+ runtimeSession: session,
508
+ scopeDir
483
509
  }
484
510
  }
485
511
  });
package/dist/index.cjs CHANGED
@@ -4303,8 +4303,56 @@ function applyCleaner(mode, html) {
4303
4303
  return cleanForAction(html);
4304
4304
  }
4305
4305
  }
4306
+ function canonicalizeDuplicateNodeIds($) {
4307
+ const occurrencesByNodeId = /* @__PURE__ */ new Map();
4308
+ let order = 0;
4309
+ $("*").each(function() {
4310
+ const element = this;
4311
+ const nodeId = $(element).attr(OS_NODE_ID_ATTR);
4312
+ if (!nodeId) {
4313
+ order += 1;
4314
+ return;
4315
+ }
4316
+ const list = occurrencesByNodeId.get(nodeId) || [];
4317
+ list.push({
4318
+ element,
4319
+ order
4320
+ });
4321
+ occurrencesByNodeId.set(nodeId, list);
4322
+ order += 1;
4323
+ });
4324
+ for (const occurrences of occurrencesByNodeId.values()) {
4325
+ if (occurrences.length <= 1) continue;
4326
+ const canonical = pickCanonicalNodeIdOccurrence($, occurrences);
4327
+ for (const occurrence of occurrences) {
4328
+ if (occurrence.element === canonical.element) continue;
4329
+ $(occurrence.element).removeAttr(OS_NODE_ID_ATTR);
4330
+ }
4331
+ }
4332
+ }
4333
+ function pickCanonicalNodeIdOccurrence($, occurrences) {
4334
+ let best = occurrences[0];
4335
+ let bestScore = scoreNodeIdOccurrence($, best.element);
4336
+ for (let i = 1; i < occurrences.length; i += 1) {
4337
+ const candidate = occurrences[i];
4338
+ const candidateScore = scoreNodeIdOccurrence($, candidate.element);
4339
+ if (candidateScore > bestScore || candidateScore === bestScore && candidate.order < best.order) {
4340
+ best = candidate;
4341
+ bestScore = candidateScore;
4342
+ }
4343
+ }
4344
+ return best;
4345
+ }
4346
+ function scoreNodeIdOccurrence($, element) {
4347
+ const el = $(element);
4348
+ const descendantCount = el.find("*").length;
4349
+ const normalizedTextLength = el.text().replace(/\s+/g, " ").trim().length;
4350
+ const attributeCount = Object.keys(el.attr() || {}).length;
4351
+ return descendantCount * 100 + normalizedTextLength * 10 + attributeCount;
4352
+ }
4306
4353
  async function assignCounters(page, html, nodePaths, nodeMeta) {
4307
4354
  const $ = cheerio3.load(html, { xmlMode: false });
4355
+ canonicalizeDuplicateNodeIds($);
4308
4356
  const counterIndex = /* @__PURE__ */ new Map();
4309
4357
  let nextCounter = 1;
4310
4358
  const assignedByNodeId = /* @__PURE__ */ new Map();
@@ -4486,44 +4534,63 @@ function stripNodeIds(html) {
4486
4534
  $(`[${OS_NODE_ID_ATTR}]`).removeAttr(OS_NODE_ID_ATTR);
4487
4535
  return $.html();
4488
4536
  }
4537
+ function isLiveCounterSyncFailure(error) {
4538
+ if (!(error instanceof Error)) return false;
4539
+ return error.message.startsWith(
4540
+ "Failed to synchronize snapshot counters with the live DOM:"
4541
+ );
4542
+ }
4489
4543
  async function prepareSnapshot(page, options = {}) {
4490
4544
  const mode = options.mode ?? "action";
4491
4545
  const withCounters = options.withCounters ?? true;
4492
4546
  const shouldMarkInteractive = options.markInteractive ?? true;
4493
- if (shouldMarkInteractive) {
4494
- await markInteractiveElements(page);
4495
- }
4496
- const serialized = await serializePageHTML(page);
4497
- const rawHtml = serialized.html;
4498
- const processedHtml = rawHtml;
4499
- const reducedHtml = applyCleaner(mode, processedHtml);
4500
- let cleanedHtml = reducedHtml;
4501
- let counterIndex = null;
4502
- if (withCounters) {
4503
- const counted = await assignCounters(
4504
- page,
4547
+ const maxAttempts = withCounters ? 4 : 1;
4548
+ let lastCounterSyncError = null;
4549
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
4550
+ if (shouldMarkInteractive) {
4551
+ await markInteractiveElements(page);
4552
+ }
4553
+ const serialized = await serializePageHTML(page);
4554
+ const rawHtml = serialized.html;
4555
+ const processedHtml = rawHtml;
4556
+ const reducedHtml = applyCleaner(mode, processedHtml);
4557
+ let cleanedHtml = reducedHtml;
4558
+ let counterIndex = null;
4559
+ if (withCounters) {
4560
+ try {
4561
+ const counted = await assignCounters(
4562
+ page,
4563
+ reducedHtml,
4564
+ serialized.nodePaths,
4565
+ serialized.nodeMeta
4566
+ );
4567
+ cleanedHtml = counted.html;
4568
+ counterIndex = counted.counterIndex;
4569
+ } catch (error) {
4570
+ if (attempt < maxAttempts && isLiveCounterSyncFailure(error)) {
4571
+ lastCounterSyncError = error;
4572
+ continue;
4573
+ }
4574
+ throw error;
4575
+ }
4576
+ } else {
4577
+ cleanedHtml = stripNodeIds(cleanedHtml);
4578
+ }
4579
+ if (mode === "extraction") {
4580
+ const $unwrap = cheerio3.load(cleanedHtml, { xmlMode: false });
4581
+ cleanedHtml = $unwrap("body").html()?.trim() || cleanedHtml;
4582
+ }
4583
+ return {
4584
+ mode,
4585
+ url: page.url(),
4586
+ rawHtml,
4587
+ processedHtml,
4505
4588
  reducedHtml,
4506
- serialized.nodePaths,
4507
- serialized.nodeMeta
4508
- );
4509
- cleanedHtml = counted.html;
4510
- counterIndex = counted.counterIndex;
4511
- } else {
4512
- cleanedHtml = stripNodeIds(cleanedHtml);
4513
- }
4514
- if (mode === "extraction") {
4515
- const $unwrap = cheerio3.load(cleanedHtml, { xmlMode: false });
4516
- cleanedHtml = $unwrap("body").html()?.trim() || cleanedHtml;
4589
+ cleanedHtml,
4590
+ counterIndex
4591
+ };
4517
4592
  }
4518
- return {
4519
- mode,
4520
- url: page.url(),
4521
- rawHtml,
4522
- processedHtml,
4523
- reducedHtml,
4524
- cleanedHtml,
4525
- counterIndex
4526
- };
4593
+ throw lastCounterSyncError || new Error("Failed to prepare snapshot after retrying counter sync.");
4527
4594
  }
4528
4595
 
4529
4596
  // src/element-path/errors.ts
package/dist/index.js CHANGED
@@ -77,7 +77,7 @@ import {
77
77
  switchTab,
78
78
  typeText,
79
79
  waitForVisualStability
80
- } from "./chunk-SXPIGCSD.js";
80
+ } from "./chunk-XIH3WGPY.js";
81
81
  import {
82
82
  createResolveCallback
83
83
  } from "./chunk-SPHS6YWD.js";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opensteer",
3
- "version": "0.5.3",
4
- "description": "Open-source browser automation SDK with robust selectors and deterministic replay.",
3
+ "version": "0.5.5",
4
+ "description": "Open-source browser automation SDK and CLI that lets AI agents build complex scrapers directly in your codebase.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {