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 +80 -2
- package/apps/desktop/main.js +140 -129
- package/apps/desktop/package.json +3 -4
- package/apps/desktop/preload.js +4 -1
- package/apps/desktop/renderer/tabs.js +10 -6
- package/apps/desktop/renderer.js +26 -25
- package/apps/desktop/resources/component.js +11 -66
- package/bin/cli-subcommand.mjs +55 -33
- package/bin/hj.mjs +60 -7
- package/bin/tosijs-dev.mjs +27 -5
- package/dist/component.js +11 -66
- package/dist/hj.js +92 -26
- package/dist/index.js +252 -341
- package/dist/server.js +252 -341
- package/docs/AGENTIC-IDE.md +10 -8
- package/package.json +1 -1
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
|
|
144
|
+
### Multi-Window
|
|
145
145
|
|
|
146
|
-
Control multiple tabs.
|
|
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
|
package/apps/desktop/main.js
CHANGED
|
@@ -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
|
|
41
|
-
//
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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)
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
|
946
|
+
ipcMain.handle('open-tab', async (event, url) => {
|
|
947
947
|
if (!mainWindow) return false
|
|
948
|
-
|
|
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
|
-
|
|
1222
|
-
|
|
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
|
-
|
|
1276
|
-
|
|
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
|
|
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:${
|
|
1302
|
+
const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: 'utf-8' }).trim()
|
|
1307
1303
|
if (!pids) {
|
|
1308
|
-
console.log(`[Haltija Desktop] Port ${
|
|
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 ${
|
|
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:${
|
|
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:${
|
|
1324
|
-
// Still occupied, continue trying
|
|
1316
|
+
execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: 'utf-8' })
|
|
1325
1317
|
} catch {
|
|
1326
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
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.
|
|
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",
|
package/apps/desktop/preload.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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) {
|
package/apps/desktop/renderer.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
244
|
-
//
|
|
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
|
|
257
|
-
|
|
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
|
|