opensteer 0.4.4 → 0.4.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
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Breaking: CLI runtime routing now uses `--session`/`OPENSTEER_SESSION` instead
6
+ of `--name`/cwd/active-session fallback.
7
+ - Breaking: non-interactive CLI calls now require explicit runtime identity via
8
+ `--session`, `OPENSTEER_SESSION`, or `OPENSTEER_CLIENT_ID`.
9
+ - Added `OPENSTEER_CLIENT_ID` support for stable client-scoped default session
10
+ binding.
11
+ - CLI `--name` is now selector-cache namespace only and no longer controls
12
+ daemon/browser routing.
13
+ - Added per-session daemon startup locking + stale-lock recovery and ping-based
14
+ health checks to remove startup races across concurrent commands.
15
+ - Added strict in-daemon request serialization for session commands, while
16
+ keeping `ping` out of the queue for reliable liveness checks.
5
17
  - Breaking: removed legacy `ai` config from `OpensteerConfig`; use top-level `model` instead.
6
18
  - Breaking: `OPENSTEER_AI_MODEL` is no longer supported; use `OPENSTEER_MODEL`.
7
19
  - Breaking: `OPENSTEER_RUNTIME` is no longer supported; use `OPENSTEER_MODE`.
package/README.md CHANGED
@@ -20,6 +20,31 @@ npm install opensteer playwright
20
20
  pnpm add opensteer playwright
21
21
  ```
22
22
 
23
+ ## CLI Session Routing
24
+
25
+ OpenSteer CLI now separates runtime routing from selector caching:
26
+
27
+ - Runtime routing: `--session` or `OPENSTEER_SESSION`
28
+ - Selector cache namespace: `--name` or `OPENSTEER_NAME` (used on `open`)
29
+
30
+ If neither `--session` nor `OPENSTEER_SESSION` is set:
31
+
32
+ - In an interactive terminal, OpenSteer creates/reuses a terminal-scoped default session.
33
+ - In non-interactive environments (agents/CI), it fails fast unless you set
34
+ `OPENSTEER_SESSION` or `OPENSTEER_CLIENT_ID`.
35
+
36
+ Example:
37
+
38
+ ```bash
39
+ export OPENSTEER_SESSION=agent-a
40
+ opensteer open https://example.com --name product-scraper
41
+ opensteer snapshot
42
+ opensteer click 3
43
+ opensteer status
44
+ ```
45
+
46
+ `opensteer status` reports `resolvedSession`, `sessionSource`, `resolvedName`, and `nameSource`.
47
+
23
48
  ## Quickstart
24
49
 
25
50
  ```ts
package/bin/opensteer.mjs CHANGED
@@ -2,10 +2,18 @@
2
2
 
3
3
  import { createHash } from 'crypto'
4
4
  import { spawn } from 'child_process'
