haltija 1.1.21 → 1.2.2
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/apps/desktop/index.html +1 -1
- package/apps/desktop/main.js +264 -64
- package/apps/desktop/package.json +11 -3
- package/apps/desktop/preload.js +17 -0
- package/apps/desktop/renderer/agent-status.js +210 -0
- package/apps/desktop/renderer/settings.js +55 -0
- package/apps/desktop/renderer/state.js +98 -0
- package/apps/desktop/renderer/status.js +38 -0
- package/apps/desktop/renderer/tabs.js +374 -0
- package/apps/desktop/renderer/ui-utils.js +180 -0
- package/apps/desktop/renderer/video-capture.js +154 -0
- package/apps/desktop/renderer/webview-events.js +225 -0
- package/apps/desktop/renderer.js +98 -1604
- package/apps/desktop/resources/component.js +265 -55
- package/apps/desktop/webview-preload.js +19 -1
- package/bin/cli-subcommand.mjs +90 -27
- package/bin/hints.json +9 -4
- package/bin/hj.mjs +61 -2
- package/bin/test-data.mjs +291 -0
- package/bin/tosijs-dev.mjs +95 -20
- package/dist/client.js +5 -1
- package/dist/component.js +265 -55
- package/dist/index.js +444 -76
- package/dist/server.js +444 -76
- package/package.json +2 -1
package/apps/desktop/index.html
CHANGED
package/apps/desktop/main.js
CHANGED
|
@@ -13,7 +13,7 @@ const {
|
|
|
13
13
|
BrowserWindow,
|
|
14
14
|
session,
|
|
15
15
|
ipcMain,
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
Menu,
|
|
18
18
|
dialog,
|
|
19
19
|
clipboard,
|
|
@@ -267,6 +267,7 @@ function createWindow() {
|
|
|
267
267
|
mainWindow = new BrowserWindow({
|
|
268
268
|
width: 1280,
|
|
269
269
|
height: 900,
|
|
270
|
+
show: false, // Don't show until content is ready
|
|
270
271
|
webPreferences: {
|
|
271
272
|
preload: path.join(__dirname, 'preload.js'),
|
|
272
273
|
nodeIntegration: false,
|
|
@@ -275,7 +276,12 @@ function createWindow() {
|
|
|
275
276
|
webviewTag: true, // Enable <webview> tag
|
|
276
277
|
},
|
|
277
278
|
titleBarStyle: 'hiddenInset', // Minimal chrome on macOS
|
|
278
|
-
|
|
279
|
+
windowButtonPosition: { x: 12, y: 12 },
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// Show window once shell UI is rendered (avoids flashing empty/intermediate states)
|
|
283
|
+
mainWindow.once('ready-to-show', () => {
|
|
284
|
+
mainWindow.show()
|
|
279
285
|
})
|
|
280
286
|
|
|
281
287
|
// Load the shell UI
|
|
@@ -652,10 +658,10 @@ function setupWebContentsInjection(wc) {
|
|
|
652
658
|
})
|
|
653
659
|
|
|
654
660
|
// Capture console messages from the webview
|
|
655
|
-
wc.on('console-message', (event
|
|
661
|
+
wc.on('console-message', (event) => {
|
|
656
662
|
try {
|
|
657
|
-
const prefix = level === 2 ? 'WARN' : level === 3 ? 'ERROR' : 'LOG'
|
|
658
|
-
console.log(`[Webview ${prefix}] ${message}`)
|
|
663
|
+
const prefix = event.level === 2 ? 'WARN' : event.level === 3 ? 'ERROR' : 'LOG'
|
|
664
|
+
console.log(`[Webview ${prefix}] ${event.message}`)
|
|
659
665
|
} catch (e) {
|
|
660
666
|
// Ignore write errors when app is closing
|
|
661
667
|
}
|
|
@@ -680,6 +686,14 @@ function setupWebContentsInjection(wc) {
|
|
|
680
686
|
// Ignore errors when app is closing
|
|
681
687
|
}
|
|
682
688
|
})
|
|
689
|
+
|
|
690
|
+
// Handle beforeunload dialogs — allow navigation by default so agents aren't blocked.
|
|
691
|
+
// The component's dialog policy (configurable via /dialog/configure) controls this,
|
|
692
|
+
// but as a safety net, Electron's will-prevent-unload always allows navigation.
|
|
693
|
+
wc.on('will-prevent-unload', (event) => {
|
|
694
|
+
console.log('[Haltija Desktop] Preventing beforeunload block for:', wc.getURL())
|
|
695
|
+
event.preventDefault() // Tells Electron to proceed with navigation despite beforeunload
|
|
696
|
+
})
|
|
683
697
|
}
|
|
684
698
|
|
|
685
699
|
async function injectWidget(webContents) {
|
|
@@ -867,6 +881,133 @@ function setupScreenCapture() {
|
|
|
867
881
|
}
|
|
868
882
|
})
|
|
869
883
|
|
|
884
|
+
// Tab management — forwarded to renderer
|
|
885
|
+
ipcMain.handle('open-tab', async (event, url) => {
|
|
886
|
+
if (!mainWindow) return false
|
|
887
|
+
mainWindow.webContents.send('open-tab', { url })
|
|
888
|
+
return true
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
ipcMain.handle('close-tab', async (event, windowId) => {
|
|
892
|
+
if (!mainWindow) return false
|
|
893
|
+
mainWindow.webContents.send('close-tab', { windowId })
|
|
894
|
+
return true
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
ipcMain.handle('focus-tab', async (event, windowId) => {
|
|
898
|
+
if (!mainWindow) return false
|
|
899
|
+
mainWindow.webContents.send('focus-tab', { windowId })
|
|
900
|
+
return true
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
// Video capture — forwarded to renderer where MediaRecorder runs
|
|
904
|
+
// Widget (webview) → main → renderer → main → widget
|
|
905
|
+
// Main also provides media source ID from webContents (not available on webview DOM element)
|
|
906
|
+
ipcMain.handle('get-media-source-id', async (event, webContentsId) => {
|
|
907
|
+
try {
|
|
908
|
+
const { webContents } = require('electron')
|
|
909
|
+
const wc = webContents.fromId(webContentsId)
|
|
910
|
+
if (!wc) return null
|
|
911
|
+
// getMediaSourceId requires the requesting WebContents as argument —
|
|
912
|
+
// i.e. the renderer that will call getUserMedia with the returned ID
|
|
913
|
+
const requestingWc = event.sender
|
|
914
|
+
return wc.getMediaSourceId(requestingWc)
|
|
915
|
+
} catch (err) {
|
|
916
|
+
console.error('[Haltija Desktop] Failed to get media source ID:', err.message)
|
|
917
|
+
return null
|
|
918
|
+
}
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
// Video file streaming — renderer sends chunks, main writes to disk
|
|
922
|
+
const activeVideoFiles = new Map() // recordingId -> { fd, path, size }
|
|
923
|
+
|
|
924
|
+
ipcMain.handle('video-file-create', async () => {
|
|
925
|
+
try {
|
|
926
|
+
const dir = '/tmp/haltija-videos'
|
|
927
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
928
|
+
const shortId = Math.random().toString(36).slice(2, 6)
|
|
929
|
+
const recordingId = `vid-${Date.now().toString(36)}-${shortId}`
|
|
930
|
+
const filepath = `${dir}/hj-${Date.now()}-${shortId}.webm`
|
|
931
|
+
const fd = fs.openSync(filepath, 'w')
|
|
932
|
+
activeVideoFiles.set(recordingId, { fd, path: filepath, size: 0 })
|
|
933
|
+
return { success: true, recordingId }
|
|
934
|
+
} catch (err) {
|
|
935
|
+
return { success: false, error: err.message }
|
|
936
|
+
}
|
|
937
|
+
})
|
|
938
|
+
|
|
939
|
+
ipcMain.on('video-file-chunk', (event, recordingId, buffer) => {
|
|
940
|
+
const file = activeVideoFiles.get(recordingId)
|
|
941
|
+
if (!file) return
|
|
942
|
+
try {
|
|
943
|
+
const data = Buffer.from(buffer)
|
|
944
|
+
fs.writeSync(file.fd, data)
|
|
945
|
+
file.size += data.length
|
|
946
|
+
} catch (err) {
|
|
947
|
+
console.error('[Haltija Desktop] Failed to write video chunk:', err.message)
|
|
948
|
+
}
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
ipcMain.handle('video-file-close', async (event, recordingId, duration) => {
|
|
952
|
+
const file = activeVideoFiles.get(recordingId)
|
|
953
|
+
if (!file) return { success: false, error: 'No active recording with that ID' }
|
|
954
|
+
try {
|
|
955
|
+
fs.closeSync(file.fd)
|
|
956
|
+
activeVideoFiles.delete(recordingId)
|
|
957
|
+
return { success: true, path: file.path, duration, size: file.size, format: 'webm' }
|
|
958
|
+
} catch (err) {
|
|
959
|
+
activeVideoFiles.delete(recordingId)
|
|
960
|
+
return { success: false, error: err.message }
|
|
961
|
+
}
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
// Video start/stop/status — forwarded to renderer where MediaRecorder runs
|
|
965
|
+
ipcMain.handle('video-start', async (event, opts) => {
|
|
966
|
+
if (!mainWindow) return { success: false, error: 'No main window' }
|
|
967
|
+
mainWindow.webContents.send('video-start', { ...opts, senderWebContentsId: event.sender.id })
|
|
968
|
+
return new Promise((resolve) => {
|
|
969
|
+
const timeout = setTimeout(() => resolve({ success: false, error: 'Video start timed out' }), 10000)
|
|
970
|
+
ipcMain.once('video-start-result', (_, result) => {
|
|
971
|
+
clearTimeout(timeout)
|
|
972
|
+
resolve(result)
|
|
973
|
+
})
|
|
974
|
+
})
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
ipcMain.handle('video-stop', async (event) => {
|
|
978
|
+
if (!mainWindow) return { success: false, error: 'No main window' }
|
|
979
|
+
mainWindow.webContents.send('video-stop', { senderWebContentsId: event.sender.id })
|
|
980
|
+
return new Promise((resolve) => {
|
|
981
|
+
const timeout = setTimeout(() => resolve({ success: false, error: 'Video stop timed out' }), 30000)
|
|
982
|
+
ipcMain.once('video-stop-result', (_, result) => {
|
|
983
|
+
clearTimeout(timeout)
|
|
984
|
+
resolve(result)
|
|
985
|
+
})
|
|
986
|
+
})
|
|
987
|
+
})
|
|
988
|
+
|
|
989
|
+
ipcMain.handle('video-status', async (event) => {
|
|
990
|
+
if (!mainWindow) return { recording: false }
|
|
991
|
+
mainWindow.webContents.send('video-status', { senderWebContentsId: event.sender.id })
|
|
992
|
+
return new Promise((resolve) => {
|
|
993
|
+
const timeout = setTimeout(() => resolve({ recording: false }), 5000)
|
|
994
|
+
ipcMain.once('video-status-result', (_, result) => {
|
|
995
|
+
clearTimeout(timeout)
|
|
996
|
+
resolve(result)
|
|
997
|
+
})
|
|
998
|
+
})
|
|
999
|
+
})
|
|
1000
|
+
|
|
1001
|
+
// Hard refresh — bypasses all caches (called from widget in webview)
|
|
1002
|
+
ipcMain.handle('hard-refresh', async (event) => {
|
|
1003
|
+
const wc = event.sender
|
|
1004
|
+
if (wc) {
|
|
1005
|
+
wc.reloadIgnoringCache()
|
|
1006
|
+
return { success: true }
|
|
1007
|
+
}
|
|
1008
|
+
return { success: false, error: 'No webContents' }
|
|
1009
|
+
})
|
|
1010
|
+
|
|
870
1011
|
// Create a new agent tab (called from widget in webview)
|
|
871
1012
|
ipcMain.handle('open-agent-tab', async (event) => {
|
|
872
1013
|
if (!mainWindow) return { error: 'No window' }
|
|
@@ -1046,17 +1187,44 @@ async function startEmbeddedServer() {
|
|
|
1046
1187
|
async function killZombieServer() {
|
|
1047
1188
|
if (os.platform() === 'win32') return
|
|
1048
1189
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
execSync(`lsof -ti:${HALTIJA_PORT}
|
|
1055
|
-
|
|
1190
|
+
const { execSync } = require('child_process')
|
|
1191
|
+
|
|
1192
|
+
// Try up to 3 times to kill the process
|
|
1193
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
1194
|
+
try {
|
|
1195
|
+
const pids = execSync(`lsof -ti:${HALTIJA_PORT} 2>/dev/null`, { encoding: 'utf-8' }).trim()
|
|
1196
|
+
if (!pids) {
|
|
1197
|
+
console.log(`[Haltija Desktop] Port ${HALTIJA_PORT} is free`)
|
|
1198
|
+
return true
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
console.log(`[Haltija Desktop] Attempt ${attempt}: Killing process(es) on port ${HALTIJA_PORT}: ${pids.replace(/\n/g, ', ')}`)
|
|
1202
|
+
|
|
1203
|
+
// Use SIGTERM first, then SIGKILL if needed
|
|
1204
|
+
const signal = attempt < 3 ? '' : '-9'
|
|
1205
|
+
execSync(`lsof -ti:${HALTIJA_PORT} | xargs kill ${signal} 2>/dev/null`, { encoding: 'utf-8' })
|
|
1206
|
+
|
|
1207
|
+
// Wait for process to die
|
|
1208
|
+
await new Promise(r => setTimeout(r, 500 * attempt))
|
|
1209
|
+
|
|
1210
|
+
// Check if port is now free
|
|
1211
|
+
try {
|
|
1212
|
+
execSync(`lsof -ti:${HALTIJA_PORT} 2>/dev/null`, { encoding: 'utf-8' })
|
|
1213
|
+
// Still occupied, continue trying
|
|
1214
|
+
} catch {
|
|
1215
|
+
// No process found = success
|
|
1216
|
+
console.log(`[Haltija Desktop] Port ${HALTIJA_PORT} freed successfully`)
|
|
1217
|
+
return true
|
|
1218
|
+
}
|
|
1219
|
+
} catch {
|
|
1220
|
+
// lsof found nothing = port is free
|
|
1221
|
+
console.log(`[Haltija Desktop] Port ${HALTIJA_PORT} is free`)
|
|
1222
|
+
return true
|
|
1056
1223
|
}
|
|
1057
|
-
} catch {
|
|
1058
|
-
// No processes found or kill failed — proceed
|
|
1059
1224
|
}
|
|
1225
|
+
|
|
1226
|
+
console.error(`[Haltija Desktop] Warning: Could not free port ${HALTIJA_PORT} after 3 attempts`)
|
|
1227
|
+
return false
|
|
1060
1228
|
}
|
|
1061
1229
|
|
|
1062
1230
|
async function ensureServer() {
|
|
@@ -1093,61 +1261,93 @@ async function ensureServer() {
|
|
|
1093
1261
|
}
|
|
1094
1262
|
}
|
|
1095
1263
|
|
|
1096
|
-
//
|
|
1097
|
-
app.
|
|
1098
|
-
|
|
1099
|
-
|
|
1264
|
+
// Single-instance lock — prevent multiple Electron windows from launching
|
|
1265
|
+
const gotTheLock = app.requestSingleInstanceLock()
|
|
1266
|
+
|
|
1267
|
+
if (!gotTheLock) {
|
|
1268
|
+
console.log('[Haltija Desktop] Another instance is already running. Focusing existing window.')
|
|
1269
|
+
app.quit()
|
|
1270
|
+
} else {
|
|
1271
|
+
app.on('second-instance', () => {
|
|
1272
|
+
// Focus the existing window when user tries to launch again
|
|
1273
|
+
const win = BrowserWindow.getAllWindows()[0]
|
|
1274
|
+
if (win) {
|
|
1275
|
+
if (win.isMinimized()) win.restore()
|
|
1276
|
+
win.focus()
|
|
1277
|
+
}
|
|
1278
|
+
})
|
|
1100
1279
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1280
|
+
// App lifecycle
|
|
1281
|
+
app.whenReady().then(async () => {
|
|
1282
|
+
console.log('[Haltija Desktop] App ready, starting initialization...')
|
|
1283
|
+
|
|
1284
|
+
try {
|
|
1285
|
+
// Start or connect to server first
|
|
1286
|
+
const serverReady = await ensureServer()
|
|
1287
|
+
|
|
1288
|
+
if (!serverReady) {
|
|
1289
|
+
console.error(
|
|
1290
|
+
'[Haltija Desktop] Could not start server. Install bun: https://bun.sh',
|
|
1291
|
+
)
|
|
1292
|
+
// Continue anyway - user might start server manually
|
|
1293
|
+
}
|
|
1107
1294
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1295
|
+
setupMenu()
|
|
1296
|
+
setupHeaderStripping()
|
|
1297
|
+
setupWidgetInjection()
|
|
1298
|
+
setupScreenCapture()
|
|
1299
|
+
|
|
1300
|
+
console.log('[Haltija Desktop] Creating main window...')
|
|
1301
|
+
createWindow()
|
|
1302
|
+
console.log('[Haltija Desktop] Window created successfully')
|
|
1303
|
+
} catch (err) {
|
|
1304
|
+
console.error('[Haltija Desktop] Fatal error during startup:', err)
|
|
1305
|
+
// Still try to create a window so user sees something
|
|
1306
|
+
try {
|
|
1307
|
+
createWindow()
|
|
1308
|
+
} catch (windowErr) {
|
|
1309
|
+
console.error('[Haltija Desktop] Failed to create window:', windowErr)
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1113
1312
|
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1313
|
+
// Check for Claude Desktop MCP setup (after window is ready)
|
|
1314
|
+
if (!hasSkippedMcpSetup()) {
|
|
1315
|
+
// Small delay to let window fully render
|
|
1316
|
+
setTimeout(() => checkAndPromptMcpSetup(), 1500)
|
|
1317
|
+
}
|
|
1119
1318
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1319
|
+
app.on('activate', () => {
|
|
1320
|
+
if (BrowserWindow.getAllWindows().length === 0) {
|
|
1321
|
+
createWindow()
|
|
1322
|
+
}
|
|
1323
|
+
})
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
app.on('window-all-closed', () => {
|
|
1327
|
+
if (process.platform !== 'darwin') {
|
|
1328
|
+
app.quit()
|
|
1123
1329
|
}
|
|
1124
1330
|
})
|
|
1125
|
-
})
|
|
1126
1331
|
|
|
1127
|
-
app.on('
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
app.on('will-quit', () => {
|
|
1134
|
-
// Kill embedded server when app quits
|
|
1135
|
-
if (embeddedServer) {
|
|
1136
|
-
console.log('[Haltija Desktop] Stopping embedded server')
|
|
1137
|
-
embeddedServer.kill()
|
|
1138
|
-
embeddedServer = null
|
|
1139
|
-
}
|
|
1140
|
-
})
|
|
1141
|
-
|
|
1142
|
-
// Handle certificate errors (for self-signed certs in dev)
|
|
1143
|
-
app.on(
|
|
1144
|
-
'certificate-error',
|
|
1145
|
-
(event, webContents, url, error, certificate, callback) => {
|
|
1146
|
-
if (url.startsWith('https://localhost')) {
|
|
1147
|
-
event.preventDefault()
|
|
1148
|
-
callback(true)
|
|
1149
|
-
} else {
|
|
1150
|
-
callback(false)
|
|
1332
|
+
app.on('will-quit', () => {
|
|
1333
|
+
// Kill embedded server when app quits
|
|
1334
|
+
if (embeddedServer) {
|
|
1335
|
+
console.log('[Haltija Desktop] Stopping embedded server')
|
|
1336
|
+
embeddedServer.kill()
|
|
1337
|
+
embeddedServer = null
|
|
1151
1338
|
}
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1339
|
+
})
|
|
1340
|
+
|
|
1341
|
+
// Handle certificate errors (for self-signed certs in dev)
|
|
1342
|
+
app.on(
|
|
1343
|
+
'certificate-error',
|
|
1344
|
+
(event, webContents, url, error, certificate, callback) => {
|
|
1345
|
+
if (url.startsWith('https://localhost')) {
|
|
1346
|
+
event.preventDefault()
|
|
1347
|
+
callback(true)
|
|
1348
|
+
} else {
|
|
1349
|
+
callback(false)
|
|
1350
|
+
}
|
|
1351
|
+
},
|
|
1352
|
+
)
|
|
1353
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "haltija-desktop",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "Haltija Desktop - God Mode Browser for AI Agents",
|
|
5
5
|
"homepage": "https://github.com/tonioloewald/haltija",
|
|
6
6
|
"author": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"license": "Apache-2.0",
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"electron": "^
|
|
27
|
+
"electron": "^40.6.1",
|
|
28
28
|
"electron-builder": "^24.9.1"
|
|
29
29
|
},
|
|
30
30
|
"build": {
|
|
@@ -40,6 +40,9 @@
|
|
|
40
40
|
"gatekeeperAssess": false,
|
|
41
41
|
"entitlements": "entitlements.mac.plist",
|
|
42
42
|
"entitlementsInherit": "entitlements.mac.plist",
|
|
43
|
+
"extendInfo": {
|
|
44
|
+
"CFBundleIconName": "AppIcon"
|
|
45
|
+
},
|
|
43
46
|
"notarize": {
|
|
44
47
|
"teamId": "TSAD7CQ4HH"
|
|
45
48
|
},
|
|
@@ -83,7 +86,8 @@
|
|
|
83
86
|
"index.html",
|
|
84
87
|
"terminal.html",
|
|
85
88
|
"styles.css",
|
|
86
|
-
"renderer.js"
|
|
89
|
+
"renderer.js",
|
|
90
|
+
"renderer/**/*.js"
|
|
87
91
|
],
|
|
88
92
|
"extraResources": [
|
|
89
93
|
{
|
|
@@ -101,6 +105,10 @@
|
|
|
101
105
|
{
|
|
102
106
|
"from": "resources/icon.svg",
|
|
103
107
|
"to": "icon.svg"
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"from": "resources/Assets.car",
|
|
111
|
+
"to": "Assets.car"
|
|
104
112
|
}
|
|
105
113
|
],
|
|
106
114
|
"asarUnpack": [
|
package/apps/desktop/preload.js
CHANGED
|
@@ -61,6 +61,23 @@ contextBridge.exposeInMainWorld('haltija', {
|
|
|
61
61
|
// Navigation (from widget via main process) - uses renderer's smart navigate with fallback
|
|
62
62
|
onNavigateUrl: (callback) => ipcRenderer.on('navigate-url', (event, data) => callback(data)),
|
|
63
63
|
|
|
64
|
+
// Tab management from widget (via main process)
|
|
65
|
+
onOpenTab: (callback) => ipcRenderer.on('open-tab', (event, data) => callback(data)),
|
|
66
|
+
onCloseTab: (callback) => ipcRenderer.on('close-tab', (event, data) => callback(data)),
|
|
67
|
+
onFocusTab: (callback) => ipcRenderer.on('focus-tab', (event, data) => callback(data)),
|
|
68
|
+
|
|
69
|
+
// Video capture — renderer streams chunks directly to disk via main process
|
|
70
|
+
getMediaSourceId: (webContentsId) => ipcRenderer.invoke('get-media-source-id', webContentsId),
|
|
71
|
+
videoFileCreate: () => ipcRenderer.invoke('video-file-create'),
|
|
72
|
+
videoFileChunk: (recordingId, buffer) => ipcRenderer.send('video-file-chunk', recordingId, buffer),
|
|
73
|
+
videoFileClose: (recordingId, duration) => ipcRenderer.invoke('video-file-close', recordingId, duration),
|
|
74
|
+
onVideoStart: (callback) => ipcRenderer.on('video-start', (event, data) => callback(data)),
|
|
75
|
+
onVideoStop: (callback) => ipcRenderer.on('video-stop', (event, data) => callback(data)),
|
|
76
|
+
onVideoStatus: (callback) => ipcRenderer.on('video-status', (event, data) => callback(data)),
|
|
77
|
+
videoStartResult: (result) => ipcRenderer.send('video-start-result', result),
|
|
78
|
+
videoStopResult: (result) => ipcRenderer.send('video-stop-result', result),
|
|
79
|
+
videoStatusResult: (result) => ipcRenderer.send('video-status-result', result),
|
|
80
|
+
|
|
64
81
|
// Window management
|
|
65
82
|
closeWindow: () => ipcRenderer.send('close-window'),
|
|
66
83
|
})
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent status bar — WebSocket connection, status rendering, memos panel.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { tabs, el, getServerUrl } from './state.js'
|
|
6
|
+
import { escapeHtml, createFloatPanel } from './ui-utils.js'
|
|
7
|
+
|
|
8
|
+
let agentStatusWs = null
|
|
9
|
+
let connectedShells = new Map()
|
|
10
|
+
|
|
11
|
+
// Exposed so tabs.js can check it
|
|
12
|
+
window._currentStatusLine = ''
|
|
13
|
+
|
|
14
|
+
function connectAgentStatusWs() {
|
|
15
|
+
if (agentStatusWs && agentStatusWs.readyState === WebSocket.OPEN) return
|
|
16
|
+
|
|
17
|
+
const wsUrl = `ws://localhost:${window.haltija?.port || 8700}/ws/terminal`
|
|
18
|
+
agentStatusWs = new WebSocket(wsUrl)
|
|
19
|
+
|
|
20
|
+
agentStatusWs.onopen = () => {
|
|
21
|
+
console.log('[Agent Status] Connected to terminal WebSocket')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
agentStatusWs.onmessage = (event) => {
|
|
25
|
+
try {
|
|
26
|
+
const msg = JSON.parse(event.data)
|
|
27
|
+
handleAgentStatusMessage(msg)
|
|
28
|
+
} catch (err) { /* Ignore non-JSON */ }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
agentStatusWs.onclose = () => {
|
|
32
|
+
console.log('[Agent Status] WebSocket closed, reconnecting in 3s...')
|
|
33
|
+
setTimeout(connectAgentStatusWs, 3000)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
agentStatusWs.onerror = () => {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function handleAgentStatusMessage(msg) {
|
|
40
|
+
switch (msg.type) {
|
|
41
|
+
case 'status':
|
|
42
|
+
window._currentStatusLine = msg.line || ''
|
|
43
|
+
renderAgentStatusBar(window._currentStatusLine)
|
|
44
|
+
break
|
|
45
|
+
case 'shell-joined':
|
|
46
|
+
connectedShells.set(msg.shellId, { name: msg.name, isAgent: msg.name?.includes('agent') })
|
|
47
|
+
updateAgentSelector()
|
|
48
|
+
break
|
|
49
|
+
case 'shell-left':
|
|
50
|
+
connectedShells.delete(msg.shellId)
|
|
51
|
+
updateAgentSelector()
|
|
52
|
+
break
|
|
53
|
+
case 'shell-renamed':
|
|
54
|
+
if (connectedShells.has(msg.shellId)) {
|
|
55
|
+
connectedShells.get(msg.shellId).name = msg.name
|
|
56
|
+
updateAgentSelector()
|
|
57
|
+
}
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function renderAgentStatusBar(line) {
|
|
63
|
+
if (!line) {
|
|
64
|
+
el.agentStatusBar.classList.add('hidden')
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Only show for terminal tabs
|
|
69
|
+
const { getActiveTab } = window._tabs
|
|
70
|
+
const activeTab = getActiveTab()
|
|
71
|
+
if (activeTab && !activeTab.isTerminal) {
|
|
72
|
+
el.agentStatusBar.classList.add('hidden')
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
el.agentStatusBar.classList.remove('hidden')
|
|
77
|
+
|
|
78
|
+
const segments = line.split(' | ')
|
|
79
|
+
let html = ''
|
|
80
|
+
|
|
81
|
+
for (const segment of segments) {
|
|
82
|
+
const trimmed = segment.trim()
|
|
83
|
+
if (!trimmed) continue
|
|
84
|
+
|
|
85
|
+
let label = ''
|
|
86
|
+
let value = trimmed
|
|
87
|
+
|
|
88
|
+
if (trimmed.startsWith('hj ')) {
|
|
89
|
+
const rest = trimmed.substring(3)
|
|
90
|
+
const words = rest.split(' ')
|
|
91
|
+
if (words.length > 1 && /^(memos|board|tasks)$/.test(words[0])) {
|
|
92
|
+
label = words[0]
|
|
93
|
+
value = words.slice(1).join(' ')
|
|
94
|
+
} else {
|
|
95
|
+
label = 'hj'
|
|
96
|
+
value = rest
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let cls = 'status-segment'
|
|
101
|
+
if (/fail|error|no browser/i.test(trimmed)) cls += ' error'
|
|
102
|
+
else if (/warn|blocked|pending/i.test(trimmed)) cls += ' alert'
|
|
103
|
+
else if (/ready|connected|pass|active/i.test(trimmed)) cls += ' ok'
|
|
104
|
+
|
|
105
|
+
html += `<div class="${cls}" data-segment="${escapeHtml(label || 'status')}">`
|
|
106
|
+
if (label) html += `<span class="label">${escapeHtml(label)}:</span>`
|
|
107
|
+
html += `<span class="value">${escapeHtml(value)}</span>`
|
|
108
|
+
html += `</div>`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
el.agentStatusItems.innerHTML = html
|
|
112
|
+
|
|
113
|
+
el.agentStatusItems.querySelectorAll('.status-segment').forEach(seg => {
|
|
114
|
+
seg.addEventListener('click', (e) => {
|
|
115
|
+
handleStatusSegmentClick(seg.dataset.segment, e.target)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function handleStatusSegmentClick(segmentName, target) {
|
|
121
|
+
if (segmentName === 'memos') showMemosPanel(target)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function showMemosPanel(target) {
|
|
125
|
+
const content = document.createElement('div')
|
|
126
|
+
content.className = 'memos-panel-content'
|
|
127
|
+
content.innerHTML = '<div class="loading">Loading memos...</div>'
|
|
128
|
+
|
|
129
|
+
const panel = createFloatPanel({ target, content, title: 'Memos', position: 's' })
|
|
130
|
+
if (!panel) return
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const resp = await fetch(`${getServerUrl()}/terminal/command`, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'Content-Type': 'application/json' },
|
|
136
|
+
body: JSON.stringify({ tool: 'tasks', command: 'board' })
|
|
137
|
+
})
|
|
138
|
+
const result = await resp.json()
|
|
139
|
+
|
|
140
|
+
if (result.boardJson?.items) {
|
|
141
|
+
renderMemosPanel(content, result.boardJson.items)
|
|
142
|
+
} else {
|
|
143
|
+
content.innerHTML = '<div class="empty">No memos</div>'
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
content.innerHTML = `<div class="error">Failed to load memos: ${escapeHtml(err.message)}</div>`
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function renderMemosPanel(container, items) {
|
|
151
|
+
const columns = ['in_progress', 'blocked', 'queued', 'review']
|
|
152
|
+
const columnNames = {
|
|
153
|
+
in_progress: '\uD83D\uDD04 In Progress',
|
|
154
|
+
blocked: '\uD83D\uDEA7 Blocked',
|
|
155
|
+
queued: '\uD83D\uDCCB Queued',
|
|
156
|
+
review: '\uD83D\uDC40 Review'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let html = '<div class="memos-list">'
|
|
160
|
+
|
|
161
|
+
for (const col of columns) {
|
|
162
|
+
const colItems = items.filter(i => i.column === col)
|
|
163
|
+
if (colItems.length === 0) continue
|
|
164
|
+
|
|
165
|
+
html += `<div class="memos-column">
|
|
166
|
+
<div class="memos-column-header">${columnNames[col]} (${colItems.length})</div>`
|
|
167
|
+
|
|
168
|
+
for (const item of colItems) {
|
|
169
|
+
html += `<div class="memo-item" data-id="${item.id}">
|
|
170
|
+
<span class="memo-title">${escapeHtml(item.title)}</span>
|
|
171
|
+
</div>`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
html += '</div>'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (html === '<div class="memos-list">') {
|
|
178
|
+
html += '<div class="empty">No active memos</div>'
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
html += '</div>'
|
|
182
|
+
container.innerHTML = html
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function updateAgentSelector() {
|
|
186
|
+
const agents = Array.from(connectedShells.entries())
|
|
187
|
+
.filter(([_, info]) => info.isAgent)
|
|
188
|
+
|
|
189
|
+
if (agents.length === 0) {
|
|
190
|
+
el.agentSelect.innerHTML = '<option value="">No agents</option>'
|
|
191
|
+
} else {
|
|
192
|
+
el.agentSelect.innerHTML = agents.map(([id, info]) =>
|
|
193
|
+
`<option value="${id}">${escapeHtml(info.name || id)}</option>`
|
|
194
|
+
).join('')
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function initAgentStatusBar() {
|
|
199
|
+
try {
|
|
200
|
+
const response = await fetch(`${getServerUrl()}/terminal/status`)
|
|
201
|
+
if (response.ok) {
|
|
202
|
+
const line = await response.text()
|
|
203
|
+
renderAgentStatusBar(line)
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
console.log('[Agent Status] Could not fetch initial status:', err.message)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
connectAgentStatusWs()
|
|
210
|
+
}
|