ai-or-die 0.1.18 → 0.1.19

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.
@@ -104,6 +104,7 @@ The main application controller. Instantiated once on page load.
104
104
  - **Connection:** Constructs the URL with the session token via `authManager.getWebSocketUrl()`. Reconnects automatically with exponential backoff up to `maxReconnectAttempts`.
105
105
  - **Message handling:** Routes incoming messages by `type` field to appropriate handlers (output rendering, session state updates, usage updates, etc.).
106
106
  - **Output rendering:** Writes raw terminal data directly to xterm.js via `terminal.write(data)`. Also feeds data to `planDetector.processOutput(data)` and `sessionTabManager.markSessionActivity()`.
107
+ - **Background session events:** Handles `session_activity`, `session_exit`, `session_error`, `session_started`, and `session_stopped` messages for sessions the client is not actively joined to. These update tab status indicators and feed the notification idle timer. These handlers never modify the terminal or show overlays — they only interact with `SessionTabManager`.
107
108
 
108
109
  ### Terminal Configuration
109
110
 
@@ -240,10 +241,12 @@ When a session transitions from `active` to `idle` in a background tab (not the
240
241
 
241
242
  - Requests permission on first load (deferred by 2 seconds).
242
243
  - Shows a prompt banner if permission is `"default"`.
243
- - Sends `Notification` when the page is not visible and the event is for a background tab.
244
+ - Sends desktop `Notification` when the page is **not visible** and the event is for a background tab.
245
+ - When the page **is visible** but the event is for a different session tab, shows an in-app toast notification instead of a desktop notification.
244
246
  - Falls back to in-page toast notifications + vibration on mobile.
245
247
  - Notifications auto-close after 5 seconds.
246
248
  - Clicking a notification switches to the relevant tab and focuses the window.
249
+ - Receives lightweight `session_activity` events from the server for sessions the client is not joined to, enabling idle detection and notifications for background tabs without requiring full terminal output.
247
250
 
248
251
  ### Keyboard Shortcuts
249
252
 
@@ -37,6 +37,7 @@ new ClaudeCodeWebServer(options)
37
37
  | `sessionStore` | `SessionStore` | Persistence layer for session data |
38
38
  | `usageReader` | `UsageReader` | Reads JSONL usage logs from `~/.claude/projects/` |
39
39
  | `usageAnalytics` | `UsageAnalytics` | Calculates burn rate, predictions, plan limits |
40
+ | `activityBroadcastTimestamps` | `Map<string, number>` | Per-session throttle timestamps for activity broadcasts |
40
41
  | `selectedWorkingDir` | string \| null | Currently selected working directory via folder browser |
41
42
  | `baseFolder` | string | `process.cwd()` at startup -- root for path validation |
42
43
  | `isShuttingDown` | boolean | Prevents duplicate shutdown sequences |
@@ -250,11 +251,18 @@ All messages are JSON. The `type` field determines the handler.
250
251
  | `info` | Informational message (e.g., "No agent is running"). |
251
252
  | `pong` | Response to `ping`. |
252
253
  | `usage_update` | Usage statistics payload (see Usage Analytics spec). |
254
+ | `session_activity` | Lightweight notification sent to connections NOT joined to the session, indicating new output. Fields: `sessionId`, `sessionName`. Throttled to 1/second per session. |
255
+ | `session_exit` | Sent to non-joined connections when agent exits. Fields: `sessionId`, `sessionName`, `code`, `signal`. |
256
+ | `session_error` | Sent to non-joined connections on error. Fields: `sessionId`, `sessionName`. |
257
+ | `session_started` | Sent to non-joined connections when a tool starts. Fields: `sessionId`, `sessionName`, `agent`. |
258
+ | `session_stopped` | Sent to non-joined connections when a tool stops. Fields: `sessionId`, `sessionName`, `agent`. |
253
259
 
254
260
  ### Broadcasting
255
261
 
256
262
  `broadcastToSession(sessionId, data)` iterates the session's `connections` Set, verifies each WebSocket is open and belongs to the correct session, then sends the JSON-serialized message.
257
263
 
264
+ `broadcastSessionActivity(sessionId, eventType, extraData)` sends a lightweight event to all WebSocket connections that are NOT joined to the specified session. This enables clients to track activity in background sessions for notification purposes without receiving full terminal output. Events use a `session_` prefix to distinguish cross-session events from in-session events (which are unprefixed like `output`, `exit`). The `session_activity` event is throttled to at most once per second per session to avoid flooding during high-output scenarios.
265
+
258
266
  ---
259
267
 
260
268
  ## Session Management
@@ -33,7 +33,7 @@ module.exports = defineConfig({
33
33
  },
34
34
  {
35
35
  name: 'functional',
36
- testMatch: /0[2-7]-.*\.spec\.js/,
36
+ testMatch: /0[2-7]-.*\.spec\.js|09-background-.*\.spec\.js/,
37
37
  },
38
38
  {
39
39
  name: 'mobile-iphone',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
package/src/public/app.js CHANGED
@@ -823,6 +823,49 @@ class ClaudeCodeWebInterface {
823
823
  );
824
824
  break;
825
825
 
826
+ // Background session activity events (from broadcastSessionActivity)
827
+ // These handlers must never touch this.terminal or this.showOverlay
828
+ case 'session_activity':
829
+ if (this.sessionTabManager && message.sessionId &&
830
+ message.sessionId !== this.currentClaudeSessionId) {
831
+ this.sessionTabManager.markSessionActivity(message.sessionId, true, '');
832
+ }
833
+ break;
834
+
835
+ case 'session_exit':
836
+ if (this.sessionTabManager && message.sessionId &&
837
+ message.sessionId !== this.currentClaudeSessionId) {
838
+ if (message.code !== 0) {
839
+ this.sessionTabManager.markSessionError(message.sessionId, true);
840
+ }
841
+ this.sessionTabManager.updateTabStatus(message.sessionId, 'idle');
842
+ }
843
+ this.loadSessions();
844
+ break;
845
+
846
+ case 'session_error':
847
+ if (this.sessionTabManager && message.sessionId &&
848
+ message.sessionId !== this.currentClaudeSessionId) {
849
+ this.sessionTabManager.markSessionError(message.sessionId, true);
850
+ }
851
+ break;
852
+
853
+ case 'session_started':
854
+ if (this.sessionTabManager && message.sessionId &&
855
+ message.sessionId !== this.currentClaudeSessionId) {
856
+ this.sessionTabManager.updateTabStatus(message.sessionId, 'active');
857
+ }
858
+ this.loadSessions();
859
+ break;
860
+
861
+ case 'session_stopped':
862
+ if (this.sessionTabManager && message.sessionId &&
863
+ message.sessionId !== this.currentClaudeSessionId) {
864
+ this.sessionTabManager.updateTabStatus(message.sessionId, 'idle');
865
+ }
866
+ this.loadSessions();
867
+ break;
868
+
826
869
  default:
827
870
  console.log('Unknown message type:', message.type);
828
871
  }
@@ -426,7 +426,7 @@
426
426
  newWorker.addEventListener('statechange', () => {
427
427
  if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
428
428
  // New service worker available, prompt user to refresh
429
- if (confirm('A new version of Claude Code Web is available. Refresh to update?')) {
429
+ if (confirm('A new version of ai-or-die is available. Refresh to update?')) {
430
430
  newWorker.postMessage({ type: 'SKIP_WAITING' });
431
431
  window.location.reload();
432
432
  }
@@ -7,6 +7,7 @@ class SessionTabManager {
7
7
  this.tabOrder = []; // visual order of tabs
8
8
  this.tabHistory = []; // most recently used order
9
9
  this.notificationsEnabled = false;
10
+ this.idleTimeoutMs = 90000;
10
11
  this.requestNotificationPermission();
11
12
  }
12
13
 
@@ -44,36 +45,35 @@ class SessionTabManager {
44
45
  sendNotification(title, body, sessionId) {
45
46
  // Don't send notification for active tab
46
47
  if (sessionId === this.activeTabId) return;
47
-
48
- // Only send notifications if the page is not visible
49
- if (document.visibilityState === 'visible') return;
50
-
51
- // Try desktop notifications first (won't work on iOS Safari)
52
- if ('Notification' in window && Notification.permission === 'granted') {
53
- try {
54
- const notification = new Notification(title, {
55
- body: body,
56
- icon: '/favicon.ico',
57
- tag: sessionId,
58
- requireInteraction: false,
59
- silent: false
60
- });
61
-
62
- notification.onclick = () => {
63
- window.focus();
64
- this.switchToTab(sessionId);
65
- notification.close();
66
- };
67
-
68
- setTimeout(() => notification.close(), 5000);
69
- console.log(`Desktop notification sent: ${title}`);
70
- return; // Exit if desktop notification worked
71
- } catch (error) {
72
- console.error('Desktop notification failed:', error);
48
+
49
+ // Try desktop notifications when the page is not visible
50
+ if (document.visibilityState !== 'visible') {
51
+ if ('Notification' in window && Notification.permission === 'granted') {
52
+ try {
53
+ const notification = new Notification(title, {
54
+ body: body,
55
+ icon: '/favicon.ico',
56
+ tag: sessionId,
57
+ requireInteraction: false,
58
+ silent: false
59
+ });
60
+
61
+ notification.onclick = () => {
62
+ window.focus();
63
+ this.switchToTab(sessionId);
64
+ notification.close();
65
+ };
66
+
67
+ setTimeout(() => notification.close(), 5000);
68
+ console.log(`Desktop notification sent: ${title}`);
69
+ return; // Desktop notification succeeded; no need for in-app toast
70
+ } catch (error) {
71
+ console.error('Desktop notification failed:', error);
72
+ }
73
73
  }
74
74
  }
75
-
76
- // Fallback for mobile: Use visual + audio/vibration
75
+
76
+ // In-app toast: show for background tabs even when page is visible
77
77
  this.showMobileNotification(title, body, sessionId);
78
78
  }
79
79
 
@@ -780,6 +780,13 @@ class SessionTabManager {
780
780
  const orderedIds = this.getOrderedTabIds();
781
781
  const closedIndex = orderedIds.indexOf(sessionId);
782
782
 
783
+ // Clear any pending notification timers before removing session data
784
+ const session = this.activeSessions.get(sessionId);
785
+ if (session) {
786
+ clearTimeout(session.idleTimeout);
787
+ clearTimeout(session.workCompleteTimeout);
788
+ }
789
+
783
790
  // Remove tab
784
791
  tab.remove();
785
792
  this.tabs.delete(sessionId);
@@ -1032,7 +1039,7 @@ class SessionTabManager {
1032
1039
  }
1033
1040
  }
1034
1041
  }
1035
- }, 90000); // 90 seconds
1042
+ }, this.idleTimeoutMs);
1036
1043
 
1037
1044
  // Keep the original 5-minute timeout for full idle state
1038
1045
  session.idleTimeout = setTimeout(() => {
package/src/server.js CHANGED
@@ -46,6 +46,7 @@ class ClaudeCodeWebServer {
46
46
  customCostLimit: parseFloat(process.env.CLAUDE_COST_LIMIT || options.customCostLimit || 50.00)
47
47
  });
48
48
  this.autoSaveInterval = null;
49
+ this.activityBroadcastTimestamps = new Map(); // sessionId -> last broadcast timestamp
49
50
  this.startTime = Date.now(); // Track server start time
50
51
  this.isShuttingDown = false; // Flag to prevent duplicate shutdown
51
52
  // Commands dropdown removed
@@ -372,6 +373,7 @@ class ClaudeCodeWebServer {
372
373
  });
373
374
 
374
375
  this.claudeSessions.delete(sessionId);
376
+ this.activityBroadcastTimestamps.delete(sessionId);
375
377
 
376
378
  // Save sessions after deletion — await to ensure persistence
377
379
  await this.saveSessionsToDisk();
@@ -1036,6 +1038,13 @@ class ClaudeCodeWebServer {
1036
1038
  currentSession.outputBuffer.shift();
1037
1039
  }
1038
1040
  this.broadcastToSession(sessionId, { type: 'output', data });
1041
+ // Notify non-joined connections about activity (throttled to 1/sec)
1042
+ const now = Date.now();
1043
+ const lastBroadcast = this.activityBroadcastTimestamps.get(sessionId) || 0;
1044
+ if (now - lastBroadcast > 1000) {
1045
+ this.activityBroadcastTimestamps.set(sessionId, now);
1046
+ this.broadcastSessionActivity(sessionId, 'session_activity');
1047
+ }
1039
1048
  },
1040
1049
  onExit: (code, signal) => {
1041
1050
  const currentSession = this.claudeSessions.get(sessionId);
@@ -1044,6 +1053,8 @@ class ClaudeCodeWebServer {
1044
1053
  currentSession.agent = null;
1045
1054
  }
1046
1055
  this.broadcastToSession(sessionId, { type: 'exit', code, signal });
1056
+ this.activityBroadcastTimestamps.delete(sessionId);
1057
+ this.broadcastSessionActivity(sessionId, 'session_exit', { code, signal });
1047
1058
  },
1048
1059
  onError: (error) => {
1049
1060
  const currentSession = this.claudeSessions.get(sessionId);
@@ -1052,6 +1063,7 @@ class ClaudeCodeWebServer {
1052
1063
  currentSession.agent = null;
1053
1064
  }
1054
1065
  this.broadcastToSession(sessionId, { type: 'error', message: error.message });
1066
+ this.broadcastSessionActivity(sessionId, 'session_error');
1055
1067
  },
1056
1068
  ...options
1057
1069
  });