5
- import { existsSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs'
5
+ import {
6
+ closeSync,
7
+ existsSync,
8
+ openSync,
9
+ readFileSync,
10
+ readdirSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ } from 'fs'
6
14
  import { connect } from 'net'
7
15
  import { tmpdir } from 'os'
8
- import { basename, dirname, join } from 'path'
16
+ import { dirname, join } from 'path'
9
17
  import { fileURLToPath } from 'url'
10
18
 
11
19
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -14,10 +22,16 @@ const SERVER_SCRIPT = join(__dirname, '..', 'dist', 'cli', 'server.js')
14
22
  const CONNECT_TIMEOUT = 15000
15
23
  const POLL_INTERVAL = 100
16
24
  const RESPONSE_TIMEOUT = 120000
25
+ const HEALTH_TIMEOUT = 1500
17
26
  const RUNTIME_PREFIX = 'opensteer-'
18
27
  const SOCKET_SUFFIX = '.sock'
19
28
  const PID_SUFFIX = '.pid'
29
+ const LOCK_SUFFIX = '.lock'
30
+ const CLIENT_BINDING_PREFIX = `${RUNTIME_PREFIX}client-`
31
+ const CLIENT_BINDING_SUFFIX = '.session'
20
32
  const CLOSE_ALL_REQUEST = { id: 1, command: 'close', args: {} }
33
+ const PING_REQUEST = { id: 1, command: 'ping', args: {} }
34
+ const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/
21
35
 
22
36
  function getVersion() {
23
37
  try {
@@ -84,59 +98,162 @@ function sanitizeNamespace(value) {
84
98
  return bounded || 'default'
85
99
  }
86
100
 
87
- function getActiveNamespacePath() {
88
- const hash = createHash('md5').update(process.cwd()).digest('hex').slice(0, 16)
89
- return join(tmpdir(), `${RUNTIME_PREFIX}active-${hash}`)
101
+ function isValidSessionId(value) {
102
+ return SESSION_ID_PATTERN.test(value)
90
103
  }
91
104
 
92
- function readActiveNamespace() {
105
+ function validateSessionId(rawValue, label) {
106
+ const value = String(rawValue ?? '').trim()
107
+ if (!value) {
108
+ throw new Error(`${label} cannot be empty.`)
109
+ }
110
+ if (!isValidSessionId(value)) {
111
+ throw new Error(
112
+ `${label} "${value}" is invalid. Use only letters, numbers, underscores, and hyphens.`
113
+ )
114
+ }
115
+ return value
116
+ }
117
+
118
+ function resolveName(flags, session) {
119
+ if (flags.name !== undefined) {
120
+ if (flags.name === true) {
121
+ throw new Error('--name requires a namespace value.')
122
+ }
123
+ const raw = String(flags.name).trim()
124
+ if (raw.length > 0) {
125
+ return { name: sanitizeNamespace(raw), source: 'flag' }
126
+ }
127
+ }
128
+
129
+ if (
130
+ typeof process.env.OPENSTEER_NAME === 'string' &&
131
+ process.env.OPENSTEER_NAME.trim().length > 0
132
+ ) {
133
+ return {
134
+ name: sanitizeNamespace(process.env.OPENSTEER_NAME),
135
+ source: 'env',
136
+ }
137
+ }
138
+
139
+ return { name: sanitizeNamespace(session), source: 'session' }
140
+ }
141
+
142
+ function hashKey(value) {
143
+ return createHash('sha256').update(value).digest('hex')
144
+ }
145
+
146
+ function getClientBindingPath(clientKey) {
147
+ return join(
148
+ tmpdir(),
149
+ `${CLIENT_BINDING_PREFIX}${hashKey(clientKey).slice(0, 24)}${CLIENT_BINDING_SUFFIX}`
150
+ )
151
+ }
152
+
153
+ function readClientBinding(clientKey) {
154
+ const bindingPath = getClientBindingPath(clientKey)
155
+ if (!existsSync(bindingPath)) {
156
+ return null
157
+ }
158
+
93
159
  try {
94
- const filePath = getActiveNamespacePath()
95
- if (!existsSync(filePath)) return null
96
- const ns = readFileSync(filePath, 'utf-8').trim()
97
- return ns || null
160
+ const rawSession = readFileSync(bindingPath, 'utf-8').trim()
161
+ if (!rawSession) {
162
+ unlinkSync(bindingPath)
163
+ return null
164
+ }
165
+ if (!isValidSessionId(rawSession)) {
166
+ unlinkSync(bindingPath)
167
+ return null
168
+ }
169
+ return rawSession
98
170
  } catch {
99
171
  return null
100
172
  }
101
173
  }
102
174
 
103
- function writeActiveNamespace(namespace) {
175
+ function writeClientBinding(clientKey, session) {
104
176
  try {
105
- writeFileSync(getActiveNamespacePath(), namespace)
177
+ writeFileSync(getClientBindingPath(clientKey), session)
106
178
  } catch { /* best-effort */ }
107
179
  }
108
180
 
109
- function resolveNamespace(flags) {
110
- if (flags.name !== undefined && String(flags.name).trim().length > 0) {
111
- return { namespace: sanitizeNamespace(String(flags.name)), source: 'flag' }
181
+ function createDefaultSessionId(prefix, clientKey) {
182
+ return `${prefix}-${hashKey(clientKey).slice(0, 12)}`
183
+ }
184
+
185
+ function isInteractiveTerminal() {
186
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY)
187
+ }
188
+
189
+ function resolveSession(flags) {
190
+ if (flags.session !== undefined) {
191
+ if (flags.session === true) {
192
+ throw new Error('--session requires a session id value.')
193
+ }
194
+
195
+ return {
196
+ session: validateSessionId(flags.session, 'Session id'),
197
+ source: 'flag',
198
+ }
112
199
  }
113
200
 
114
201
  if (
115
- typeof process.env.OPENSTEER_NAME === 'string' &&
116
- process.env.OPENSTEER_NAME.trim().length > 0
202
+ typeof process.env.OPENSTEER_SESSION === 'string' &&
203
+ process.env.OPENSTEER_SESSION.trim().length > 0
117
204
  ) {
118
- return { namespace: sanitizeNamespace(process.env.OPENSTEER_NAME), source: 'env' }
205
+ return {
206
+ session: validateSessionId(
207
+ process.env.OPENSTEER_SESSION,
208
+ 'OPENSTEER_SESSION'
209
+ ),
210
+ source: 'env',
211
+ }
119
212
  }
120
213
 
121
- const active = readActiveNamespace()
122
- if (active && isServerRunning(active)) {
123
- return { namespace: active, source: 'active' }
214
+ if (
215
+ typeof process.env.OPENSTEER_CLIENT_ID === 'string' &&
216
+ process.env.OPENSTEER_CLIENT_ID.trim().length > 0
217
+ ) {
218
+ const clientId = process.env.OPENSTEER_CLIENT_ID.trim()
219
+ const clientKey = `client:${process.cwd()}:${clientId}`
220
+ const bound = readClientBinding(clientKey)
221
+ if (bound) {
222
+ return { session: bound, source: 'client_binding' }
223
+ }
224
+
225
+ const created = createDefaultSessionId('client', clientKey)
226
+ writeClientBinding(clientKey, created)
227
+ return { session: created, source: 'client_binding' }
124
228
  }
125
229
 
126
- const cwdBase = basename(process.cwd())
127
- if (cwdBase && cwdBase !== '.' && cwdBase !== '/') {
128
- return { namespace: sanitizeNamespace(cwdBase), source: 'cwd' }
230
+ if (isInteractiveTerminal()) {
231
+ const ttyKey = `tty:${process.cwd()}:${process.ppid}`
232
+ const bound = readClientBinding(ttyKey)
233
+ if (bound) {
234
+ return { session: bound, source: 'tty_default' }
235
+ }
236
+
237
+ const created = createDefaultSessionId('tty', ttyKey)
238
+ writeClientBinding(ttyKey, created)
239
+ return { session: created, source: 'tty_default' }
129
240
  }
130
241
 
131
- return { namespace: 'default', source: 'default' }
242
+ throw new Error(
243
+ 'No session resolved for this non-interactive command. Set OPENSTEER_SESSION or OPENSTEER_CLIENT_ID, or pass --session <id>.'
244
+ )
132
245
  }
133
246
 
134
- function getSocketPath(namespace) {
135
- return join(tmpdir(), `${RUNTIME_PREFIX}${namespace}${SOCKET_SUFFIX}`)
247
+ function getSocketPath(session) {
248
+ return join(tmpdir(), `${RUNTIME_PREFIX}${session}${SOCKET_SUFFIX}`)
136
249
  }
137
250
 
138
- function getPidPath(namespace) {
139
- return join(tmpdir(), `${RUNTIME_PREFIX}${namespace}${PID_SUFFIX}`)
251
+ function getPidPath(session) {
252
+ return join(tmpdir(), `${RUNTIME_PREFIX}${session}${PID_SUFFIX}`)
253
+ }
254
+
255
+ function getLockPath(session) {
256
+ return join(tmpdir(), `${RUNTIME_PREFIX}${session}${LOCK_SUFFIX}`)
140
257
  }
141
258
 
142
259
  function buildRequest(command, flags, positional) {
@@ -270,66 +387,36 @@ function isPidAlive(pid) {
270
387
  }
271
388
  }
272
389
 
273
- function cleanStaleFiles(namespace) {
274
- try {
275
- unlinkSync(getSocketPath(namespace))
276
- } catch { }
277
- try {
278
- unlinkSync(getPidPath(namespace))
279
- } catch { }
280
- }
390
+ function cleanStaleFiles(session, options = {}) {
391
+ const removeSocket = options.removeSocket !== false
392
+ const removePid = options.removePid !== false
281
393
 
282
- function isServerRunning(namespace) {
283
- const pidPath = getPidPath(namespace)
284
- const pid = readPid(pidPath)
285
- if (!pid) {
286
- cleanStaleFiles(namespace)
287
- return false
394
+ if (removeSocket) {
395
+ try {
396
+ unlinkSync(getSocketPath(session))
397
+ } catch { }
288
398
  }
289
399
 
290
- if (!isPidAlive(pid)) {
291
- cleanStaleFiles(namespace)
292
- return false
400
+ if (removePid) {
401
+ try {
402
+ unlinkSync(getPidPath(session))
403
+ } catch { }
293
404
  }
294
-
295
- return existsSync(getSocketPath(namespace))
296
405
  }
297
406
 
298
- function startServer(namespace) {
407
+ function startServer(session) {
299
408
  const child = spawn('node', [SERVER_SCRIPT], {
300
409
  detached: true,
301
410
  stdio: ['ignore', 'ignore', 'ignore'],
302
411
  env: {
303
412
  ...process.env,
304
- OPENSTEER_NAME: namespace,
413
+ OPENSTEER_SESSION: session,
305
414
  },
306
415
  })
307
416
  child.unref()
308
417
  }
309
418
 
310
- function waitForSocket(socketPath, timeout) {
311
- return new Promise((resolve, reject) => {
312
- const start = Date.now()
313
-
314
- function poll() {
315
- if (Date.now() - start > timeout) {
316
- reject(new Error('Timed out waiting for server to start'))
317
- return
318
- }
319
-
320
- if (existsSync(socketPath)) {
321
- resolve()
322
- return
323
- }
324
-
325
- setTimeout(poll, POLL_INTERVAL)
326
- }
327
-
328
- poll()
329
- })
330
- }
331
-
332
- function sendCommand(socketPath, request) {
419
+ function sendCommand(socketPath, request, timeoutMs = RESPONSE_TIMEOUT) {
333
420
  return new Promise((resolve, reject) => {
334
421
  const socket = connect(socketPath)
335
422
  let buffer = ''
@@ -341,7 +428,7 @@ function sendCommand(socketPath, request) {
341
428
  socket.destroy()
342
429
  reject(new Error('Response timeout'))
343
430
  }
344
- }, RESPONSE_TIMEOUT)
431
+ }, timeoutMs)
345
432
 
346
433
  socket.on('connect', () => {
347
434
  socket.write(JSON.stringify(request) + '\n')
@@ -381,6 +468,169 @@ function sendCommand(socketPath, request) {
381
468
  })
382
469
  }
383
470
 
471
+ async function pingServer(session) {
472
+ const socketPath = getSocketPath(session)
473
+ if (!existsSync(socketPath)) return false
474
+
475
+ try {
476
+ const response = await sendCommand(socketPath, PING_REQUEST, HEALTH_TIMEOUT)
477
+ return Boolean(response?.ok && response?.result?.pong)
478
+ } catch {
479
+ return false
480
+ }
481
+ }
482
+
483
+ async function isServerHealthy(session) {
484
+ const pid = readPid(getPidPath(session))
485
+ const pidAlive = pid ? isPidAlive(pid) : false
486
+ const socketExists = existsSync(getSocketPath(session))
487
+
488
+ if (pid && !pidAlive) {
489
+ cleanStaleFiles(session)
490
+ return false
491
+ }
492
+
493
+ if (!socketExists) {
494
+ return false
495
+ }
496
+
497
+ const healthy = await pingServer(session)
498
+ if (healthy) {
499
+ return true
500
+ }
501
+
502
+ // If there is no pid and ping fails, the socket file is stale.
503
+ if (!pid) {
504
+ cleanStaleFiles(session, { removeSocket: true, removePid: false })
505
+ }
506
+
507
+ return false
508
+ }
509
+
510
+ function acquireStartLock(session) {
511
+ const lockPath = getLockPath(session)
512
+
513
+ try {
514
+ const fd = openSync(lockPath, 'wx')
515
+ writeFileSync(
516
+ fd,
517
+ JSON.stringify({
518
+ pid: process.pid,
519
+ createdAt: Date.now(),
520
+ })
521
+ )
522
+ closeSync(fd)
523
+ return true
524
+ } catch {
525
+ return false
526
+ }
527
+ }
528
+
529
+ function releaseStartLock(session) {
530
+ try {
531
+ unlinkSync(getLockPath(session))
532
+ } catch { /* best-effort */ }
533
+ }
534
+
535
+ function recoverStaleStartLock(session) {
536
+ const lockPath = getLockPath(session)
537
+ if (!existsSync(lockPath)) {
538
+ return false
539
+ }
540
+
541
+ try {
542
+ const raw = readFileSync(lockPath, 'utf-8')
543
+ const parsed = JSON.parse(raw)
544
+ const pid =
545
+ parsed && Number.isInteger(parsed.pid) && parsed.pid > 0
546
+ ? parsed.pid
547
+ : null
548
+
549
+ if (!pid || !isPidAlive(pid)) {
550
+ unlinkSync(lockPath)
551
+ return true
552
+ }
553
+
554
+ return false
555
+ } catch {
556
+ try {
557
+ unlinkSync(lockPath)
558
+ return true
559
+ } catch {
560
+ return false
561
+ }
562
+ }
563
+ }
564
+
565
+ async function sleep(ms) {
566
+ await new Promise((resolve) => setTimeout(resolve, ms))
567
+ }
568
+
569
+ async function waitForServerReady(session, timeout) {
570
+ const start = Date.now()
571
+
572
+ while (Date.now() - start <= timeout) {
573
+ if (await isServerHealthy(session)) {
574
+ return
575
+ }
576
+ await sleep(POLL_INTERVAL)
577
+ }
578
+
579
+ throw new Error(`Timed out waiting for server '${session}' to become healthy.`)
580
+ }
581
+
582
+ async function ensureServer(session) {
583
+ if (await isServerHealthy(session)) {
584
+ return
585
+ }
586
+
587
+ if (!existsSync(SERVER_SCRIPT)) {
588
+ throw new Error(
589
+ `Server script not found: ${SERVER_SCRIPT}. Run the build script first.`
590
+ )
591
+ }
592
+
593
+ const deadline = Date.now() + CONNECT_TIMEOUT
594
+
595
+ while (Date.now() < deadline) {
596
+ if (await isServerHealthy(session)) {
597
+ return
598
+ }
599
+
600
+ const existingPid = readPid(getPidPath(session))
601
+ if (existingPid && isPidAlive(existingPid)) {
602
+ // A daemon process already owns this session. It may still be
603
+ // starting up or shutting down; avoid spawning a competing daemon.
604
+ await sleep(POLL_INTERVAL)
605
+ continue
606
+ }
607
+
608
+ recoverStaleStartLock(session)
609
+
610
+ if (acquireStartLock(session)) {
611
+ try {
612
+ if (!(await isServerHealthy(session))) {
613
+ startServer(session)
614
+ }
615
+
616
+ await waitForServerReady(
617
+ session,
618
+ Math.max(500, deadline - Date.now())
619
+ )
620
+ return
621
+ } finally {
622
+ releaseStartLock(session)
623
+ }
624
+ }
625
+
626
+ await sleep(POLL_INTERVAL)
627
+ }
628
+
629
+ throw new Error(
630
+ `Failed to start server for session '${session}'. Check that the build is complete.`
631
+ )
632
+ }
633
+
384
634
  function listSessions() {
385
635
  const sessions = []
386
636
  const entries = readdirSync(tmpdir())
@@ -465,11 +715,11 @@ Navigation:
465
715
  forward Go forward
466
716
  reload Reload page
467
717
  close Close browser and server
468
- close --all Close all active namespace-scoped servers
718
+ close --all Close all active session-scoped servers
469
719
 
470
720
  Sessions:
471
- sessions List active namespace-scoped sessions
472
- status Show resolved namespace and session state
721
+ sessions List active session-scoped daemons
722
+ status Show resolved session/name and session state
473
723
 
474
724
  Observation:
475
725
  snapshot [--mode action] Get page snapshot
@@ -515,7 +765,8 @@ Utility:
515
765
  extract <schema-json> Extract structured data
516
766
 
517
767
  Global Flags:
518
- --name <namespace> Session namespace (default: CWD basename or OPENSTEER_NAME)
768
+ --session <id> Runtime session id for daemon/browser routing
769
+ --name <namespace> Selector namespace for cache storage on 'open'
519
770
  --headless Launch browser in headless mode
520
771
  --connect-url <url> Connect to a running browser (e.g. http://localhost:9222)
521
772
  --channel <browser> Use installed browser (chrome, chrome-beta, msedge)
@@ -527,7 +778,9 @@ Global Flags:
527
778
  --version, -v Show version
528
779
 
529
780
  Environment:
530
- OPENSTEER_NAME Default session namespace when --name is omitted
781
+ OPENSTEER_SESSION Runtime session id (equivalent to --session)
782
+ OPENSTEER_CLIENT_ID Stable client identity for default session binding
783
+ OPENSTEER_NAME Default selector namespace for 'open' when --name is omitted
531
784
  OPENSTEER_MODE Runtime mode: "local" (default) or "remote"
532
785
  OPENSTEER_API_KEY Required when remote mode is selected
533
786
  OPENSTEER_BASE_URL Override remote control-plane base URL
@@ -536,26 +789,12 @@ Environment:
536
789
 
537
790
  async function main() {
538
791
  const { command, flags, positional } = parseArgs(process.argv)
539
- const { namespace, source: namespaceSource } = resolveNamespace(flags)
540
- const socketPath = getSocketPath(namespace)
541
792
 
542
793
  if (command === 'sessions') {
543
794
  output({ ok: true, sessions: listSessions() })
544
795
  return
545
796
  }
546
797
 
547
- if (command === 'status') {
548
- output({
549
- ok: true,
550
- namespace,
551
- namespaceSource,
552
- serverRunning: isServerRunning(namespace),
553
- socketPath,
554
- sessions: listSessions(),
555
- })
556
- return
557
- }
558
-
559
798
  if (command === 'close' && flags.all === true) {
560
799
  try {
561
800
  const closed = await closeAllSessions()
@@ -566,36 +805,66 @@ async function main() {
566
805
  return
567
806
  }
568
807
 
808
+ let resolvedSession
809
+ let resolvedName
810
+ try {
811
+ resolvedSession = resolveSession(flags)
812
+ resolvedName = resolveName(flags, resolvedSession.session)
813
+ } catch (err) {
814
+ error(err instanceof Error ? err.message : 'Failed to resolve session')
815
+ }
816
+
817
+ const session = resolvedSession.session
818
+ const sessionSource = resolvedSession.source
819
+ const name = resolvedName.name
820
+ const nameSource = resolvedName.source
821
+ const socketPath = getSocketPath(session)
822
+
823
+ if (command === 'status') {
824
+ output({
825
+ ok: true,
826
+ resolvedSession: session,
827
+ sessionSource,
828
+ resolvedName: name,
829
+ nameSource,
830
+ serverRunning: await isServerHealthy(session),
831
+ socketPath,
832
+ sessions: listSessions(),
833
+ })
834
+ return
835
+ }
836
+
569
837
  delete flags.name
838
+ delete flags.session
570
839
  delete flags.all
840
+
571
841
  const request = buildRequest(command, flags, positional)
842
+ if (command === 'open') {
843
+ request.args.name = name
844
+ }
572
845
 
573
- if (!isServerRunning(namespace)) {
846
+ if (!(await isServerHealthy(session))) {
574
847
  if (command !== 'open') {
575
848
  error(
576
- `No server running for namespace '${namespace}' (resolved from ${namespaceSource}). Run 'opensteer open' first or use 'opensteer sessions' to see active sessions.`
849
+ `No server running for session '${session}' (resolved from ${sessionSource}). Run 'opensteer open' first or use 'opensteer sessions' to see active sessions.`
577
850
  )
578
851
  }
579
- if (!existsSync(SERVER_SCRIPT)) {
852
+
853
+ try {
854
+ await ensureServer(session)
855
+ } catch (err) {
580
856
  error(
581
- `Server script not found: ${SERVER_SCRIPT}. Run the build script first.`
857
+ err instanceof Error
858
+ ? err.message
859
+ : 'Failed to start server. Check that the build is complete.'
582
860
  )
583
861
  }
584
- startServer(namespace)
585
- try {
586
- await waitForSocket(socketPath, CONNECT_TIMEOUT)
587
- } catch {
588
- error('Failed to start server. Check that the build is complete.')
589
- }
590
862
  }
591
863
 
592
864
  try {
593
865
  const response = await sendCommand(socketPath, request)
594
866
 
595
867
  if (response.ok) {
596
- if (command === 'open') {
597
- writeActiveNamespace(namespace)
598
- }
599
868
  output({ ok: true, ...response.result })
600
869
  } else {
601
870
  process.stderr.write(
@@ -817,15 +817,15 @@ function normalizeNamespace(input) {
817
817
  if (!segments.length) return DEFAULT_NAMESPACE;
818
818
  return segments.join("/");
819
819
  }
820
- function resolveNamespaceDir(rootDir, namespace2) {
820
+ function resolveNamespaceDir(rootDir, namespace) {
821
821
  const selectorsRoot = import_path2.default.resolve(rootDir, ".opensteer", "selectors");
822
- const normalizedNamespace = normalizeNamespace(namespace2);
822
+ const normalizedNamespace = normalizeNamespace(namespace);
823
823
  const namespaceDir = import_path2.default.resolve(selectorsRoot, normalizedNamespace);
824
824
  const relative = import_path2.default.relative(selectorsRoot, namespaceDir);
825
825
  if (relative === "" || relative === ".") return namespaceDir;
826
826
  if (relative.startsWith("..") || import_path2.default.isAbsolute(relative)) {
827
827
  throw new Error(
828
- `Namespace "${namespace2}" resolves outside selectors root.`
828
+ `Namespace "${namespace}" resolves outside selectors root.`
829
829
  );
830
830
  }
831
831
  return namespaceDir;
@@ -1275,9 +1275,9 @@ function createEmptyRegistry(name) {
1275
1275
  var LocalSelectorStorage = class {
1276
1276
  rootDir;
1277
1277
  namespace;
1278
- constructor(rootDir, namespace2) {
1278
+ constructor(rootDir, namespace) {
1279
1279
  this.rootDir = rootDir;
1280
- this.namespace = normalizeNamespace(namespace2);
1280
+ this.namespace = normalizeNamespace(namespace);
1281
1281
  }
1282
1282
  getRootDir() {
1283
1283
  return this.rootDir;
@@ -6973,7 +6973,7 @@ function withTokenQuery(wsUrl, token) {
6973
6973
  var import_fs3 = __toESM(require("fs"), 1);
6974
6974
  var import_path5 = __toESM(require("path"), 1);
6975
6975
  function collectLocalSelectorCacheEntries(storage) {
6976
- const namespace2 = storage.getNamespace();
6976
+ const namespace = storage.getNamespace();
6977
6977
  const namespaceDir = storage.getNamespaceDir();
6978
6978
  if (!import_fs3.default.existsSync(namespaceDir)) return [];
6979
6979
  const entries = [];
@@ -6994,7 +6994,7 @@ function collectLocalSelectorCacheEntries(storage) {
6994
6994
  continue;
6995
6995
  }
6996
6996
  entries.push({
6997
- namespace: namespace2,
6997
+ namespace,
6998
6998
  siteOrigin,
6999
6999
  method,
7000
7000
  descriptionHash,
@@ -7430,20 +7430,20 @@ var Opensteer = class _Opensteer {
7430
7430
  );
7431
7431
  }
7432
7432
  }
7433
- const session2 = await this.remote.sessionClient.create({
7433
+ const session3 = await this.remote.sessionClient.create({
7434
7434
  name: this.namespace,
7435
7435
  model: this.config.model,
7436
7436
  launchContext: options.context || void 0
7437
7437
  });
7438
- sessionId = session2.sessionId;
7438
+ sessionId = session3.sessionId;
7439
7439
  actionClient = await ActionWsClient.connect({
7440
- url: session2.actionWsUrl,
7441
- token: session2.actionToken,
7442
- sessionId: session2.sessionId
7440
+ url: session3.actionWsUrl,
7441
+ token: session3.actionToken,
7442
+ sessionId: session3.sessionId
7443
7443
  });
7444
7444
  const cdpConnection = await this.remote.cdpClient.connect({
7445
- wsUrl: session2.cdpWsUrl,
7446
- token: session2.cdpToken
7445
+ wsUrl: session3.cdpWsUrl,
7446
+ token: session3.cdpToken
7447
7447
  });
7448
7448
  browser = cdpConnection.browser;
7449
7449
  this.browser = cdpConnection.browser;
@@ -7467,15 +7467,15 @@ var Opensteer = class _Opensteer {
7467
7467
  throw error;
7468
7468
  }
7469
7469
  }
7470
- const session = await this.pool.launch({
7470
+ const session2 = await this.pool.launch({
7471
7471
  ...options,
7472
7472
  connectUrl: options.connectUrl ?? this.config.browser?.connectUrl,
7473
7473
  channel: options.channel ?? this.config.browser?.channel,
7474
7474
  profileDir: options.profileDir ?? this.config.browser?.profileDir
7475
7475
  });
7476
- this.browser = session.browser;
7477
- this.contextRef = session.context;
7478
- this.pageRef = session.page;
7476
+ this.browser = session2.browser;
7477
+ this.contextRef = session2.context;
7478
+ this.pageRef = session2.page;
7479
7479
  this.ownsBrowser = true;
7480
7480
  this.snapshotCache = null;
7481
7481
  }
@@ -9501,14 +9501,14 @@ function getScrollDelta2(options) {
9501
9501
  // src/cli/paths.ts
9502
9502
  var import_os2 = require("os");
9503
9503
  var import_path6 = require("path");
9504
- function prefix(namespace2) {
9505
- return `opensteer-${namespace2}`;
9504
+ function prefix(session2) {
9505
+ return `opensteer-${session2}`;
9506
9506
  }
9507
- function getSocketPath(namespace2) {
9508
- return (0, import_path6.join)((0, import_os2.tmpdir)(), `${prefix(namespace2)}.sock`);
9507
+ function getSocketPath(session2) {
9508
+ return (0, import_path6.join)((0, import_os2.tmpdir)(), `${prefix(session2)}.sock`);
9509
9509
  }
9510
- function getPidPath(namespace2) {
9511
- return (0, import_path6.join)((0, import_os2.tmpdir)(), `${prefix(namespace2)}.pid`);
9510
+ function getPidPath(session2) {
9511
+ return (0, import_path6.join)((0, import_os2.tmpdir)(), `${prefix(session2)}.pid`);
9512
9512
  }
9513
9513
 
9514
9514
  // src/cli/commands.ts
@@ -9746,6 +9746,19 @@ function getCommandHandler(name) {
9746
9746
  // src/cli/server.ts
9747
9747
  var instance = null;
9748
9748
  var launchPromise = null;
9749
+ var selectorNamespace = null;
9750
+ var requestQueue = Promise.resolve();
9751
+ var shuttingDown = false;
9752
+ function sanitizeNamespace(value) {
9753
+ const trimmed = String(value || "").trim();
9754
+ if (!trimmed || trimmed === "." || trimmed === "..") {
9755
+ return "default";
9756
+ }
9757
+ const replaced = trimmed.replace(/[^a-zA-Z0-9_-]+/g, "_");
9758
+ const collapsed = replaced.replace(/_+/g, "_");
9759
+ const bounded = collapsed.replace(/^_+|_+$/g, "");
9760
+ return bounded || "default";
9761
+ }
9749
9762
  function invalidateInstance() {
9750
9763
  if (!instance) return;
9751
9764
  instance.close().catch(() => {
@@ -9759,13 +9772,14 @@ function attachLifecycleListeners(inst) {
9759
9772
  } catch {
9760
9773
  }
9761
9774
  }
9762
- var namespace = process.env.OPENSTEER_NAME?.trim();
9763
- if (!namespace) {
9764
- process.stderr.write("Missing OPENSTEER_NAME environment variable.\n");
9775
+ var sessionEnv = process.env.OPENSTEER_SESSION?.trim();
9776
+ if (!sessionEnv) {
9777
+ process.stderr.write("Missing OPENSTEER_SESSION environment variable.\n");
9765
9778
  process.exit(1);
9766
9779
  }
9767
- var socketPath = getSocketPath(namespace);
9768
- var pidPath = getPidPath(namespace);
9780
+ var session = sessionEnv;
9781
+ var socketPath = getSocketPath(session);
9782
+ var pidPath = getPidPath(session);
9769
9783
  function cleanup() {
9770
9784
  try {
9771
9785
  (0, import_fs4.unlinkSync)(socketPath);
@@ -9776,14 +9790,49 @@ function cleanup() {
9776
9790
  } catch {
9777
9791
  }
9778
9792
  }
9793
+ function beginShutdown() {
9794
+ if (shuttingDown) return;
9795
+ shuttingDown = true;
9796
+ cleanup();
9797
+ server.close(() => {
9798
+ process.exit(0);
9799
+ });
9800
+ setTimeout(() => {
9801
+ process.exit(0);
9802
+ }, 250).unref();
9803
+ }
9779
9804
  function sendResponse(socket, response) {
9780
9805
  try {
9781
9806
  socket.write(JSON.stringify(response) + "\n");
9782
9807
  } catch {
9783
9808
  }
9784
9809
  }
9810
+ function enqueueRequest(request, socket) {
9811
+ if (request.command === "ping") {
9812
+ void handleRequest(request, socket);
9813
+ return;
9814
+ }
9815
+ requestQueue = requestQueue.then(() => handleRequest(request, socket)).catch(() => {
9816
+ });
9817
+ }
9785
9818
  async function handleRequest(request, socket) {
9786
9819
  const { id, command, args } = request;
9820
+ if (command === "ping" && shuttingDown) {
9821
+ sendResponse(socket, {
9822
+ id,
9823
+ ok: false,
9824
+ error: `Session '${session}' is shutting down.`
9825
+ });
9826
+ return;
9827
+ }
9828
+ if (shuttingDown) {
9829
+ sendResponse(socket, {
9830
+ id,
9831
+ ok: false,
9832
+ error: `Session '${session}' is shutting down. Retry your command.`
9833
+ });
9834
+ return;
9835
+ }
9787
9836
  if (command === "open") {
9788
9837
  try {
9789
9838
  const url = args.url;
@@ -9791,6 +9840,19 @@ async function handleRequest(request, socket) {
9791
9840
  const connectUrl = args["connect-url"];
9792
9841
  const channel = args.channel;
9793
9842
  const profileDir = args["profile-dir"];
9843
+ const requestedName = typeof args.name === "string" && args.name.trim().length > 0 ? sanitizeNamespace(args.name) : null;
9844
+ if (selectorNamespace && requestedName && requestedName !== selectorNamespace) {
9845
+ sendResponse(socket, {
9846
+ id,
9847
+ ok: false,
9848
+ 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.`
9849
+ });
9850
+ return;
9851
+ }
9852
+ if (!selectorNamespace) {
9853
+ selectorNamespace = requestedName ?? session;
9854
+ }
9855
+ const activeNamespace = selectorNamespace ?? session;
9794
9856
  if (instance && !launchPromise) {
9795
9857
  try {
9796
9858
  if (instance.page.isClosed()) {
@@ -9802,7 +9864,7 @@ async function handleRequest(request, socket) {
9802
9864
  }
9803
9865
  if (!instance) {
9804
9866
  instance = new Opensteer({
9805
- name: namespace,
9867
+ name: activeNamespace,
9806
9868
  browser: {
9807
9869
  headless: headless ?? false,
9808
9870
  connectUrl,
@@ -9834,8 +9896,9 @@ async function handleRequest(request, socket) {
9834
9896
  ok: true,
9835
9897
  result: {
9836
9898
  url: instance.page.url(),
9837
- sessionId: instance.getRemoteSessionId() ?? void 0,
9838
- name: namespace
9899
+ session,
9900
+ name: activeNamespace,
9901
+ remoteSessionId: instance.getRemoteSessionId() ?? void 0
9839
9902
  }
9840
9903
  });
9841
9904
  } catch (err) {
@@ -9865,10 +9928,7 @@ async function handleRequest(request, socket) {
9865
9928
  error: err instanceof Error ? err.message : String(err)
9866
9929
  });
9867
9930
  }
9868
- setTimeout(() => {
9869
- cleanup();
9870
- process.exit(0);
9871
- }, 100);
9931
+ beginShutdown();
9872
9932
  return;
9873
9933
  }
9874
9934
  if (command === "ping") {
@@ -9879,7 +9939,7 @@ async function handleRequest(request, socket) {
9879
9939
  sendResponse(socket, {
9880
9940
  id,
9881
9941
  ok: false,
9882
- error: `No browser session in namespace '${namespace}'. Call 'opensteer open --name ${namespace}' first, or use 'opensteer sessions' to list active sessions.`
9942
+ error: `No browser session in session '${session}'. Call 'opensteer open --session ${session}' first, or use 'opensteer sessions' to list active sessions.`
9883
9943
  });
9884
9944
  return;
9885
9945
  }
@@ -9916,7 +9976,7 @@ var server = (0, import_net.createServer)((socket) => {
9916
9976
  if (!line.trim()) continue;
9917
9977
  try {
9918
9978
  const request = JSON.parse(line);
9919
- handleRequest(request, socket);
9979
+ enqueueRequest(request, socket);
9920
9980
  } catch {
9921
9981
  sendResponse(socket, {
9922
9982
  id: 0,
@@ -9941,6 +10001,8 @@ server.on("error", (err) => {
9941
10001
  process.exit(1);
9942
10002
  });
9943
10003
  async function shutdown() {
10004
+ if (shuttingDown) return;
10005
+ shuttingDown = true;
9944
10006
  if (instance) {
9945
10007
  try {
9946
10008
  await instance.close();
@@ -9948,9 +10010,13 @@ async function shutdown() {
9948
10010
  }
9949
10011
  instance = null;
9950
10012
  }
9951
- server.close();
9952
10013
  cleanup();
9953
- process.exit(0);
10014
+ server.close(() => {
10015
+ process.exit(0);
10016
+ });
10017
+ setTimeout(() => {
10018
+ process.exit(0);
10019
+ }, 250).unref();
9954
10020
  }
9955
10021
  process.on("SIGTERM", shutdown);
9956
10022
  process.on("SIGINT", shutdown);
@@ -10,14 +10,14 @@ import { writeFileSync, unlinkSync, existsSync } from "fs";
10
10
  // src/cli/paths.ts
11
11
  import { tmpdir } from "os";
12
12
  import { join } from "path";
13
- function prefix(namespace2) {
14
- return `opensteer-${namespace2}`;
13
+ function prefix(session2) {
14
+ return `opensteer-${session2}`;
15
15
  }
16
- function getSocketPath(namespace2) {
17
- return join(tmpdir(), `${prefix(namespace2)}.sock`);
16
+ function getSocketPath(session2) {
17
+ return join(tmpdir(), `${prefix(session2)}.sock`);
18
18
  }
19
- function getPidPath(namespace2) {
20
- return join(tmpdir(), `${prefix(namespace2)}.pid`);
19
+ function getPidPath(session2) {
20
+ return join(tmpdir(), `${prefix(session2)}.pid`);
21
21
  }
22
22
 
23
23
  // src/cli/commands.ts
@@ -255,6 +255,19 @@ function getCommandHandler(name) {
255
255
  // src/cli/server.ts
256
256
  var instance = null;
257
257
  var launchPromise = null;
258
+ var selectorNamespace = null;
259
+ var requestQueue = Promise.resolve();
260
+ var shuttingDown = false;
261
+ function sanitizeNamespace(value) {
262
+ const trimmed = String(value || "").trim();
263
+ if (!trimmed || trimmed === "." || trimmed === "..") {
264
+ return "default";
265
+ }
266
+ const replaced = trimmed.replace(/[^a-zA-Z0-9_-]+/g, "_");
267
+ const collapsed = replaced.replace(/_+/g, "_");
268
+ const bounded = collapsed.replace(/^_+|_+$/g, "");
269
+ return bounded || "default";
270
+ }
258
271
  function invalidateInstance() {
259
272
  if (!instance) return;
260
273
  instance.close().catch(() => {
@@ -268,13 +281,14 @@ function attachLifecycleListeners(inst) {
268
281
  } catch {
269
282
  }
270
283
  }
271
- var namespace = process.env.OPENSTEER_NAME?.trim();
272
- if (!namespace) {
273
- process.stderr.write("Missing OPENSTEER_NAME environment variable.\n");
284
+ var sessionEnv = process.env.OPENSTEER_SESSION?.trim();
285
+ if (!sessionEnv) {
286
+ process.stderr.write("Missing OPENSTEER_SESSION environment variable.\n");
274
287
  process.exit(1);
275
288
  }
276
- var socketPath = getSocketPath(namespace);
277
- var pidPath = getPidPath(namespace);
289
+ var session = sessionEnv;
290
+ var socketPath = getSocketPath(session);
291
+ var pidPath = getPidPath(session);
278
292
  function cleanup() {
279
293
  try {
280
294
  unlinkSync(socketPath);
@@ -285,14 +299,49 @@ function cleanup() {
285
299
  } catch {
286
300
  }
287
301
  }
302
+ function beginShutdown() {
303
+ if (shuttingDown) return;
304
+ shuttingDown = true;
305
+ cleanup();
306
+ server.close(() => {
307
+ process.exit(0);
308
+ });
309
+ setTimeout(() => {
310
+ process.exit(0);
311
+ }, 250).unref();
312
+ }
288
313
  function sendResponse(socket, response) {
289
314
  try {
290
315
  socket.write(JSON.stringify(response) + "\n");
291
316
  } catch {
292
317
  }
293
318
  }
319
+ function enqueueRequest(request, socket) {
320
+ if (request.command === "ping") {
321
+ void handleRequest(request, socket);
322
+ return;
323
+ }
324
+ requestQueue = requestQueue.then(() => handleRequest(request, socket)).catch(() => {
325
+ });
326
+ }
294
327
  async function handleRequest(request, socket) {
295
328
  const { id, command, args } = request;
329
+ if (command === "ping" && shuttingDown) {
330
+ sendResponse(socket, {
331
+ id,
332
+ ok: false,
333
+ error: `Session '${session}' is shutting down.`
334
+ });
335
+ return;
336
+ }
337
+ if (shuttingDown) {
338
+ sendResponse(socket, {
339
+ id,
340
+ ok: false,
341
+ error: `Session '${session}' is shutting down. Retry your command.`
342
+ });
343
+ return;
344
+ }
296
345
  if (command === "open") {
297
346
  try {
298
347
  const url = args.url;
@@ -300,6 +349,19 @@ async function handleRequest(request, socket) {
300
349
  const connectUrl = args["connect-url"];
301
350
  const channel = args.channel;
302
351
  const profileDir = args["profile-dir"];
352
+ const requestedName = typeof args.name === "string" && args.name.trim().length > 0 ? sanitizeNamespace(args.name) : null;
353
+ if (selectorNamespace && requestedName && requestedName !== selectorNamespace) {
354
+ sendResponse(socket, {
355
+ id,
356
+ ok: false,
357
+ 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.`
358
+ });
359
+ return;
360
+ }
361
+ if (!selectorNamespace) {
362
+ selectorNamespace = requestedName ?? session;
363
+ }
364
+ const activeNamespace = selectorNamespace ?? session;
303
365
  if (instance && !launchPromise) {
304
366
  try {
305
367
  if (instance.page.isClosed()) {
@@ -311,7 +373,7 @@ async function handleRequest(request, socket) {
311
373
  }
312
374
  if (!instance) {
313
375
  instance = new Opensteer({
314
- name: namespace,
376
+ name: activeNamespace,
315
377
  browser: {
316
378
  headless: headless ?? false,
317
379
  connectUrl,
@@ -343,8 +405,9 @@ async function handleRequest(request, socket) {
343
405
  ok: true,
344
406
  result: {
345
407
  url: instance.page.url(),
346
- sessionId: instance.getRemoteSessionId() ?? void 0,
347
- name: namespace
408
+ session,
409
+ name: activeNamespace,
410
+ remoteSessionId: instance.getRemoteSessionId() ?? void 0
348
411
  }
349
412
  });
350
413
  } catch (err) {
@@ -374,10 +437,7 @@ async function handleRequest(request, socket) {
374
437
  error: err instanceof Error ? err.message : String(err)
375
438
  });
376
439
  }
377
- setTimeout(() => {
378
- cleanup();
379
- process.exit(0);
380
- }, 100);
440
+ beginShutdown();
381
441
  return;
382
442
  }
383
443
  if (command === "ping") {
@@ -388,7 +448,7 @@ async function handleRequest(request, socket) {
388
448
  sendResponse(socket, {
389
449
  id,
390
450
  ok: false,
391
- error: `No browser session in namespace '${namespace}'. Call 'opensteer open --name ${namespace}' first, or use 'opensteer sessions' to list active sessions.`
451
+ error: `No browser session in session '${session}'. Call 'opensteer open --session ${session}' first, or use 'opensteer sessions' to list active sessions.`
392
452
  });
393
453
  return;
394
454
  }
@@ -425,7 +485,7 @@ var server = createServer((socket) => {
425
485
  if (!line.trim()) continue;
426
486
  try {
427
487
  const request = JSON.parse(line);
428
- handleRequest(request, socket);
488
+ enqueueRequest(request, socket);
429
489
  } catch {
430
490
  sendResponse(socket, {
431
491
  id: 0,
@@ -450,6 +510,8 @@ server.on("error", (err) => {
450
510
  process.exit(1);
451
511
  });
452
512
  async function shutdown() {
513
+ if (shuttingDown) return;
514
+ shuttingDown = true;
453
515
  if (instance) {
454
516
  try {
455
517
  await instance.close();
@@ -457,9 +519,13 @@ async function shutdown() {
457
519
  }
458
520
  instance = null;
459
521
  }
460
- server.close();
461
522
  cleanup();
462
- process.exit(0);
523
+ server.close(() => {
524
+ process.exit(0);
525
+ });
526
+ setTimeout(() => {
527
+ process.exit(0);
528
+ }, 250).unref();
463
529
  }
464
530
  process.on("SIGTERM", shutdown);
465
531
  process.on("SIGINT", shutdown);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opensteer",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "packageManager": "pnpm@10.29.3",
5
5
  "description": "Open-source browser automation SDK with robust selectors and deterministic replay.",
6
6
  "license": "MIT",