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.
- package/docs/specs/client-app.md +4 -1
- package/docs/specs/server.md +8 -0
- package/e2e/playwright.config.js +1 -1
- package/package.json +1 -1
- package/src/public/app.js +43 -0
- package/src/public/index.html +1 -1
- package/src/public/session-manager.js +36 -29
- package/src/server.js +30 -2
package/docs/specs/client-app.md
CHANGED
|
@@ -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
|
|
package/docs/specs/server.md
CHANGED
|
@@ -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
|
package/e2e/playwright.config.js
CHANGED
package/package.json
CHANGED
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
|
}
|
package/src/public/index.html
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
49
|
-
if (document.visibilityState
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
//
|
|
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
|
-
},
|
|
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;
|