haltija 1.3.0-beta.7 → 1.3.0-beta.9

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/README.md CHANGED
@@ -141,9 +141,9 @@ Raw DOM events are noise. Haltija aggregates them into intent:
141
141
 
142
142
  Screenshots include a chyron (title, URL, timestamp) so agents always know what they're looking at. Disable with `chyron: false` for clean captures.
143
143
 
144
- ### Multi-Window & Session Affinity
144
+ ### Multi-Window
145
145
 
146
- Control multiple tabs. Session headers (`X-Haltija-Session`) give agents sticky window targeting no need to specify window ID every call.
146
+ Control multiple tabs. The focused tab receives untargeted commands; pass `?window=<id>` (REST) or `--window <id>` (CLI) to target a specific tab.
147
147
 
148
148
  ### Selection Tool
149
149
 
@@ -190,6 +190,59 @@ Your agent uses the same `hj` commands either way — it doesn't know or care wh
190
190
 
191
191
  ---
192
192
 
193
+ ## Embed Haltija in Your Own App
194
+
195
+ Building a tool, dev environment, or product that wants an agent eye built in? Run a haltija server on a port you choose and import the widget directly. Two flavours:
196
+
197
+ ```js
198
+ // Visible — widget renders its own UI in the corner
199
+ import { inject } from 'haltija/component'
200
+ inject('ws://localhost:9123/ws/browser')
201
+
202
+ // Headless — widget is present but invisible; agent still has full control
203
+ inject('ws://localhost:9123/ws/browser', { mode: 'headless' })
204
+ ```
205
+
206
+ Or via HTML, no JS required:
207
+
208
+ ```html
209
+ <haltija-dev server="ws://localhost:9123/ws/browser"></haltija-dev>
210
+ ```
211
+
212
+ Tell `hj` which server to talk to (per-shell):
213
+
214
+ ```bash
215
+ # Named instance — recommended, no port juggling
216
+ haltija --name dashboard --server # in one shell: register as "dashboard"
217
+ export HALTIJA_NAME=dashboard # in your other shells
218
+ hj tree # finds dashboard via ~/.haltija/servers/
219
+
220
+ # Port-based — if you'd rather pin a number
221
+ haltija --port 9123 --server
222
+ export HALTIJA_PORT=9123
223
+ hj tree
224
+ ```
225
+
226
+ If you don't pass `--port`, haltija tries 8700 first and falls back to a kernel-assigned ephemeral port — `--name` records whichever port it ends up on so `hj` can find it. A different shell can target a different project; there's no global state, just one named instance per haltija server.
227
+
228
+ **Production embedding.** When haltija is reachable beyond loopback, gate it with a shared-secret token:
229
+
230
+ ```bash
231
+ haltija --port 9123 --token $(openssl rand -hex 16) --server
232
+ ```
233
+
234
+ ```js
235
+ inject('ws://your-host:9123/ws/browser', { token: 'same-secret' })
236
+ ```
237
+
238
+ ```bash
239
+ HALTIJA_TOKEN=same-secret hj tree
240
+ ```
241
+
242
+ The server rejects every REST/WebSocket request without a matching `X-Haltija-Token` (or `?token=` for WebSockets). This is a stub — provide your own TLS, key rotation, and per-agent identity if you need them.
243
+
244
+ ---
245
+
193
246
  ## Installation
194
247
 
195
248
  ```bash
@@ -200,6 +253,7 @@ npm install -g haltija # Install globally
200
253
  # Server options
201
254
  haltija --https # HTTPS mode
202
255
  haltija --port 3000 # Custom port
256
+ haltija --token <secret> # Require X-Haltija-Token on every request
203
257
  haltija --headless # For CI pipelines
204
258
  haltija --setup-mcp # Configure Claude Desktop
205
259
  ```
@@ -241,6 +295,30 @@ hj --help # CLI subcommand reference
241
295
 
242
296
  ---
243
297
 
