haltija 1.2.3 → 1.2.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.
@@ -36,6 +36,10 @@ const HALTIJA_SERVER = `http://localhost:${HALTIJA_PORT}`
36
36
  // Combined with webContents.id to create globally unique tab identifiers
37
37
  const APP_INSTANCE_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
38
38
 
39
+ // Pending session: when an agent opens a tab with a session, store it here
40
+ // so injectWidget can include it in the widget config for the next new tab
41
+ let pendingTabSession = null
42
+
39
43
  // ============================================
40
44
  // Preferences
41
45
  // ============================================
@@ -741,8 +745,16 @@ async function injectWidget(webContents) {
741
745
  // This is stable across navigations (even cross-origin) enabling cross-page recording
742
746
  const wsUrl = HALTIJA_SERVER.replace('http:', 'ws:') + '/ws/browser'
743
747
  const windowId = `hj-${APP_INSTANCE_ID}-${webContents.id}`
748
+ const configObj = { serverUrl: wsUrl, windowId }
749
+ // Session priority: pending session from agent's tabs/open > env var
750
+ if (pendingTabSession) {
751
+ configObj.session = pendingTabSession
752
+ pendingTabSession = null // Consume — only applies to the next tab
753
+ } else if (process.env.HALTIJA_SESSION) {
754
+ configObj.session = process.env.HALTIJA_SESSION
755
+ }
744
756
  await webContents.executeJavaScript(
745
- `window.__haltija_config__ = { serverUrl: '${wsUrl}', windowId: '${windowId}' };`,
757
+ `window.__haltija_config__ = ${JSON.stringify(configObj)};`,
746
758
  )
747
759
  await webContents.executeJavaScript(componentCode)
748
760
 
@@ -882,9 +894,10 @@ function setupScreenCapture() {
882
894
  })
883
895
 
884
896
  // Tab management — forwarded to renderer
