opensteer 0.4.3 → 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,13 +9746,40 @@ function getCommandHandler(name) {
9746
9746
  // src/cli/server.ts
9747
9747
  var instance = null;
9748
9748
  var launchPromise = null;
9749
- var namespace = process.env.OPENSTEER_NAME?.trim();
9750
- if (!namespace) {
9751
- process.stderr.write("Missing OPENSTEER_NAME environment variable.\n");
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
+ }
9762
+ function invalidateInstance() {
9763
+ if (!instance) return;
9764
+ instance.close().catch(() => {
9765
+ });
9766
+ instance = null;
9767
+ }
9768
+ function attachLifecycleListeners(inst) {
9769
+ try {
9770
+ inst.page.on("close", invalidateInstance);
9771
+ inst.context.on("close", invalidateInstance);
9772
+ } catch {
9773
+ }
9774
+ }
9775
+ var sessionEnv = process.env.OPENSTEER_SESSION?.trim();
9776
+ if (!sessionEnv) {
9777
+ process.stderr.write("Missing OPENSTEER_SESSION environment variable.\n");
9752
9778
  process.exit(1);
9753
9779
  }
9754
- var socketPath = getSocketPath(namespace);
9755
- var pidPath = getPidPath(namespace);
9780
+ var session = sessionEnv;
9781
+ var socketPath = getSocketPath(session);
9782
+ var pidPath = getPidPath(session);
9756
9783
  function cleanup() {
9757
9784
  try {
9758
9785
  (0, import_fs4.unlinkSync)(socketPath);
@@ -9763,14 +9790,49 @@ function cleanup() {
9763
9790
  } catch {
9764
9791
  }
9765
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
+ }
9766
9804
  function sendResponse(socket, response) {
9767
9805
  try {
9768
9806
  socket.write(JSON.stringify(response) + "\n");
9769
9807
  } catch {
9770
9808
  }
9771
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
+ }
9772
9818
  async function handleRequest(request, socket) {
9773
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
+ }
9774
9836
  if (command === "open") {
9775
9837
  try {
9776
9838
  const url = args.url;
@@ -9778,23 +9840,31 @@ async function handleRequest(request, socket) {
9778
9840
  const connectUrl = args["connect-url"];
9779
9841
  const channel = args.channel;
9780
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;
9781
9856
  if (instance && !launchPromise) {
9782
9857
  try {
9783
- const alive = !instance.page.isClosed();
9784
- if (!alive) {
9785
- await instance.close().catch(() => {
9786
- });
9787
- instance = null;
9858
+ if (instance.page.isClosed()) {
9859
+ invalidateInstance();
9788
9860
  }
9789
9861
  } catch {
9790
- await instance?.close().catch(() => {
9791
- });
9792
- instance = null;
9862
+ invalidateInstance();
9793
9863
  }
9794
9864
  }
9795
9865
  if (!instance) {
9796
9866
  instance = new Opensteer({
9797
- name: namespace,
9867
+ name: activeNamespace,
9798
9868
  browser: {
9799
9869
  headless: headless ?? false,
9800
9870
  connectUrl,
@@ -9808,6 +9878,7 @@ async function handleRequest(request, socket) {
9808
9878
  });
9809
9879
  try {
9810
9880
  await launchPromise;
9881
+ attachLifecycleListeners(instance);
9811
9882
  } catch (err) {
9812
9883
  instance = null;
9813
9884
  throw err;
@@ -9825,8 +9896,9 @@ async function handleRequest(request, socket) {
9825
9896
  ok: true,
9826
9897
  result: {
9827
9898
  url: instance.page.url(),
9828
- sessionId: instance.getRemoteSessionId() ?? void 0,
9829
- name: namespace
9899
+ session,
9900
+ name: activeNamespace,
9901
+ remoteSessionId: instance.getRemoteSessionId() ?? void 0
9830
9902
  }
9831
9903
  });
9832
9904
  } catch (err) {
@@ -9856,10 +9928,7 @@ async function handleRequest(request, socket) {
9856
9928
  error: err instanceof Error ? err.message : String(err)
9857
9929
  });
9858
9930
  }
9859
- setTimeout(() => {
9860
- cleanup();
9861
- process.exit(0);
9862
- }, 100);
9931
+ beginShutdown();
9863
9932
  return;
9864
9933
  }
9865
9934
  if (command === "ping") {
@@ -9870,7 +9939,7 @@ async function handleRequest(request, socket) {
9870
9939
  sendResponse(socket, {
9871
9940
  id,
9872
9941
  ok: false,
9873
- 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.`
9874
9943
  });
9875
9944
  return;
9876
9945
  }
@@ -9907,7 +9976,7 @@ var server = (0, import_net.createServer)((socket) => {
9907
9976
  if (!line.trim()) continue;
9908
9977
  try {
9909
9978
  const request = JSON.parse(line);
9910
- handleRequest(request, socket);
9979
+ enqueueRequest(request, socket);
9911
9980
  } catch {
9912
9981
  sendResponse(socket, {
9913
9982
  id: 0,
@@ -9932,6 +10001,8 @@ server.on("error", (err) => {
9932
10001
  process.exit(1);
9933
10002
  });
9934
10003
  async function shutdown() {
10004
+ if (shuttingDown) return;
10005
+ shuttingDown = true;
9935
10006
  if (instance) {
9936
10007
  try {
9937
10008
  await instance.close();
@@ -9939,9 +10010,13 @@ async function shutdown() {
9939
10010
  }
9940
10011
  instance = null;
9941
10012
  }
9942
- server.close();
9943
10013
  cleanup();
9944
- process.exit(0);
10014
+ server.close(() => {
10015
+ process.exit(0);
10016
+ });
10017
+ setTimeout(() => {
10018
+ process.exit(0);
10019
+ }, 250).unref();
9945
10020
  }
9946
10021
  process.on("SIGTERM", shutdown);
9947
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,13 +255,40 @@ function getCommandHandler(name) {
255
255
  // src/cli/server.ts
256
256
  var instance = null;
257
257
  var launchPromise = null;
258
- var namespace = process.env.OPENSTEER_NAME?.trim();
259
- if (!namespace) {
260
- process.stderr.write("Missing OPENSTEER_NAME environment variable.\n");
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
+ }
271
+ function invalidateInstance() {
272
+ if (!instance) return;
273
+ instance.close().catch(() => {
274
+ });
275
+ instance = null;
276
+ }
277
+ function attachLifecycleListeners(inst) {
278
+ try {
279
+ inst.page.on("close", invalidateInstance);
280
+ inst.context.on("close", invalidateInstance);
281
+ } catch {
282
+ }
283
+ }
284
+ var sessionEnv = process.env.OPENSTEER_SESSION?.trim();
285
+ if (!sessionEnv) {
286
+ process.stderr.write("Missing OPENSTEER_SESSION environment variable.\n");
261
287
  process.exit(1);
262
288
  }
263
- var socketPath = getSocketPath(namespace);
264
- var pidPath = getPidPath(namespace);
289
+ var session = sessionEnv;
290
+ var socketPath = getSocketPath(session);
291
+ var pidPath = getPidPath(session);
265
292
  function cleanup() {
266
293
  try {
267
294
  unlinkSync(socketPath);
@@ -272,14 +299,49 @@ function cleanup() {
272
299
  } catch {
273
300
  }
274
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
+ }
275
313
  function sendResponse(socket, response) {
276
314
  try {
277
315
  socket.write(JSON.stringify(response) + "\n");
278
316
  } catch {
279
317
  }
280
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
+ }
281
327
  async function handleRequest(request, socket) {
282
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
+ }
283
345
  if (command === "open") {
284
346
  try {
285
347
  const url = args.url;
@@ -287,23 +349,31 @@ async function handleRequest(request, socket) {
287
349
  const connectUrl = args["connect-url"];
288
350
  const channel = args.channel;
289
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;
290
365
  if (instance && !launchPromise) {
291
366
  try {
292
- const alive = !instance.page.isClosed();
293
- if (!alive) {
294
- await instance.close().catch(() => {
295
- });
296
- instance = null;
367
+ if (instance.page.isClosed()) {
368
+ invalidateInstance();
297
369
  }
298
370
  } catch {
299
- await instance?.close().catch(() => {
300
- });
301
- instance = null;
371
+ invalidateInstance();
302
372
  }
303
373
  }
304
374
  if (!instance) {
305
375
  instance = new Opensteer({
306
- name: namespace,
376
+ name: activeNamespace,
307
377
  browser: {
308
378
  headless: headless ?? false,
309
379
  connectUrl,
@@ -317,6 +387,7 @@ async function handleRequest(request, socket) {
317
387
  });
318
388
  try {
319
389
  await launchPromise;
390
+ attachLifecycleListeners(instance);
320
391
  } catch (err) {
321
392
  instance = null;
322
393
  throw err;
@@ -334,8 +405,9 @@ async function handleRequest(request, socket) {
334
405
  ok: true,
335
406
  result: {
336
407
  url: instance.page.url(),
337
- sessionId: instance.getRemoteSessionId() ?? void 0,
338
- name: namespace
408
+ session,
409
+ name: activeNamespace,
410
+ remoteSessionId: instance.getRemoteSessionId() ?? void 0
339
411
  }
340
412
  });
341
413
  } catch (err) {
@@ -365,10 +437,7 @@ async function handleRequest(request, socket) {
365
437
  error: err instanceof Error ? err.message : String(err)
366
438
  });
367
439
  }
368
- setTimeout(() => {
369
- cleanup();
370
- process.exit(0);
371
- }, 100);
440
+ beginShutdown();
372
441
  return;
373
442
  }
374
443
  if (command === "ping") {
@@ -379,7 +448,7 @@ async function handleRequest(request, socket) {
379
448
  sendResponse(socket, {
380
449
  id,
381
450
  ok: false,
382
- 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.`
383
452
  });
384
453
  return;
385
454
  }
@@ -416,7 +485,7 @@ var server = createServer((socket) => {
416
485
  if (!line.trim()) continue;
417
486
  try {
418
487
  const request = JSON.parse(line);
419
- handleRequest(request, socket);
488
+ enqueueRequest(request, socket);
420
489
  } catch {
421
490
  sendResponse(socket, {
422
491
  id: 0,
@@ -441,6 +510,8 @@ server.on("error", (err) => {
441
510
  process.exit(1);
442
511
  });
443
512
  async function shutdown() {
513
+ if (shuttingDown) return;
514
+ shuttingDown = true;
444
515
  if (instance) {
445
516
  try {
446
517
  await instance.close();
@@ -448,9 +519,13 @@ async function shutdown() {
448
519
  }
449
520
  instance = null;
450
521
  }
451
- server.close();
452
522
  cleanup();
453
- process.exit(0);
523
+ server.close(() => {
524
+ process.exit(0);
525
+ });
526
+ setTimeout(() => {
527
+ process.exit(0);
528
+ }, 250).unref();
454
529
  }
455
530
  process.on("SIGTERM", shutdown);
456
531
  process.on("SIGINT", shutdown);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opensteer",
3
- "version": "0.4.3",
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",