298
+ ## 1.3.0-beta.8 — change of direction
299
+
300
+ Earlier 1.3 betas tried to support multiple agents on a single haltija server by issuing each widget a *session token* and routing requests by `X-Haltija-Session`. The model was load-bearing but leaky — six of the last fifteen commits before this release were firefighting session-isolation regressions.
301
+
302
+ beta.8 deletes the entire mechanism and replaces it with **process boundaries**: each project runs its own haltija server, and the agent talks to the right one by **port** or by **name**.
303
+
304
+ ```bash
305
+ haltija --name dashboard --server # one project, registers itself in ~/.haltija/servers/
306
+ HALTIJA_NAME=dashboard hj tree # any shell can address it by name
307
+ ```
308
+
309
+ What this means for you, depending on how you used 1.3.0-beta.7:
310
+
311
+ - **`bunx haltija` desktop app** — works the same; no migration needed. The outer "chrome" widget that lets the app inspect itself now lives on a separate internal port (8701) so it never appears in agent-facing window listings.
312
+ - **`HALTIJA_SESSION` / `--session` / `--secure` / the click-to-copy session badge** — gone. If you were setting `HALTIJA_SESSION` in your shell, replace it with `HALTIJA_NAME` (and start the server with `--name <foo>`) or `HALTIJA_PORT`.
313
+ - **`inject(url, { session })`** — the `session` option is removed. If you need auth, use `inject(url, { token })` (matches `haltija --token <secret>`); for embedding without auth, just `inject(url)` or `inject(url, { mode: 'headless' })`.
314
+ - **`hj-chrome` exclusion logic** — gone from the public REST API. To inspect the desktop app's outer UI from `hj`, target the internal port directly: `HALTIJA_PORT=8701 hj tree`.
315
+
316
+ Net code change: ~830 lines removed, ~150 added back for the simpler model. Test count went up (we now have integration coverage for the token stub, named instances, and auto-port fallback that the previous betas lacked).
317
+
318
+ The pre-revert state is preserved on the `multi-user-isolation` branch in case the multi-agent-on-one-server design ever needs revisiting.
319
+
320
+ ---
321
+
244
322
  ## License
245
323
 
246
324
  Apache 2.0
@@ -33,18 +33,20 @@ process.stderr.on('error', () => {})
33
33
  const HALTIJA_PORT = parseInt(process.env.HALTIJA_PORT || '8700')
34
34
  const HALTIJA_SERVER = `http://localhost:${HALTIJA_PORT}`
35
35
 
36
+ // Internal port for the chrome widget (the haltija UI inspecting itself).
37
+ // Lives on a separate server so it never appears in agent-facing window lists
38
+ // — agents see only content tabs unless they explicitly target this port.
39
+ const HALTIJA_INTERNAL_PORT = parseInt(process.env.HALTIJA_INTERNAL_PORT || '8701')
40
+ const HALTIJA_INTERNAL_SERVER = `http://localhost:${HALTIJA_INTERNAL_PORT}`
41
+
36
42
  // Unique app instance ID - used to create stable window IDs across navigations
37
43
  // Combined with webContents.id to create globally unique tab identifiers
38
44
  const APP_INSTANCE_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
39
45
 
40
- // Pending session: when an agent opens a tab with a session, store it here
41
- // so injectWidget can include it in the widget config for the next new tab
42
- let pendingTabSession = null
43
-
44
- // Stable session per webContents — persists across navigations within the same tab.
45
- // Without this, each page load re-injects the widget with a new random session,
46
- // causing agents to lose track of their tab and open duplicates.
47
- const tabSessions = new Map() // webContents.id → session string
46
+ // Pending navigate-url requests: main asks renderer to route a navigation,
47
+ // then awaits a result so the widget's promise resolves with real success/failure.
48
+ const pendingNavigates = new Map()
49
+ let navigateRequestId = 0
48
50
 
49
51
  // ============================================
50
52
  // Preferences
