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.
@@ -105,6 +105,6 @@
105
105
  </div>
106
106
  </div>
107
107
 
108
- <script src="renderer.js"></script>
108
+ <script type="module" src="renderer.js"></script>
109
109
  </body>
110
110
  </html>
@@ -13,7 +13,7 @@ const {
13
13
  BrowserWindow,
14
14
  session,
15
15
  ipcMain,
16
- desktopCapturer,
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
- trafficLightPosition: { x: 12, y: 12 },
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, level, message, line, sourceId) => {
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
- try {
1050
- const { execSync } = require('child_process')
1051
- const pids = execSync(`lsof -ti:${HALTIJA_PORT} 2>/dev/null`, { encoding: 'utf-8' }).trim()
1052
- if (pids) {
1053
- console.log(`[Haltija Desktop] Killing zombie process(es) on port ${HALTIJA_PORT}: ${pids.replace(/\n/g, ', ')}`)
1054
- execSync(`lsof -ti:${HALTIJA_PORT} | xargs kill 2>/dev/null`, { encoding: 'utf-8' })
1055
- await new Promise(r => setTimeout(r, 1000))
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
- // App lifecycle
1097
- app.whenReady().then(async () => {
1098
- // Start or connect to server first
1099
- const serverReady = await ensureServer()
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
- if (!serverReady) {
1102
- console.error(
1103
- '[Haltija Desktop] Could not start server. Install bun: https://bun.sh',
1104
- )
1105
- // Continue anyway - user might start server manually
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
- setupMenu()
1109
- setupHeaderStripping()
1110
- setupWidgetInjection()
1111
- setupScreenCapture()
1112
- createWindow()
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
- // Check for Claude Desktop MCP setup (after window is ready)
1115
- if (!hasSkippedMcpSetup()) {
1116
- // Small delay to let window fully render
1117
- setTimeout(() => checkAndPromptMcpSetup(), 1500)
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
- app.on('activate', () => {
1121
- if (BrowserWindow.getAllWindows().length === 0) {
1122
- createWindow()
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('window-all-closed', () => {
1128
- if (process.platform !== 'darwin') {
1129
- app.quit()
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.1.18",
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": "^28.0.0",
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": [
@@ -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
+ }