885
- ipcMain.handle('open-tab', async (event, url) => {
897
+ ipcMain.handle('open-tab', async (event, url, session) => {
886
898
  if (!mainWindow) return false
887
- mainWindow.webContents.send('open-tab', { url })
899
+ if (session) pendingTabSession = session
900
+ mainWindow.webContents.send('open-tab', { url, session })
888
901
  return true
889
902
  })
890
903
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "Haltija Desktop - God Mode Browser for AI Agents",
5
5
  "homepage": "https://github.com/tonioloewald/haltija",
6
6
  "author": {
@@ -150,13 +150,24 @@ export function renameTerminalTab(tab, name) {
150
150
  }
151
151
  }
152
152
 
153
+ // Track the last active content (non-terminal) tab so it stays visible
154
+ // behind terminal/agent overlays for screenshot/hj command support
155
+ let lastContentTabId = null
156
+
153
157
  export function activateTab(tabId) {
154
158
  const tab = tabs.find((t) => t.id === tabId)
155
159
  if (!tab) return
156
160
 
161
+ const isTerminalTab = tab.isTerminal || tab.url === 'terminal'
162
+ if (!isTerminalTab) lastContentTabId = tabId
163
+
157
164
  tabs.forEach((t) => {
158
165
  t.element.classList.remove('active')
159
- t.webview.classList.remove('active')
166
+ if (isTerminalTab && t.id === lastContentTabId) {
167
+ // Keep the last content webview visible behind the terminal iframe
168
+ } else {
169
+ t.webview.classList.remove('active')
170
+ }
160
171
  })
161
172
 
162
173
  tab.element.classList.add('active')
@@ -130,7 +130,7 @@ document.addEventListener('keydown', (e) => {
130
130
  // ============================================
131
131
 
132
132
  // Tab management from widget (via main process IPC)
133
- window.haltija?.onOpenTab?.((data) => createTab(data.url))
133
+ window.haltija?.onOpenTab?.((data) => createTab(data.url, { session: data.session }))
134
134
  window.haltija?.onCloseTab?.((data) => {
135
135
  const tab = findTabByWindowId(data.windowId)
136
136
  if (tab) closeTab(tab.id)
@@ -46,7 +46,7 @@
46
46
  });
47
47
 
48
48
  // src/version.ts
49
- var VERSION = "1.2.3";
49
+ var VERSION = "1.2.5";
50
50
 
51
51
  // src/text-selector.ts
52
52
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -1312,7 +1312,7 @@
1312
1312
  }
1313
1313
  function buildDomTree(el, options, currentDepth = 0) {
1314
1314
  const {
1315
- depth = 3,
1315
+ depth = 5,
1316
1316
  includeText = true,
1317
1317
  allAttributes = false,
1318
1318
  includeStyles = false,
@@ -1703,6 +1703,7 @@
1703
1703
  windowId;
1704
1704
  isElectron = false;
1705
1705
  browserId = uid();
1706
+ sessionToken = "";
1706
1707
  killed = false;
1707
1708
  isActive = true;
1708
1709
  homeLeft = 0;
@@ -1775,7 +1776,7 @@
1775
1776
  selectionBox = null;
1776
1777
  highlightedElements = [];
1777
1778
  static get observedAttributes() {
1778
- return ["server", "hidden"];
1779
+ return ["server", "hidden", "session"];
1779
1780
  }
1780
1781
  static async runTests() {
1781
1782
  const el = document.querySelector(TAG_NAME);
@@ -1871,10 +1872,16 @@
1871
1872
  }
1872
1873
  this.windowId = storedWindowId;
1873
1874
  }
1875
+ if (config?.session) {
1876
+ this.sessionToken = config.session;
1877
+ } else {
1878
+ this.sessionToken = uid();
1879
+ }
1874
1880
  }
1875
1881
  connectedCallback() {
1876
1882
  this.killed = false;
1877
1883
  this.serverUrl = this.getAttribute("server") || this.serverUrl;
1884
+ this.sessionToken = this.getAttribute("session") || this.sessionToken;
1878
1885
  this.render();
1879
1886
  const rect = this.getBoundingClientRect();
1880
1887
  this.homeLeft = window.innerWidth - rect.width - 16;
@@ -1902,6 +1909,11 @@
1902
1909
  this.connect();
1903
1910
  }
1904
1911
  }
1912
+ if (name === "session") {
1913
+ this.sessionToken = value;
1914
+ this.disconnect();
1915
+ this.connect();
1916
+ }
1905
1917
  }
1906
1918
  render() {
1907
1919
  if (this.shadowRoot.querySelector(".widget")) {
@@ -2087,6 +2099,25 @@
2087
2099
  .btn.info-btn:hover {
2088
2100
  background: #2563eb;
2089
2101
  }
2102
+ .session-badge {
2103
+ display: flex;
2104
+ align-items: center;
2105
+ gap: 2px;
2106
+ font-size: 9px;
2107
+ color: #666;
2108
+ background: rgba(255,255,255,0.05);
2109
+ padding: 2px 4px;
2110
+ border-radius: 3px;
2111
+ cursor: pointer;
2112
+ user-select: none;
2113
+ }
2114
+ .session-badge:hover {
2115
+ background: rgba(255,255,255,0.1);
2116
+ color: #aaa;
2117
+ }
2118
+ .session-badge.copied {
2119
+ color: #22c55e;
2120
+ }
2090
2121
  @keyframes pulse {
2091
2122
  0%, 100% { opacity: 1; }
2092
2123
  50% { opacity: 0.5; }
@@ -2449,6 +2480,8 @@
2449
2480
  <span class="logo">\uD83E\uDDDD</span>
2450
2481
  </div>
2451
2482
  <div class="title">${PRODUCT_NAME}</div>
2483
+ <span class="session-badge" data-action="copy-session" title="Session: ${this.sessionToken}
2484
+ Click to copy session command">${this.sessionToken.slice(0, 8)}</span>
2452
2485
  <div class="controls">
2453
2486
  <button class="btn" data-action="select" title="Select elements (drag to select area)" aria-label="Select elements">\uD83D\uDC46</button>
2454
2487
  <button class="btn" data-action="record" title="Record test (click to start/stop)" aria-label="Record test">REC</button>
@@ -2535,6 +2568,8 @@
2535
2568
  this.startSelection();
2536
2569
  if (action2 === "stats")
2537
2570
  this.copyStatsToClipboard();
2571
+ if (action2 === "copy-session")
2572
+ this.copySessionToken(e.currentTarget);
2538
2573
  if (action2 === "close-modal")
2539
2574
  this.closeTestModal();
2540
2575
  if (action2 === "copy-test")
@@ -3160,6 +3195,28 @@
3160
3195
  });
3161
3196
  }
3162
3197
  }
3198
+ async copySessionToken(badge) {
3199
+ const cmd = `export HALTIJA_SESSION=${this.sessionToken}`;
3200
+ try {
3201
+ await navigator.clipboard.writeText(cmd);
3202
+ badge.textContent = "copied!";
3203
+ badge.classList.add("copied");
3204
+ setTimeout(() => {
3205
+ badge.textContent = this.sessionToken.slice(0, 8);
3206
+ badge.classList.remove("copied");
3207
+ }, 1500);
3208
+ } catch {
3209
+ try {
3210
+ await navigator.clipboard.writeText(this.sessionToken);
3211
+ badge.textContent = "copied!";
3212
+ badge.classList.add("copied");
3213
+ setTimeout(() => {
3214
+ badge.textContent = this.sessionToken.slice(0, 8);
3215
+ badge.classList.remove("copied");
3216
+ }, 1500);
3217
+ } catch {}
3218
+ }
3219
+ }
3163
3220
  async copyStatsToClipboard() {
3164
3221
  const refStats = refRegistry.getStats();
3165
3222
  stats.refsStale = refStats.stale;
@@ -4926,6 +4983,7 @@ ${elementSummary}${moreText}`;
4926
4983
  this.send("system", "connected", {
4927
4984
  windowId: this.windowId,
4928
4985
  browserId: this.browserId,
4986
+ session: this.sessionToken,
4929
4987
  version: VERSION2,
4930
4988
  serverSessionId: SERVER_SESSION_ID,
4931
4989
  url: location.href,
@@ -5351,7 +5409,7 @@ ${elementSummary}${moreText}`;
5351
5409
  const haltija = window.haltija;
5352
5410
  if (action2 === "open") {
5353
5411
  if (haltija?.openTab) {
5354
- haltija.openTab(payload2.url).then((opened) => {
5412
+ haltija.openTab(payload2.url, payload2.session).then((opened) => {
5355
5413
  this.respond(msg2.id, true, { opened });
5356
5414
  }).catch((err) => {
5357
5415
  this.respond(msg2.id, false, null, err.message);
@@ -7207,7 +7265,7 @@ ${elementSummary}${moreText}`;
7207
7265
  }
7208
7266
  registerDevChannel();
7209
7267
  var WIDGET_ID = "haltija-widget";
7210
- function inject(serverUrl2 = "wss://localhost:8700/ws/browser") {
7268
+ function inject(serverUrl2 = "wss://localhost:8700/ws/browser", options) {
7211
7269
  const existing = document.getElementById(WIDGET_ID);
7212
7270
  if (existing) {
7213
7271
  console.log(`${LOG_PREFIX} Already injected`);
@@ -7222,6 +7280,8 @@ ${elementSummary}${moreText}`;
7222
7280
  const el = DevChannel.elementCreator()();
7223
7281
  el.id = WIDGET_ID;
7224
7282
  el.setAttribute("server", serverUrl2);
7283
+ if (options?.session)
7284
+ el.setAttribute("session", options.session);
7225
7285
  el.setAttribute("data-version", VERSION2);
7226
7286
  document.body.appendChild(el);
7227
7287
  console.log(`${LOG_PREFIX} Injected`);
@@ -7233,7 +7293,7 @@ ${elementSummary}${moreText}`;
7233
7293
  const config = window.__haltija_config__;
7234
7294
  if (config?.autoInject !== false) {
7235
7295
  if (config) {
7236
- inject(config.serverUrl || config.wsUrl);
7296
+ inject(config.serverUrl || config.wsUrl, { session: config.session });
7237
7297
  return;
7238
7298
  }
7239
7299
  }
@@ -640,9 +640,15 @@ body {
640
640
  display: none;
641
641
  }
642
642
 
643
- #webview-container webview.active,
643
+ #webview-container webview.active {
644
+ display: flex;
645
+ z-index: 0;
646
+ }
647
+
644
648
  #webview-container .terminal-frame.active {
645
649
  display: flex;
650
+ z-index: 1;
651
+ opacity: 0.88;
646
652
  }
647
653
 
648
654
  #webview-container .terminal-frame {
@@ -1192,7 +1192,12 @@
1192
1192
  outputPreview.className = 'tool-output-preview'
1193
1193
  const lines = (output || '').split('\n')
1194
1194
  const previewText = lines.slice(0, 3).join('\n')
1195
- outputPreview.textContent = previewText + (lines.length > 3 ? `\n... (${lines.length} lines)` : '')
1195
+ const previewFull = previewText + (lines.length > 3 ? `\n... (${lines.length} lines)` : '')
1196
+ if (/\x1b\[/.test(previewFull)) {
1197
+ outputPreview.innerHTML = ansiToHtml(previewFull)
1198
+ } else {
1199
+ outputPreview.textContent = previewFull
1200
+ }
1196
1201
  card.appendChild(outputPreview)
1197
1202
 
1198
1203
  // Add full output to expandable body
@@ -1205,10 +1210,13 @@
1205
1210
  outputDiv.appendChild(outputLabel)
1206
1211
  const outputPre = document.createElement('pre')
1207
1212
  outputPre.style.cssText = 'margin: 0; white-space: pre-wrap;'
1208
- if (lines.length > 20) {
1209
- outputPre.textContent = lines.slice(0, 20).join('\n') + `\n... (${lines.length} lines total)`
1213
+ const fullText = lines.length > 20
1214
+ ? lines.slice(0, 20).join('\n') + `\n... (${lines.length} lines total)`
1215
+ : (output || '')
1216
+ if (/\x1b\[/.test(fullText)) {
1217
+ outputPre.innerHTML = ansiToHtml(fullText)
1210
1218
  } else {
1211
- outputPre.textContent = output || ''
1219
+ outputPre.textContent = fullText
1212
1220
  }
1213
1221
  outputDiv.appendChild(outputPre)
1214
1222
  body.appendChild(outputDiv)
@@ -1277,6 +1285,8 @@
1277
1285
  div.textContent = text
1278
1286
  } else if (type === 'summary') {
1279
1287
  div.textContent = text
1288
+ } else if (/\x1b\[/.test(text)) {
1289
+ div.innerHTML = ansiToHtml(text)
1280
1290
  } else {
1281
1291
  div.textContent = text
1282
1292
  }
@@ -1296,6 +1306,30 @@
1296
1306
  return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1297
1307
  }
1298
1308
 
1309
+ /** Convert ANSI escape codes to HTML spans */
1310
+ function ansiToHtml(str) {
1311
+ if (str == null) return ''
1312
+ const escaped = escapeHtml(str)
1313
+ // Map SGR codes to CSS styles
1314
+ const styles = {
1315
+ '1': 'font-weight:bold',
1316
+ '2': 'opacity:0.6',
1317
+ '3': 'font-style:italic',
1318
+ '4': 'text-decoration:underline',
1319
+ '31': 'color:#f87171', '32': 'color:#4ade80', '33': 'color:#fbbf24',
1320
+ '34': 'color:#60a5fa', '35': 'color:#c084fc', '36': 'color:#22d3ee',
1321
+ '90': 'color:#6b7280', '91': 'color:#f87171', '92': 'color:#4ade80',
1322
+ '93': 'color:#fbbf24', '94': 'color:#60a5fa', '95': 'color:#c084fc',
1323
+ '96': 'color:#22d3ee',
1324
+ }
1325
+ // Replace \e[...m sequences with styled spans
1326
+ return escaped.replace(/\x1b\[([0-9;]*)m/g, (_, codes) => {
1327
+ if (!codes || codes === '0') return '</span>'
1328
+ const css = codes.split(';').map(c => styles[c] || '').filter(Boolean).join(';')
1329
+ return css ? `<span style="${css}">` : ''
1330
+ })
1331
+ }
1332
+
1299
1333
  // ==========================================
1300
1334
  // Init
1301
1335
  // ==========================================
@@ -25,7 +25,7 @@ contextBridge.exposeInMainWorld('haltija', {
25
25
  return ipcRenderer.invoke('navigate-url', url)
26
26
  },
27
27
  // Tab management — routed through main process to renderer
28
- openTab: (url) => ipcRenderer.invoke('open-tab', url),
28
+ openTab: (url, session) => ipcRenderer.invoke('open-tab', url, session),
29
29
  closeTab: (windowId) => ipcRenderer.invoke('close-tab', windowId),
30
30
  focusTab: (windowId) => ipcRenderer.invoke('focus-tab', windowId),
31
31
  openAgentTab: () => {
@@ -95,9 +95,19 @@ export const ARG_MAPS = {
95
95
  call: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), method: args[1], args: args.slice(2).map(tryParseJSON) }),
96
96
  fetch: (args) => ({ url: args[0], prompt: args.slice(1).join(' ') || undefined }),
97
97
  screenshot: (args) => {
98
- const dataUrl = args.includes('--data-url')
99
- const filtered = args.filter(a => a !== '--data-url')
100
- return { ...parseTargetArgs(filtered), file: !dataUrl }
98
+ const body = { file: true }
99
+ const positional = []
100
+ for (let i = 0; i < args.length; i++) {
101
+ const a = args[i]
102
+ if (a === '--data-url') { body.file = false; continue }
103
+ if (a === '--scale') { body.scale = num(args[++i]); continue }
104
+ if (a === '--maxWidth' || a === '--max-width') { body.maxWidth = num(args[++i]); continue }
105
+ if (a === '--maxHeight' || a === '--max-height') { body.maxHeight = num(args[++i]); continue }
106
+ if (a === '--delay') { body.delay = num(args[++i]); continue }
107
+ if (a === '--no-chyron') { body.chyron = false; continue }
108
+ if (!a.startsWith('-')) { positional.push(a) }
109
+ }
110
+ return { ...body, ...parseTargetArgs(positional) }
101
111
  },
102
112
  snapshot: (args) => ({ context: args.join(' ') || undefined }),
103
113
  select: (args) => ({ action: args[0] }),
@@ -192,6 +202,7 @@ export function parseTreeArgs(args) {
192
202
  for (let i = 0; i < args.length; i++) {
193
203
  const a = args[i]
194
204
  if (a === '--depth' || a === '-d') { body.depth = num(args[++i]); continue }
205
+ if (a === '--all' || a === '-a') { body.depth = -1; continue }
195
206
  if (a === '--selector' || a === '-s') { body.selector = args[++i]; continue }
196
207
  if (a === '--compact' || a === '-c') { body.compact = true; continue }
197
208
  if (a === '--visible') { body.visibleOnly = true; continue }
@@ -422,6 +433,18 @@ export function clean(obj) {
422
433
  return Object.keys(result).length ? result : undefined
423
434
  }
424
435
 
436
+ // ============================================
437
+ // Session ID for multi-agent isolation
438
+ // ============================================
439
+
440
+ export function getSessionId() {
441
+ if (process.env.HALTIJA_SESSION) return process.env.HALTIJA_SESSION
442
+ // Auto-generate a session ID for this shell — persisted in env for subsequent calls
443
+ const id = `hj_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
444
+ process.env.HALTIJA_SESSION = id
445
+ return id
446
+ }
447
+
425
448
  // ============================================
426
449
  // Server auto-start
427
450
  // ============================================
@@ -499,14 +522,92 @@ async function startServerInBackground(port) {
499
522
  return false
500
523
  }
501
524
 
525
+ // ============================================
526
+ // Auto-launch Electron app when no browser windows connected
527
+ // ============================================
528
+
529
+ async function launchElectronApp() {
530
+ const { execSync, spawn: spawnChild } = await import('child_process')
531
+
532
+ if (process.platform === 'darwin') {
533
+ // Check common locations for Haltija.app
534
+ const appPaths = [
535
+ '/Applications/Haltija.app',
536
+ `${process.env.HOME}/Applications/Haltija.app`,
537
+ ]
538
+ for (const p of appPaths) {
539
+ if (existsSync(p)) {
540
+ spawnChild('open', ['-a', p], { stdio: 'ignore', detached: true }).unref()
541
+ return true
542
+ }
543
+ }
544
+ // Try spotlight search as fallback
545
+ try {
546
+ const result = execSync('mdfind "kMDItemCFBundleIdentifier == com.electron.haltija" | head -1', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim()
547
+ if (result) {
548
+ spawnChild('open', ['-a', result], { stdio: 'ignore', detached: true }).unref()
549
+ return true
550
+ }
551
+ } catch {}
552
+ return false
553
+ }
554
+
555
+ // Linux/Windows: not yet supported
556
+ return false
557
+ }
558
+
559
+ async function ensureBrowserConnected(port) {
560
+ try {
561
+ const resp = await fetch(`http://localhost:${port}/status`, {
562
+ signal: AbortSignal.timeout(2000)
563
+ })
564
+ const status = await resp.json()
565
+ // Use status.ok (global, not session-filtered) to check if any browser is connected
566
+ if (status.ok) return true
567
+ } catch { return false }
568
+
569
+ // No windows connected — try to launch Electron app (macOS only)
570
+ if (process.platform !== 'darwin') return false
571
+
572
+ process.stderr.write('\x1b[2mLaunching Haltija browser...\x1b[0m')
573
+ const launched = await launchElectronApp()
574
+ if (!launched) {
575
+ process.stderr.write('\x1b[2m not found\x1b[0m\n')
576
+ return false
577
+ }
578
+
579
+ // Wait for a window to connect (up to 10s)
580
+ const maxWait = 10000
581
+ const start = Date.now()
582
+ while (Date.now() - start < maxWait) {
583
+ try {
584
+ const resp = await fetch(`http://localhost:${port}/status`, {
585
+ signal: AbortSignal.timeout(1000)
586
+ })
587
+ const status = await resp.json()
588
+ if (status.ok) {
589
+ process.stderr.write('\x1b[2m ready\x1b[0m\n')
590
+ return true
591
+ }
592
+ } catch {}
593
+ await new Promise(r => setTimeout(r, 500))
594
+ }
595
+ process.stderr.write('\x1b[2m timeout\x1b[0m\n')
596
+ return false
597
+ }
598
+
599
+ // Commands that don't need a browser window to be connected
600
+ const INFO_COMMANDS = new Set(['status', 'windows', 'version', 'help'])
601
+
502
602
  // ============================================
503
603
  // Main subcommand execution
504
604
  // ============================================
505
605
 
506
- export async function runSubcommand(subcommand, subArgs, port = '8700') {
606
+ export async function runSubcommand(subcommand, subArgs, port = '8700', options = {}) {
507
607
  const baseUrl = `http://localhost:${port}`
508
608
  const jsonOutput = subArgs.includes('--json')
509
- // Remove --json and extract --window before processing
609
+ const noLaunch = options.noLaunch || false
610
+ // Remove --json and extract --window/--session before processing
510
611
  let filteredArgs = subArgs.filter(a => a !== '--json')
511
612
  let targetWindowId = undefined
512
613
  const windowIdx = filteredArgs.indexOf('--window')
@@ -514,6 +615,11 @@ export async function runSubcommand(subcommand, subArgs, port = '8700') {
514
615
  targetWindowId = filteredArgs[windowIdx + 1]
515
616
  filteredArgs = [...filteredArgs.slice(0, windowIdx), ...filteredArgs.slice(windowIdx + 2)]
516
617
  }
618
+ const sessionIdx = filteredArgs.indexOf('--session')
619
+ if (sessionIdx !== -1) {
620
+ process.env.HALTIJA_SESSION = filteredArgs[sessionIdx + 1]
621
+ filteredArgs = [...filteredArgs.slice(0, sessionIdx), ...filteredArgs.slice(sessionIdx + 2)]
622
+ }
517
623
 
518
624
  // Check if server is running, auto-start if not
519
625
  if (!(await isServerRunning(port))) {
@@ -528,6 +634,11 @@ export async function runSubcommand(subcommand, subArgs, port = '8700') {
528
634
  }
529
635
  }
530
636
 
637
+ // Auto-launch browser if no windows connected (skip for info commands and --no-launch)
638
+ if (!noLaunch && !INFO_COMMANDS.has(subcommand)) {
639
+ await ensureBrowserConnected(port)
640
+ }
641
+
531
642
  // Special handling for 'send' command - route to appropriate endpoint
532
643
  // hj send selection [agent] → /send/selection
533
644
  // hj send recording [agent] → /send/recording
@@ -586,9 +697,10 @@ export async function runSubcommand(subcommand, subArgs, port = '8700') {
586
697
  async function doRequest(url, method, body, context = {}) {
587
698
  const { subcommand, jsonOutput } = context
588
699
  try {
589
- const opts = { method }
700
+ const sessionId = getSessionId()
701
+ const opts = { method, headers: { 'X-Haltija-Session': sessionId } }
590
702
  if (body) {
591
- opts.headers = { 'Content-Type': 'application/json' }
703
+ opts.headers['Content-Type'] = 'application/json'
592
704
  opts.body = JSON.stringify(body)
593
705
  }
594
706
 
@@ -600,7 +712,7 @@ async function doRequest(url, method, body, context = {}) {
600
712
 
601
713
  // Text format for supported subcommands (unless --json)
602
714
  if (!jsonOutput && subcommand === 'tree' && json.success && json.data) {
603
- console.log(formatTree(json.data))
715
+ console.log(formatTree(json.data, 0, { depth: body?.depth }))
604
716
  } else if (!jsonOutput && subcommand === 'events' && (json.events || Array.isArray(json))) {
605
717
  console.log(formatEvents(json))
606
718
  } else if (!jsonOutput && subcommand === 'test-run' && json.test) {
@@ -720,7 +832,7 @@ export function listSubcommands() {
720
832
  return `
721
833
  Subcommands (replace curl with simple commands):
722
834
  ${bold('Inspect')}
723
- tree [selector] [-d depth] DOM tree with ref IDs
835
+ tree [selector] [-d N] [-a] DOM tree with ref IDs (default depth 5, -a = all)
724
836
  query <selector> Find elements matching selector
725
837
  inspect <@ref|selector> Detailed element info
726
838
  inspectAll <selector> Deep inspect all matches
@@ -27,15 +27,17 @@ const MAX_TEXT_LEN = 80
27
27
  * @param {number} indent - current indentation level (internal)
28
28
  * @returns {string} formatted text output with footer
29
29
  */
30
- export function formatTree(node, indent = 0) {
30
+ export function formatTree(node, indent = 0, { depth } = {}) {
31
31
  if (!node) return ''
32
32
 
33
33
  const lines = []
34
34
  formatNode(node, indent, lines)
35
35
 
36
- // Footer: JSON escape hatch
36
+ // Footer: depth and options hint
37
+ const d = depth ?? 5
38
+ const isDefault = depth === undefined || depth === null
37
39
  lines.push('---')
38
- lines.push('hj tree --json')
40
+ lines.push(`depth=${d}${isDefault ? ' (default)' : d === -1 ? ' (unlimited)' : ''} | -d N | --all | --json`)
39
41
 
40
42
  return lines.join('\n')
41
43
  }
package/bin/hj.mjs CHANGED
@@ -36,6 +36,21 @@ if (portIdx !== -1 && args[portIdx + 1]) {
36
36
  args.splice(portIdx, 2)
37
37
  }
38
38
 
39
+ // Parse --session option (set session token)
40
+ const sessionIdx = args.indexOf('--session')
41
+ if (sessionIdx !== -1 && args[sessionIdx + 1]) {
42
+ process.env.HALTIJA_SESSION = args[sessionIdx + 1]
43
+ args.splice(sessionIdx, 2)
44
+ }
45
+
46
+ // Parse --no-launch option (skip auto-launching Electron app)
47
+ let noLaunch = false
48
+ const noLaunchIdx = args.indexOf('--no-launch')
49
+ if (noLaunchIdx !== -1) {
50
+ noLaunch = true
51
+ args.splice(noLaunchIdx, 1)
52
+ }
53
+
39
54
  const subcommand = args[0]
40
55
  const subArgs = args.slice(1).filter(a => a !== '--window' || true) // keep all args
41
56
 
@@ -61,7 +76,7 @@ if (!isSubcommand(subcommand)) {
61
76
  console.error(`Run 'hj' for docs.`)
62
77
  process.exit(1)
63
78
  } else {
64
- runSubcommand(subcommand, subArgs, port)
79
+ runSubcommand(subcommand, subArgs, port, { noLaunch })
65
80
  }
66
81
 
67
82
  function filterHelp(topic) {
@@ -362,6 +362,8 @@ if (docsDirIdx !== -1 && args[docsDirIdx + 1]) {
362
362
  // ============================================
363
363
 
364
364
  const ciMode = args.includes('--ci')
365
+ const secureMode = args.includes('--secure')
366
+ if (secureMode) process.env.HALTIJA_SECURE = '1'
365
367
  const headlessMode = args.includes('--headless') // Playwright headless (separate from CI)
366
368
  const headlessUrlIdx = args.indexOf('--headless-url')
367
369
  const headlessUrl = headlessUrlIdx !== -1 ? args[headlessUrlIdx + 1] : null