@@ -64,7 +66,7 @@ const DEFAULT_PREFS = {
64
66
  const prefs = { ...DEFAULT_PREFS }
65
67
 
66
68
  let mainWindow = null
67
- let embeddedServer = null
69
+ const embeddedServers = []
68
70
 
69
71
  // ============================================
70
72
  // MCP Setup for Claude Desktop
@@ -779,23 +781,6 @@ async function injectWidget(webContents) {
779
781
  const wsUrl = HALTIJA_SERVER.replace('http:', 'ws:') + '/ws/browser'
780
782
  const windowId = `hj-${APP_INSTANCE_ID}-${webContents.id}`
781
783
  const configObj = { serverUrl: wsUrl, windowId, mode: 'headless' }
782
- // Session priority: pending session from agent's tabs/open > previously used session for this tab > env var > auto-generate
783
- // Stable session per tab prevents agents from losing their window after navigation.
784
- if (pendingTabSession) {
785
- configObj.session = pendingTabSession
786
- tabSessions.set(webContents.id, pendingTabSession)
787
- pendingTabSession = null // Consume — only applies to the next tab
788
- } else if (tabSessions.has(webContents.id)) {
789
- configObj.session = tabSessions.get(webContents.id)
790
- } else if (process.env.HALTIJA_SESSION) {
791
- configObj.session = process.env.HALTIJA_SESSION
792
- tabSessions.set(webContents.id, process.env.HALTIJA_SESSION)
793
- } else {
794
- // Generate a stable session for this tab so it survives navigations
795
- const generated = Math.random().toString(36).slice(2, 10)
796
- configObj.session = generated
797
- tabSessions.set(webContents.id, generated)
798
- }
799
784
  await webContents.executeJavaScript(
800
785
  `window.__haltija_config__ = ${JSON.stringify(configObj)};`,
801
786
  )
@@ -928,25 +913,39 @@ function setupScreenCapture() {
928
913
  })
929
914
 
930
915
  // Navigate URL with smart fallback (called from widget in webview)
931
- // Routes through renderer's navigate() which has https->http fallback
932
- // Passes the sender's webContentsId so the renderer navigates the correct tab
916
+ // Routes through renderer's navigate() which has https->http fallback.
917
+ // Awaits a result from the renderer so failures (no matching tab, terminal
918
+ // tab targeted, etc.) propagate back to the widget — and to `hj navigate`.
933
919
  ipcMain.handle('navigate-url', async (event, url) => {
934
- if (!mainWindow) return { success: false, error: 'No window' }
920
+ if (!mainWindow) throw new Error('No window')
921
+
922
+ const id = ++navigateRequestId
923
+ const senderWcId = event.sender.id
924
+ const result = await new Promise((resolve) => {
925
+ const timeout = setTimeout(() => {
926
+ pendingNavigates.delete(id)
927
+ resolve({ success: false, error: 'navigate-url: renderer did not respond within 5s' })
928
+ }, 5000)
929
+ pendingNavigates.set(id, { resolve, timeout })
930
+ mainWindow.webContents.send('navigate-url', { id, url, webContentsId: senderWcId })
931
+ })
935
932
 
936
- try {
937
- const senderWcId = event.sender.id
938
- mainWindow.webContents.send('navigate-url', { url, webContentsId: senderWcId })
939
- return { success: true }
940
- } catch (err) {
941
- return { success: false, error: err.message }
942
- }
933
+ if (!result.success) throw new Error(result.error || 'Navigation failed')
934
+ return result
935
+ })
936
+
937
+ ipcMain.on('navigate-url-result', (event, { id, success, error }) => {
938
+ const pending = pendingNavigates.get(id)
939
+ if (!pending) return
940
+ clearTimeout(pending.timeout)
941
+ pendingNavigates.delete(id)
942
+ pending.resolve({ success, error })
943
943
  })
944
944
 
945
945
  // Tab management — forwarded to renderer
946
- ipcMain.handle('open-tab', async (event, url, session) => {
946
+ ipcMain.handle('open-tab', async (event, url) => {
947
947
  if (!mainWindow) return false
948
- if (session) pendingTabSession = session
949
- mainWindow.webContents.send('open-tab', { url, session })
948
+ mainWindow.webContents.send('open-tab', { url })
950
949
  return true
951
950
  })
952
951
 
@@ -1164,6 +1163,62 @@ function checkServerRunning() {
1164
1163
  })
1165
1164
  }
1166
1165
 
1166
+ /**
1167
+ * Spawn a single haltija server subprocess on a given port.
1168
+ * Stdout/stderr are piped to the desktop app's console with a label, and
1169
+ * `__NEED_WINDOW__` from the public server triggers window recreation.
1170
+ */
1171
+ function spawnHaltijaServer({ port, role, serverPath, useCompiledBinary, componentDir }) {
1172
+ const env = { ...process.env, PORT: port.toString(), HALTIJA_DESKTOP: '1' }
1173
+ let proc
1174
+ if (serverPath && useCompiledBinary) {
1175
+ proc = spawn(serverPath, [], {
1176
+ stdio: ['ignore', 'pipe', 'pipe'],
1177
+ cwd: componentDir || path.dirname(serverPath),
1178
+ env,
1179
+ })
1180
+ } else if (serverPath) {
1181
+ proc = spawn('bun', ['run', serverPath], {
1182
+ stdio: ['ignore', 'pipe', 'pipe'],
1183
+ env,
1184
+ })
1185
+ } else {
1186
+ proc = spawn('bunx', ['haltija', '--port', port.toString()], {
1187
+ stdio: ['ignore', 'pipe', 'pipe'],
1188
+ env,
1189
+ })
1190
+ }
1191
+
1192
+ const label = `[${role} server]`
1193
+
1194
+ proc.stdout.on('data', (data) => {
1195
+ try {
1196
+ const text = data.toString().trim()
1197
+ console.log(`${label} ${text}`)
1198
+ if (role === 'public' && text.includes('__NEED_WINDOW__') && BrowserWindow.getAllWindows().length === 0) {
1199
+ console.log('[Haltija Desktop] Server requested window, recreating...')
1200
+ createWindow()
1201
+ }
1202
+ } catch {}
1203
+ })
1204
+ proc.stderr.on('data', (data) => {
1205
+ try { console.error(`${label} ${data.toString().trim()}`) } catch {}
1206
+ })
1207
+ proc.stdout.on('error', () => {})
1208
+ proc.stderr.on('error', () => {})
1209
+ proc.on('error', (err) => {
1210
+ console.error(`[Haltija Desktop] Failed to start ${role} server:`, err)
1211
+ })
1212
+ proc.on('exit', (code) => {
1213
+ console.log(`[Haltija Desktop] ${role} server exited with code ${code}`)
1214
+ const idx = embeddedServers.indexOf(proc)
1215
+ if (idx !== -1) embeddedServers.splice(idx, 1)
1216
+ })
1217
+
1218
+ embeddedServers.push(proc)
1219
+ return proc
1220
+ }
1221
+
1167
1222
  /**
1168
1223
  * Start embedded Haltija server
1169
1224
  */
@@ -1204,7 +1259,6 @@ async function startEmbeddedServer() {
1204
1259
  serverPath || 'fallback to bunx',
1205
1260
  )
1206
1261
 
1207
- // Find component.js - server needs this to inject session ID
1208
1262
  const componentPaths = [
1209
1263
  path.join(process.resourcesPath || '', 'component.js'),
1210
1264
  path.join(__dirname, 'resources', 'component.js'),
@@ -1218,68 +1272,11 @@ async function startEmbeddedServer() {
1218
1272
  }
1219
1273
  }
1220
1274
 
1221
- if (serverPath && useCompiledBinary) {
1222
- // Run compiled standalone binary with cwd set to find component.js
1223
- embeddedServer = spawn(serverPath, [], {
1224
- stdio: ['ignore', 'pipe', 'pipe'],
1225
- cwd: componentDir || path.dirname(serverPath),
1226
- env: { ...process.env, PORT: HALTIJA_PORT.toString() },
1227
- })
1228
- } else if (serverPath) {
1229
- // Run with bun (development)
1230
- embeddedServer = spawn('bun', ['run', serverPath], {
1231
- stdio: ['ignore', 'pipe', 'pipe'],
1232
- env: { ...process.env, PORT: HALTIJA_PORT.toString() },
1233
- })
1234
- } else {
1235
- // Fallback to bunx haltija
1236
- embeddedServer = spawn(
1237
- 'bunx',
1238
- ['haltija', '--port', HALTIJA_PORT.toString()],
1239
- {
1240
- stdio: ['ignore', 'pipe', 'pipe'],
1241
- },
1242
- )
1243
- }
1244
-
1245
- embeddedServer.stdout.on('data', (data) => {
1246
- try {
1247
- const text = data.toString().trim()
1248
- console.log(`[Server] ${text}`)
1249
- // Server signals it needs a window but none exist (app alive, all windows closed).
1250
- // Re-create the main window so the agent has something to work with.
1251
- if (text.includes('__NEED_WINDOW__') && BrowserWindow.getAllWindows().length === 0) {
1252
- console.log('[Haltija Desktop] Server requested window, recreating...')
1253
- createWindow()
1254
- }
1255
- } catch (e) {
1256
- // Ignore write errors when app is closing
1257
- }
1258
- })
1259
-
1260
- embeddedServer.stderr.on('data', (data) => {
1261
- try {
1262
- console.error(`[Server] ${data.toString().trim()}`)
1263
- } catch (e) {
1264
- // Ignore write errors when app is closing
1265
- }
1266
- })
1267
-
1268
- embeddedServer.stdout.on('error', () => {})
1269
- embeddedServer.stderr.on('error', () => {})
1270
-
1271
- embeddedServer.on('error', (err) => {
1272
- console.error('[Haltija Desktop] Failed to start server:', err)
1273
- })
1275
+ spawnHaltijaServer({ port: HALTIJA_PORT, role: 'public', serverPath, useCompiledBinary, componentDir })
1276
+ spawnHaltijaServer({ port: HALTIJA_INTERNAL_PORT, role: 'internal', serverPath, useCompiledBinary, componentDir })
1274
1277
 
1275
- embeddedServer.on('exit', (code) => {
1276
- if (embeddedServer) {
1277
- console.log(`[Haltija Desktop] Server exited with code ${code}`)
1278
- embeddedServer = null
1279
- }
1280
- })
1281
-
1282
- // Wait for server to be ready
1278
+ // Wait for the public server to be ready (the internal one is best-effort —
1279
+ // the chrome widget will retry until it connects).
1283
1280
  for (let i = 0; i < 30; i++) {
1284
1281
  await new Promise((r) => setTimeout(r, 200))
1285
1282
  if (await checkServerRunning()) {
@@ -1295,49 +1292,48 @@ async function startEmbeddedServer() {
1295
1292
  /**
1296
1293
  * Ensure Haltija server is available
1297
1294
  */
1298
- async function killZombieServer() {
1299
- if (os.platform() === 'win32') return
1295
+ async function killZombieOnPort(port) {
1296
+ if (os.platform() === 'win32') return true
1300
1297
 
1301
1298
  const { execSync } = require('child_process')
1302
-
1303
- // Try up to 3 times to kill the process
1299
+
1304
1300
  for (let attempt = 1; attempt <= 3; attempt++) {
1305
1301
  try {
1306
- const pids = execSync(`lsof -ti:${HALTIJA_PORT} 2>/dev/null`, { encoding: 'utf-8' }).trim()
1302
+ const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: 'utf-8' }).trim()
1307
1303
  if (!pids) {
1308
- console.log(`[Haltija Desktop] Port ${HALTIJA_PORT} is free`)
1304
+ console.log(`[Haltija Desktop] Port ${port} is free`)
1309
1305
  return true
1310
1306
  }
1311
-
1312
- console.log(`[Haltija Desktop] Attempt ${attempt}: Killing process(es) on port ${HALTIJA_PORT}: ${pids.replace(/\n/g, ', ')}`)
1313
-
1314
- // Use SIGTERM first, then SIGKILL if needed
1307
+
1308
+ console.log(`[Haltija Desktop] Attempt ${attempt}: Killing process(es) on port ${port}: ${pids.replace(/\n/g, ', ')}`)
1309
+
1315
1310
  const signal = attempt < 3 ? '' : '-9'
1316
- execSync(`lsof -ti:${HALTIJA_PORT} | xargs kill ${signal} 2>/dev/null`, { encoding: 'utf-8' })
1317
-
1318
- // Wait for process to die
1311
+ execSync(`lsof -ti:${port} | xargs kill ${signal} 2>/dev/null`, { encoding: 'utf-8' })
1312
+
1319
1313
  await new Promise(r => setTimeout(r, 500 * attempt))
1320
-
1321
- // Check if port is now free
1314
+
1322
1315
  try {
1323
- execSync(`lsof -ti:${HALTIJA_PORT} 2>/dev/null`, { encoding: 'utf-8' })
1324
- // Still occupied, continue trying
1316
+ execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: 'utf-8' })
1325
1317
  } catch {
1326
- // No process found = success
1327
- console.log(`[Haltija Desktop] Port ${HALTIJA_PORT} freed successfully`)
1318
+ console.log(`[Haltija Desktop] Port ${port} freed successfully`)
1328
1319
  return true
1329
1320
  }
1330
1321
  } catch {
1331
- // lsof found nothing = port is free
1332
- console.log(`[Haltija Desktop] Port ${HALTIJA_PORT} is free`)
1322
+ console.log(`[Haltija Desktop] Port ${port} is free`)
1333
1323
  return true
1334
1324
  }
1335
1325
  }
1336
-
1337
- console.error(`[Haltija Desktop] Warning: Could not free port ${HALTIJA_PORT} after 3 attempts`)
1326
+
1327
+ console.error(`[Haltija Desktop] Warning: Could not free port ${port} after 3 attempts`)
1338
1328
  return false
1339
1329
  }
1340
1330
 
1331
+ async function killZombieServer() {
1332
+ const publicOk = await killZombieOnPort(HALTIJA_PORT)
1333
+ const internalOk = await killZombieOnPort(HALTIJA_INTERNAL_PORT)
1334
+ return publicOk && internalOk
1335
+ }
1336
+
1341
1337
  async function ensureServer() {
1342
1338
  const running = await checkServerRunning()
1343
1339
 
@@ -1391,6 +1387,13 @@ if (!gotTheLock) {
1391
1387
  // App lifecycle
1392
1388
  app.whenReady().then(async () => {
1393
1389
  console.log('[Haltija Desktop] App ready, starting initialization...')
1390
+
1391
+ // Clear the "manually quit" marker — user is explicitly starting Haltija,
1392
+ // so hj should resume auto-launching when needed.
1393
+ try {
1394
+ const quitMarker = path.join(os.homedir(), '.haltija', 'last-quit')
1395
+ if (fs.existsSync(quitMarker)) fs.rmSync(quitMarker, { force: true })
1396
+ } catch {}
1394
1397
 
1395
1398
  try {
1396
1399
  // Start or connect to server first
@@ -1441,12 +1444,20 @@ if (!gotTheLock) {
1441
1444
  })
1442
1445
 
1443
1446
  app.on('will-quit', () => {
1444
- // Kill embedded server when app quits
1445
- if (embeddedServer) {
1446
- console.log('[Haltija Desktop] Stopping embedded server')
1447
- embeddedServer.kill()
1448
- embeddedServer = null
1447
+ if (embeddedServers.length > 0) {
1448
+ console.log(`[Haltija Desktop] Stopping ${embeddedServers.length} embedded server(s)`)
1449
+ for (const proc of embeddedServers) {
1450
+ try { proc.kill() } catch {}
1451
+ }
1452
+ embeddedServers.length = 0
1449
1453
  }
1454
+ // Drop a marker so hj's auto-launch knows the user explicitly quit.
1455
+ // Cleared next time the user manually starts Haltija.
1456
+ try {
1457
+ const dir = path.join(os.homedir(), '.haltija')
1458
+ fs.mkdirSync(dir, { recursive: true })
1459
+ fs.writeFileSync(path.join(dir, 'last-quit'), String(Date.now()))
1460
+ } catch {}
1450
1461
  })
1451
1462
 
1452
1463
  // Handle certificate errors (for self-signed certs in dev)
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.3.0-beta.7",
3
+ "version": "1.3.0-beta.9",
4
+ "private": true,
4
5
  "description": "Haltija Desktop - God Mode Browser for AI Agents",
5
6
  "homepage": "https://github.com/tonioloewald/haltija",
6
7
  "author": {
@@ -43,9 +44,7 @@
43
44
  "extendInfo": {
44
45
  "CFBundleIconName": "AppIcon"
45
46
  },
46
- "notarize": {
47
- "teamId": "TSAD7CQ4HH"
48
- },
47
+ "notarize": true,
49
48
  "target": [
50
49
  {
51
50
  "target": "dmg",
@@ -13,6 +13,8 @@ const webviewPreloadPath = __dirname + '/webview-preload.js'
13
13
  contextBridge.exposeInMainWorld('haltija', {
14
14
  // Path to webview preload script
15
15
  webviewPreloadPath: webviewPreloadPath,
16
+ // Internal-server port (chrome widget connects here, hidden from public agent traffic)
17
+ internalPort: parseInt(process.env.HALTIJA_INTERNAL_PORT || '8701'),
16
18
  // Navigation
17
19
  navigate: (url) => ipcRenderer.send('navigate', url),
18
20
  goBack: () => ipcRenderer.send('go-back'),
@@ -62,7 +64,8 @@ contextBridge.exposeInMainWorld('haltija', {
62
64
 
63
65
  // Navigation (from widget via main process) - uses renderer's smart navigate with fallback
64
66
  onNavigateUrl: (callback) => ipcRenderer.on('navigate-url', (event, data) => callback(data)),
65
-
67
+ navigateUrlResult: (result) => ipcRenderer.send('navigate-url-result', result),
68
+
66
69
  // Tab management from widget (via main process)
67
70
  onOpenTab: (callback) => ipcRenderer.on('open-tab', (event, data) => callback(data)),
68
71
  onCloseTab: (callback) => ipcRenderer.on('close-tab', (event, data) => callback(data)),
@@ -357,14 +357,16 @@ export function getActiveWebview() {
357
357
 
358
358
  export function navigate(url, tabId = activeTabId) {
359
359
  const tab = tabs.find((t) => t.id === tabId)
360
- if (!tab) return
360
+ if (!tab) {
361
+ const error = `navigate: no tab found for id ${tabId}`
362
+ console.error('[Haltija]', error)
363
+ return { success: false, error }
364
+ }
361
365
 
362
- // Never navigate terminal/agent iframes — they aren't browser tabs
363
366
  if (tab.isTerminal) {
364
- console.warn('[Haltija] Refusing to navigate terminal tab, finding content tab instead')
365
- const contentTab = tabs.find(t => !t.isTerminal)
366
- if (contentTab) return navigate(url, contentTab.id)
367
- return
367
+ const error = `navigate: refusing to navigate terminal/agent tab ${tabId}`
368
+ console.error('[Haltija]', error)
369
+ return { success: false, error }
368
370
  }
369
371
 
370
372
  let addedHttps = false
@@ -406,6 +408,8 @@ export function navigate(url, tabId = activeTabId) {
406
408
  if (tabId === activeTabId && !tab.isTerminal) {
407
409
  el.urlInput.value = tab.url
408
410
  }
411
+
412
+ return { success: true }
409
413
  }
410
414
 
411
415
  export async function changeTerminalDirectory(tab, path) {
@@ -35,33 +35,31 @@ createTab()
35
35
  // Periodic status check
36
36
  setInterval(checkHaltija, 5000)
37
37
 
38
- // Inject outer widget (headless) into the renderer for persistence + self-inspection
39
- // Lives in the Electron chrome, survives page navigations, can inspect the app's own UI
38
+ // Inject outer widget (headless) into the renderer for persistence + self-inspection.
39
+ // Connects to the *internal* server (default port 8701) so it doesn't appear in
40
+ // agent-facing window lists on the public server. To inspect the outer Haltija
41
+ // UI from `hj`, target the internal port: HALTIJA_PORT=8701 hj tree
40
42
  ;(async function injectOuterWidget() {
41
- const serverUrl = getServerUrl()
42
- try {
43
- const resp = await fetch(`${serverUrl}/component.js`)
43
+ const internalPort = window.haltija?.internalPort || 8701
44
+ const internalUrl = getServerUrl().replace(/:\d+/, `:${internalPort}`)
45
+ const wsUrl = internalUrl.replace('http:', 'ws:') + '/ws/browser'
46
+ const tryInject = async () => {
47
+ const resp = await fetch(`${internalUrl}/component.js`)
44
48
  if (!resp.ok) throw new Error(`${resp.status}`)
45
- const wsUrl = serverUrl.replace('http:', 'ws:') + '/ws/browser'
46
- window.__haltija_config__ = { serverUrl: wsUrl, windowId: 'hj-chrome', mode: 'headless', session: 'chrome' }
49
+ window.__haltija_config__ = { serverUrl: wsUrl, windowId: 'hj-chrome', mode: 'headless' }
47
50
  const code = await resp.text()
48
51
  const script = document.createElement('script')
49
52
  script.textContent = code
50
53
  document.head.appendChild(script)
51
- console.log('[Haltija Desktop] Outer widget injected (headless, windowId: hj-chrome)')
54
+ }
55
+ try {
56
+ await tryInject()
57
+ console.log('[Haltija Desktop] Outer widget injected (internal port, windowId: hj-chrome)')
52
58
  } catch (err) {
53
- console.log('[Haltija Desktop] Outer widget deferred — server not yet ready:', err.message)
54
- // Retry once after server comes up (checkHaltija runs every 5s)
59
+ console.log('[Haltija Desktop] Outer widget deferred — internal server not ready:', err.message)
55
60
  const retry = setInterval(async () => {
56
61
  try {
57
- const resp = await fetch(`${serverUrl}/component.js`)
58
- if (!resp.ok) return
59
- const wsUrl = serverUrl.replace('http:', 'ws:') + '/ws/browser'
60
- window.__haltija_config__ = { serverUrl: wsUrl, windowId: 'hj-chrome', mode: 'headless', session: 'chrome' }
61
- const code = await resp.text()
62
- const script = document.createElement('script')
63
- script.textContent = code
64
- document.head.appendChild(script)
62
+ await tryInject()
65
63
  console.log('[Haltija Desktop] Outer widget injected (retry)')
66
64
  clearInterval(retry)
67
65
  } catch { /* keep retrying */ }
@@ -165,7 +163,7 @@ document.addEventListener('keydown', (e) => {
165
163
  // ============================================
166
164
 
167
165
  // Tab management from widget (via main process IPC)
168
- window.haltija?.onOpenTab?.((data) => createTab(data.url, { session: data.session }))
166
+ window.haltija?.onOpenTab?.((data) => createTab(data.url))
169
167
  window.haltija?.onCloseTab?.((data) => {
170
168
  const tab = findTabByWindowId(data.windowId)
171
169
  if (tab) closeTab(tab.id)
@@ -240,8 +238,9 @@ if (window.haltija) {
240
238
 
241
239
  window.haltija.onNavigateUrl?.((data) => {
242
240
  console.log('[Haltija Desktop] Navigate request from widget:', data.url, 'wcId:', data.webContentsId)
243
- // Find the tab that owns the webview that sent the navigate request
244
- // so we navigate the correct tab, not the active (possibly terminal) tab
241
+ // Resolve the tab that owns the webview which sent the navigate request.
242
+ // No fallback: silently routing to "some other tab" hides bugs and lets
243
+ // hj navigate report success when nothing actually navigated.
245
244
  let targetTabId = undefined
246
245
  if (data.webContentsId) {
247
246
  const match = tabs.find(t => {
@@ -251,12 +250,14 @@ if (window.haltija) {
251
250
  })
252
251
  if (match) targetTabId = match.id
253
252
  }
254
- // Fallback: navigate the first non-terminal tab (never navigate a terminal/agent iframe)
255
253
  if (!targetTabId) {
256
- const contentTab = tabs.find(t => !t.isTerminal)
257
- if (contentTab) targetTabId = contentTab.id
254
+ const error = `navigate: could not resolve target tab (webContentsId: ${data.webContentsId})`
255
+ console.error('[Haltija Desktop]', error)
256
+ window.haltija.navigateUrlResult?.({ id: data.id, success: false, error })
257
+ return
258
258
  }
259
- navigate(data.url, targetTabId)
259
+ const result = navigate(data.url, targetTabId)
260
+ window.haltija.navigateUrlResult?.({ id: data.id, success: result?.success !== false, error: result?.error })
260
261
  })
261
262
  }
262
263