@@ -1067,6 +1079,7 @@ class ClaudeCodeWebServer {
1067
1079
  type: `${toolName}_started`,
1068
1080
  sessionId: sessionId
1069
1081
  });
1082
+ this.broadcastSessionActivity(sessionId, 'session_started', { agent: toolName });
1070
1083
 
1071
1084
  } catch (error) {
1072
1085
  if (this.dev) {
@@ -1093,6 +1106,8 @@ class ClaudeCodeWebServer {
1093
1106
  session.agent = null;
1094
1107
  session.lastActivity = new Date();
1095
1108
  this.broadcastToSession(sessionId, { type: `${agentType}_stopped` });
1109
+ this.activityBroadcastTimestamps.delete(sessionId);
1110
+ this.broadcastSessionActivity(sessionId, 'session_stopped', { agent: agentType });
1096
1111
  }
1097
1112
 
1098
1113
  sendToWebSocket(ws, data) {
@@ -1108,14 +1123,27 @@ class ClaudeCodeWebServer {
1108
1123
  session.connections.forEach(wsId => {
1109
1124
  const wsInfo = this.webSocketConnections.get(wsId);
1110
1125
  // Double-check that this WebSocket is actually part of this session
1111
- if (wsInfo &&
1112
- wsInfo.claudeSessionId === claudeSessionId &&
1126
+ if (wsInfo &&
1127
+ wsInfo.claudeSessionId === claudeSessionId &&
1113
1128
  wsInfo.ws.readyState === WebSocket.OPEN) {
1114
1129
  this.sendToWebSocket(wsInfo.ws, data);
1115
1130
  }
1116
1131
  });
1117
1132
  }
1118
1133
 
1134
+ // Sends a lightweight event to all WebSocket connections that are NOT joined
1135
+ // to the specified session. This enables clients to track activity in background
1136
+ // sessions for notification purposes without receiving full terminal output.
1137
+ broadcastSessionActivity(sessionId, eventType, extraData = {}) {
1138
+ const session = this.claudeSessions.get(sessionId);
1139
+ const sessionName = session ? session.name : '';
1140
+ this.webSocketConnections.forEach((wsInfo, wsId) => {
1141
+ if (wsInfo.claudeSessionId === sessionId) return;
1142
+ if (wsInfo.ws.readyState !== WebSocket.OPEN) return;
1143
+ this.sendToWebSocket(wsInfo.ws, { type: eventType, sessionId, sessionName, ...extraData });
1144
+ });
1145
+ }
1146
+
1119
1147
  cleanupWebSocketConnection(wsId) {
1120
1148
  const wsInfo = this.webSocketConnections.get(wsId);
1121
1149
  if (!wsInfo) return;