ai-agent-session-center 1.0.0
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 +618 -0
- package/bin/cli.js +20 -0
- package/hooks/dashboard-hook-codex.sh +67 -0
- package/hooks/dashboard-hook-gemini.sh +102 -0
- package/hooks/dashboard-hook.ps1 +147 -0
- package/hooks/dashboard-hook.sh +142 -0
- package/hooks/dashboard-hooks-backup.json +103 -0
- package/hooks/install-hooks.js +543 -0
- package/hooks/reset.js +357 -0
- package/hooks/setup-wizard.js +156 -0
- package/package.json +52 -0
- package/public/css/dashboard.css +10200 -0
- package/public/index.html +915 -0
- package/public/js/analyticsPanel.js +467 -0
- package/public/js/app.js +1148 -0
- package/public/js/browserDb.js +806 -0
- package/public/js/chartUtils.js +383 -0
- package/public/js/historyPanel.js +298 -0
- package/public/js/movementManager.js +155 -0
- package/public/js/navController.js +32 -0
- package/public/js/robotManager.js +526 -0
- package/public/js/sceneManager.js +7 -0
- package/public/js/sessionPanel.js +2477 -0
- package/public/js/settingsManager.js +924 -0
- package/public/js/soundManager.js +249 -0
- package/public/js/statsPanel.js +118 -0
- package/public/js/terminalManager.js +391 -0
- package/public/js/timelinePanel.js +278 -0
- package/public/js/wsClient.js +88 -0
- package/server/apiRouter.js +321 -0
- package/server/config.js +120 -0
- package/server/hookProcessor.js +55 -0
- package/server/hookRouter.js +18 -0
- package/server/hookStats.js +107 -0
- package/server/index.js +314 -0
- package/server/logger.js +67 -0
- package/server/mqReader.js +218 -0
- package/server/serverConfig.js +27 -0
- package/server/sessionStore.js +1049 -0
- package/server/sshManager.js +339 -0
- package/server/wsManager.js +83 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// WebSocket client with auto-reconnect
|
|
2
|
+
let ws;
|
|
3
|
+
let reconnectDelay = 1000;
|
|
4
|
+
let onSnapshot = null;
|
|
5
|
+
let onSessionUpdate = null;
|
|
6
|
+
let onDurationAlert = null;
|
|
7
|
+
let onTeamUpdate = null;
|
|
8
|
+
let onHookStats = null;
|
|
9
|
+
let onSessionRemoved = null;
|
|
10
|
+
let onTerminalOutput = null;
|
|
11
|
+
let onTerminalReady = null;
|
|
12
|
+
let onTerminalClosed = null;
|
|
13
|
+
let onClearBrowserDb = null;
|
|
14
|
+
let reconnectTimer = null;
|
|
15
|
+
let reconnectTarget = 0; // timestamp when reconnect fires
|
|
16
|
+
|
|
17
|
+
export let connected = false;
|
|
18
|
+
|
|
19
|
+
export function getReconnectRemaining() {
|
|
20
|
+
if (connected || !reconnectTarget) return 0;
|
|
21
|
+
return Math.max(0, Math.ceil((reconnectTarget - Date.now()) / 1000));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function connect({ onSnapshotCb, onSessionUpdateCb, onSessionRemovedCb, onDurationAlertCb, onTeamUpdateCb, onHookStatsCb, onTerminalOutputCb, onTerminalReadyCb, onTerminalClosedCb, onClearBrowserDbCb }) {
|
|
25
|
+
onSnapshot = onSnapshotCb;
|
|
26
|
+
onSessionUpdate = onSessionUpdateCb;
|
|
27
|
+
onSessionRemoved = onSessionRemovedCb || null;
|
|
28
|
+
onDurationAlert = onDurationAlertCb;
|
|
29
|
+
onTeamUpdate = onTeamUpdateCb;
|
|
30
|
+
onHookStats = onHookStatsCb;
|
|
31
|
+
onTerminalOutput = onTerminalOutputCb || null;
|
|
32
|
+
onTerminalReady = onTerminalReadyCb || null;
|
|
33
|
+
onTerminalClosed = onTerminalClosedCb || null;
|
|
34
|
+
onClearBrowserDb = onClearBrowserDbCb || null;
|
|
35
|
+
_connect();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getWs() {
|
|
39
|
+
return ws;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function _connect() {
|
|
43
|
+
ws = new WebSocket(`ws://${window.location.host}`);
|
|
44
|
+
|
|
45
|
+
ws.onopen = () => {
|
|
46
|
+
reconnectDelay = 1000;
|
|
47
|
+
reconnectTarget = 0;
|
|
48
|
+
connected = true;
|
|
49
|
+
console.log('[WS] Connected');
|
|
50
|
+
document.dispatchEvent(new CustomEvent('ws-status', { detail: 'connected' }));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
ws.onmessage = (event) => {
|
|
54
|
+
const data = JSON.parse(event.data);
|
|
55
|
+
if (data.type === 'snapshot' && onSnapshot) {
|
|
56
|
+
onSnapshot(data.sessions, data.teams);
|
|
57
|
+
} else if (data.type === 'session_update' && onSessionUpdate) {
|
|
58
|
+
onSessionUpdate(data.session, data.team);
|
|
59
|
+
} else if (data.type === 'session_removed' && onSessionRemoved) {
|
|
60
|
+
onSessionRemoved(data.sessionId);
|
|
61
|
+
} else if (data.type === 'team_update' && onTeamUpdate) {
|
|
62
|
+
onTeamUpdate(data.team);
|
|
63
|
+
} else if (data.type === 'hook_stats' && onHookStats) {
|
|
64
|
+
onHookStats(data.stats);
|
|
65
|
+
} else if (data.type === 'duration_alert' && onDurationAlert) {
|
|
66
|
+
onDurationAlert(data);
|
|
67
|
+
} else if (data.type === 'terminal_output' && onTerminalOutput) {
|
|
68
|
+
onTerminalOutput(data.terminalId, data.data);
|
|
69
|
+
} else if (data.type === 'terminal_ready' && onTerminalReady) {
|
|
70
|
+
onTerminalReady(data.terminalId);
|
|
71
|
+
} else if (data.type === 'terminal_closed' && onTerminalClosed) {
|
|
72
|
+
onTerminalClosed(data.terminalId, data.reason);
|
|
73
|
+
} else if (data.type === 'clearBrowserDb' && onClearBrowserDb) {
|
|
74
|
+
onClearBrowserDb();
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
ws.onclose = () => {
|
|
79
|
+
connected = false;
|
|
80
|
+
console.log(`[WS] Disconnected, reconnecting in ${reconnectDelay}ms`);
|
|
81
|
+
document.dispatchEvent(new CustomEvent('ws-status', { detail: 'disconnected' }));
|
|
82
|
+
reconnectTarget = Date.now() + reconnectDelay;
|
|
83
|
+
reconnectTimer = setTimeout(_connect, reconnectDelay);
|
|
84
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
ws.onerror = () => ws.close();
|
|
88
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// apiRouter.js — Express router for all API endpoints (no SQLite/database dependencies)
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { findClaudeProcess, killSession, archiveSession, setSessionTitle, setSessionLabel, setSummary, getSession, detectSessionSource, createTerminalSession, deleteSessionFromMemory } from './sessionStore.js';
|
|
4
|
+
import { createTerminal, closeTerminal, getTerminals, listSshKeys, listTmuxSessions } from './sshManager.js';
|
|
5
|
+
import { getStats as getHookStats, resetStats as resetHookStats } from './hookStats.js';
|
|
6
|
+
import { getMqStats } from './mqReader.js';
|
|
7
|
+
import { execFile } from 'child_process';
|
|
8
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
9
|
+
import { join, dirname } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
|
|
13
|
+
const __apiDirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
const router = Router();
|
|
16
|
+
|
|
17
|
+
// Hook performance stats
|
|
18
|
+
router.get('/hook-stats', (req, res) => {
|
|
19
|
+
res.json(getHookStats());
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
router.post('/hook-stats/reset', (req, res) => {
|
|
23
|
+
resetHookStats();
|
|
24
|
+
res.json({ ok: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Full reset — broadcast to all connected browsers to clear their IndexedDB
|
|
28
|
+
router.post('/reset', async (req, res) => {
|
|
29
|
+
const { broadcast } = await import('./wsManager.js');
|
|
30
|
+
broadcast({ type: 'clearBrowserDb' });
|
|
31
|
+
res.json({ ok: true, message: 'Browser DB clear signal sent' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// MQ reader stats
|
|
35
|
+
router.get('/mq-stats', (req, res) => {
|
|
36
|
+
res.json(getMqStats());
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ---- Hook Density Management ----
|
|
40
|
+
|
|
41
|
+
const CLAUDE_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
42
|
+
const INSTALL_HOOKS_SCRIPT = join(__apiDirname, '..', 'hooks', 'install-hooks.js');
|
|
43
|
+
const HOOK_PATTERN = 'dashboard-hook.';
|
|
44
|
+
const ALL_HOOK_EVENTS = [
|
|
45
|
+
'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure',
|
|
46
|
+
'PermissionRequest', 'Stop', 'Notification', 'SubagentStart', 'SubagentStop',
|
|
47
|
+
'TeammateIdle', 'TaskCompleted', 'PreCompact', 'SessionEnd'
|
|
48
|
+
];
|
|
49
|
+
const DENSITY_EVENTS = {
|
|
50
|
+
high: ALL_HOOK_EVENTS,
|
|
51
|
+
medium: [
|
|
52
|
+
'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure',
|
|
53
|
+
'PermissionRequest', 'Stop', 'Notification', 'SubagentStart', 'SubagentStop',
|
|
54
|
+
'TaskCompleted', 'SessionEnd'
|
|
55
|
+
],
|
|
56
|
+
low: ['SessionStart', 'UserPromptSubmit', 'PermissionRequest', 'Stop', 'SessionEnd']
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Get current hooks status from ~/.claude/settings.json
|
|
60
|
+
router.get('/hooks/status', (req, res) => {
|
|
61
|
+
try {
|
|
62
|
+
let claudeSettings = {};
|
|
63
|
+
try {
|
|
64
|
+
claudeSettings = JSON.parse(readFileSync(CLAUDE_SETTINGS_PATH, 'utf8'));
|
|
65
|
+
} catch { /* file doesn't exist yet */ }
|
|
66
|
+
|
|
67
|
+
const hooks = claudeSettings.hooks || {};
|
|
68
|
+
const installedEvents = ALL_HOOK_EVENTS.filter(event =>
|
|
69
|
+
hooks[event]?.some(group => group.hooks?.some(h => h.command?.includes(HOOK_PATTERN)))
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Infer density from installed events
|
|
73
|
+
let density = 'off';
|
|
74
|
+
if (installedEvents.length > 0) {
|
|
75
|
+
if (installedEvents.length === DENSITY_EVENTS.high.length &&
|
|
76
|
+
DENSITY_EVENTS.high.every(e => installedEvents.includes(e))) {
|
|
77
|
+
density = 'high';
|
|
78
|
+
} else if (installedEvents.length === DENSITY_EVENTS.medium.length &&
|
|
79
|
+
DENSITY_EVENTS.medium.every(e => installedEvents.includes(e))) {
|
|
80
|
+
density = 'medium';
|
|
81
|
+
} else if (installedEvents.length === DENSITY_EVENTS.low.length &&
|
|
82
|
+
DENSITY_EVENTS.low.every(e => installedEvents.includes(e))) {
|
|
83
|
+
density = 'low';
|
|
84
|
+
} else {
|
|
85
|
+
density = 'custom';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
res.json({ installed: installedEvents.length > 0, density, events: installedEvents });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
res.status(500).json({ error: err.message });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Install hooks with specified density
|
|
96
|
+
router.post('/hooks/install', (req, res) => {
|
|
97
|
+
const { density } = req.body;
|
|
98
|
+
if (!density || !DENSITY_EVENTS[density]) {
|
|
99
|
+
return res.status(400).json({ error: 'density must be one of: high, medium, low' });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Run install-hooks.js with --density flag
|
|
103
|
+
execFile('node', [INSTALL_HOOKS_SCRIPT, '--density', density], { timeout: 15000 }, (err, stdout, stderr) => {
|
|
104
|
+
if (err) {
|
|
105
|
+
console.error('[hooks/install] Error:', err.message);
|
|
106
|
+
return res.status(500).json({ error: err.message, stdout, stderr });
|
|
107
|
+
}
|
|
108
|
+
console.log('[hooks/install]', stdout);
|
|
109
|
+
res.json({ ok: true, density, events: DENSITY_EVENTS[density], output: stdout });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Uninstall all dashboard hooks
|
|
114
|
+
router.post('/hooks/uninstall', (req, res) => {
|
|
115
|
+
// Run install-hooks.js with --uninstall flag
|
|
116
|
+
execFile('node', [INSTALL_HOOKS_SCRIPT, '--uninstall'], { timeout: 15000 }, (err, stdout, stderr) => {
|
|
117
|
+
if (err) {
|
|
118
|
+
console.error('[hooks/uninstall] Error:', err.message);
|
|
119
|
+
return res.status(500).json({ error: err.message, stdout, stderr });
|
|
120
|
+
}
|
|
121
|
+
console.log('[hooks/uninstall]', stdout);
|
|
122
|
+
res.json({ ok: true, output: stdout });
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---- Session Control Endpoints ----
|
|
127
|
+
|
|
128
|
+
// Kill session process — sends SIGTERM, then SIGKILL after 3s if still alive
|
|
129
|
+
router.post('/sessions/:id/kill', (req, res) => {
|
|
130
|
+
if (!req.body.confirm) {
|
|
131
|
+
return res.status(400).json({ error: 'Must send {confirm: true} to kill a session' });
|
|
132
|
+
}
|
|
133
|
+
const sessionId = req.params.id;
|
|
134
|
+
const mem = getSession(sessionId);
|
|
135
|
+
const pid = findClaudeProcess(sessionId, mem?.projectPath);
|
|
136
|
+
const source = detectSessionSource(sessionId);
|
|
137
|
+
if (pid) {
|
|
138
|
+
try {
|
|
139
|
+
process.kill(pid, 'SIGTERM');
|
|
140
|
+
// Follow up with SIGKILL after 3s if process is still alive
|
|
141
|
+
setTimeout(() => {
|
|
142
|
+
try {
|
|
143
|
+
process.kill(pid, 0); // Check if still alive
|
|
144
|
+
process.kill(pid, 'SIGKILL');
|
|
145
|
+
} catch(e) { /* already dead — good */ }
|
|
146
|
+
}, 3000);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return res.status(500).json({ error: `Failed to kill PID ${pid}: ${e.message}` });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const session = killSession(sessionId);
|
|
152
|
+
archiveSession(sessionId, true);
|
|
153
|
+
// Close associated SSH terminal if present
|
|
154
|
+
if (session && session.terminalId) {
|
|
155
|
+
closeTerminal(session.terminalId);
|
|
156
|
+
} else if (mem && mem.terminalId) {
|
|
157
|
+
closeTerminal(mem.terminalId);
|
|
158
|
+
}
|
|
159
|
+
if (!session && !pid) {
|
|
160
|
+
return res.status(404).json({ error: 'Session not found and no matching process' });
|
|
161
|
+
}
|
|
162
|
+
res.json({ ok: true, pid: pid || null, source });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Permanently delete a session — removes from memory, broadcasts removal to clients
|
|
166
|
+
router.delete('/sessions/:id', async (req, res) => {
|
|
167
|
+
const sessionId = req.params.id;
|
|
168
|
+
const session = getSession(sessionId);
|
|
169
|
+
// Close terminal if still active
|
|
170
|
+
if (session && session.terminalId) {
|
|
171
|
+
closeTerminal(session.terminalId);
|
|
172
|
+
}
|
|
173
|
+
const removed = deleteSessionFromMemory(sessionId);
|
|
174
|
+
// Broadcast session_removed so all connected browsers remove the card
|
|
175
|
+
try {
|
|
176
|
+
const { broadcast } = await import('./wsManager.js');
|
|
177
|
+
broadcast({ type: 'session_removed', sessionId });
|
|
178
|
+
} catch (e) {}
|
|
179
|
+
res.json({ ok: true, removed });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Detect session source (vscode / terminal)
|
|
183
|
+
router.get('/sessions/:id/source', (req, res) => {
|
|
184
|
+
const source = detectSessionSource(req.params.id);
|
|
185
|
+
res.json({ source });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
// Update session title (in-memory only, no DB write)
|
|
191
|
+
router.put('/sessions/:id/title', (req, res) => {
|
|
192
|
+
const { title } = req.body;
|
|
193
|
+
if (title === undefined) return res.status(400).json({ error: 'title is required' });
|
|
194
|
+
setSessionTitle(req.params.id, title);
|
|
195
|
+
res.json({ ok: true });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Update session label (in-memory only, no DB write)
|
|
199
|
+
router.put('/sessions/:id/label', (req, res) => {
|
|
200
|
+
const { label } = req.body;
|
|
201
|
+
if (label === undefined) return res.status(400).json({ error: 'label is required' });
|
|
202
|
+
setSessionLabel(req.params.id, label);
|
|
203
|
+
res.json({ ok: true });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Summarize session using Claude CLI
|
|
207
|
+
// The frontend sends { context, promptTemplate } from IndexedDB data.
|
|
208
|
+
// If custom_prompt is provided, use it directly as the prompt template.
|
|
209
|
+
router.post('/sessions/:id/summarize', async (req, res) => {
|
|
210
|
+
const sessionId = req.params.id;
|
|
211
|
+
const { context, promptTemplate: bodyPromptTemplate, custom_prompt: customPrompt } = req.body;
|
|
212
|
+
|
|
213
|
+
if (!context) {
|
|
214
|
+
return res.status(400).json({ error: 'context is required in request body (prepared from IndexedDB data)' });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Determine prompt template: custom_prompt > bodyPromptTemplate > default
|
|
218
|
+
const promptTemplate = customPrompt || bodyPromptTemplate || 'Summarize this Claude Code session in detail.';
|
|
219
|
+
|
|
220
|
+
const summaryPrompt = `${promptTemplate}\n\n--- SESSION TRANSCRIPT ---\n${context}`;
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const summary = await new Promise((resolve, reject) => {
|
|
224
|
+
const child = execFile('claude', ['-p', '--model', 'haiku'], {
|
|
225
|
+
timeout: 60000,
|
|
226
|
+
maxBuffer: 1024 * 1024,
|
|
227
|
+
}, (error, stdout, stderr) => {
|
|
228
|
+
if (error) return reject(error);
|
|
229
|
+
resolve(stdout.trim());
|
|
230
|
+
});
|
|
231
|
+
child.stdin.write(summaryPrompt);
|
|
232
|
+
child.stdin.end();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Store summary in memory
|
|
236
|
+
setSummary(sessionId, summary);
|
|
237
|
+
archiveSession(sessionId, true);
|
|
238
|
+
|
|
239
|
+
res.json({ ok: true, summary });
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error('[apiRouter] Summarize error:', err.message);
|
|
242
|
+
res.status(500).json({ error: `Summarize failed: ${err.message}` });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ── SSH Keys ──
|
|
247
|
+
|
|
248
|
+
router.get('/ssh-keys', (req, res) => {
|
|
249
|
+
res.json({ keys: listSshKeys() });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ── Tmux Sessions ──
|
|
253
|
+
|
|
254
|
+
router.post('/tmux-sessions', async (req, res) => {
|
|
255
|
+
try {
|
|
256
|
+
const { host, port, username, password, privateKeyPath, authMethod, passphrase } = req.body;
|
|
257
|
+
if (!username) return res.status(400).json({ error: 'username required' });
|
|
258
|
+
const config = {
|
|
259
|
+
host: host || 'localhost',
|
|
260
|
+
port: port || 22,
|
|
261
|
+
username,
|
|
262
|
+
authMethod: authMethod || 'key',
|
|
263
|
+
privateKeyPath,
|
|
264
|
+
password,
|
|
265
|
+
passphrase,
|
|
266
|
+
};
|
|
267
|
+
const sessions = await listTmuxSessions(config);
|
|
268
|
+
res.json({ sessions });
|
|
269
|
+
} catch (err) {
|
|
270
|
+
res.status(500).json({ error: err.message });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ── Terminals ──
|
|
275
|
+
|
|
276
|
+
router.post('/terminals', async (req, res) => {
|
|
277
|
+
try {
|
|
278
|
+
const { host, port, username, password, privateKeyPath, authMethod, workingDir, command, apiKey, tmuxSession, useTmux, sessionTitle, label } = req.body;
|
|
279
|
+
|
|
280
|
+
if (!username) return res.status(400).json({ error: 'username required' });
|
|
281
|
+
const config = {
|
|
282
|
+
host: host || 'localhost',
|
|
283
|
+
port: port || 22,
|
|
284
|
+
username,
|
|
285
|
+
authMethod: authMethod || 'key',
|
|
286
|
+
privateKeyPath,
|
|
287
|
+
workingDir: workingDir || '~',
|
|
288
|
+
command: command || 'claude',
|
|
289
|
+
password,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// Tmux modes
|
|
293
|
+
if (tmuxSession) config.tmuxSession = tmuxSession; // attach to existing
|
|
294
|
+
if (useTmux) config.useTmux = true; // wrap in new tmux session
|
|
295
|
+
if (sessionTitle) config.sessionTitle = sessionTitle;
|
|
296
|
+
if (label) config.label = label;
|
|
297
|
+
|
|
298
|
+
// Resolve API key from request body only (no DB lookup)
|
|
299
|
+
if (apiKey) {
|
|
300
|
+
config.apiKey = apiKey;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const terminalId = await createTerminal(config, null);
|
|
304
|
+
// Create session card immediately so it appears in the dashboard
|
|
305
|
+
await createTerminalSession(terminalId, config);
|
|
306
|
+
res.json({ ok: true, terminalId });
|
|
307
|
+
} catch (err) {
|
|
308
|
+
res.status(500).json({ error: err.message });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
router.get('/terminals', (req, res) => {
|
|
313
|
+
res.json({ terminals: getTerminals() });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
router.delete('/terminals/:id', (req, res) => {
|
|
317
|
+
closeTerminal(req.params.id);
|
|
318
|
+
res.json({ ok: true });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
export default router;
|
package/server/config.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// config.js — Extracted session status & approval detection configuration
|
|
2
|
+
import { config as serverConfig } from './serverConfig.js';
|
|
3
|
+
|
|
4
|
+
// ---- Tool Categories for Approval Detection ----
|
|
5
|
+
// When PreToolUse fires, we start a timer. If PostToolUse doesn't arrive
|
|
6
|
+
// within the timeout, the tool is likely pending user interaction.
|
|
7
|
+
// NOTE: PermissionRequest event (when available at medium+ density) provides
|
|
8
|
+
// a direct signal for approval-needed state, replacing the timeout heuristic.
|
|
9
|
+
|
|
10
|
+
export const TOOL_CATEGORIES = {
|
|
11
|
+
// Tools that complete instantly when auto-approved (3s timeout)
|
|
12
|
+
fast: ['Read', 'Write', 'Edit', 'Grep', 'Glob', 'NotebookEdit'],
|
|
13
|
+
// Tools that ALWAYS require user interaction — not approval, but input (3s timeout)
|
|
14
|
+
userInput: ['AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode'],
|
|
15
|
+
// Tools that can be slow but not minutes-slow (15s timeout)
|
|
16
|
+
medium: ['WebFetch', 'WebSearch'],
|
|
17
|
+
// Tools that can run for minutes but still need approval detection (8s timeout).
|
|
18
|
+
// Tradeoff: auto-approved long-running commands (npm install, builds) will
|
|
19
|
+
// briefly show as "approval" after 8s until PostToolUse clears it.
|
|
20
|
+
slow: ['Bash', 'Task'],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const TOOL_TIMEOUTS = {
|
|
24
|
+
fast: 3000,
|
|
25
|
+
userInput: 3000,
|
|
26
|
+
medium: 15000,
|
|
27
|
+
slow: 8000,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Status to set when each category's timeout fires
|
|
31
|
+
export const WAITING_REASONS = {
|
|
32
|
+
fast: 'approval', // "NEEDS YOUR APPROVAL"
|
|
33
|
+
userInput: 'input', // "WAITING FOR YOUR ANSWER"
|
|
34
|
+
medium: 'approval', // "NEEDS YOUR APPROVAL"
|
|
35
|
+
slow: 'approval', // "NEEDS YOUR APPROVAL"
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Human-readable labels for waitingDetail per category
|
|
39
|
+
export const WAITING_LABELS = {
|
|
40
|
+
approval: (toolName, detail) =>
|
|
41
|
+
detail ? `Approve ${toolName}: ${detail}` : `Approve ${toolName}`,
|
|
42
|
+
input: (toolName, _detail) => {
|
|
43
|
+
if (toolName === 'AskUserQuestion') return 'Waiting for your answer';
|
|
44
|
+
if (toolName === 'EnterPlanMode') return 'Review plan mode request';
|
|
45
|
+
if (toolName === 'ExitPlanMode') return 'Review plan';
|
|
46
|
+
return `Waiting for input on ${toolName}`;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ---- Auto-Idle Timeouts ----
|
|
51
|
+
// Sessions transition to idle/waiting if no activity for these durations (ms)
|
|
52
|
+
export const AUTO_IDLE_TIMEOUTS = {
|
|
53
|
+
prompting: 30_000, // prompting → waiting (user likely cancelled)
|
|
54
|
+
waiting: 120_000, // waiting → idle (2 min)
|
|
55
|
+
working: 180_000, // working → idle (3 min)
|
|
56
|
+
approval: 600_000, // approval → idle (10 min safety net)
|
|
57
|
+
input: 600_000, // input → idle (10 min safety net)
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ---- Process Liveness Check ----
|
|
61
|
+
// How often to check if session PIDs are still alive (ms).
|
|
62
|
+
// When a user closes VS Code, JetBrains, or terminal abruptly, the SessionEnd
|
|
63
|
+
// hook never fires. This monitor detects dead processes and auto-ends sessions.
|
|
64
|
+
export const PROCESS_CHECK_INTERVAL = serverConfig.processCheckInterval || 15_000;
|
|
65
|
+
|
|
66
|
+
// ---- Animation State Mappings ----
|
|
67
|
+
export const STATUS_ANIMATIONS = {
|
|
68
|
+
idle: { animationState: 'Idle', emote: null },
|
|
69
|
+
prompting: { animationState: 'Walking', emote: 'Wave' },
|
|
70
|
+
working: { animationState: 'Running', emote: null },
|
|
71
|
+
approval: { animationState: 'Waiting', emote: null },
|
|
72
|
+
input: { animationState: 'Waiting', emote: null },
|
|
73
|
+
waiting: { animationState: 'Waiting', emote: 'ThumbsUp' },
|
|
74
|
+
ended: { animationState: 'Death', emote: null },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ---- Precomputed Tool → Category Lookup ----
|
|
78
|
+
// Built once at import time for O(1) lookups in hot path
|
|
79
|
+
const _toolToCategory = new Map();
|
|
80
|
+
for (const [category, tools] of Object.entries(TOOL_CATEGORIES)) {
|
|
81
|
+
for (const tool of tools) {
|
|
82
|
+
_toolToCategory.set(tool, category);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get the category for a tool name.
|
|
88
|
+
* @returns {string|null} 'fast' | 'userInput' | 'medium' | 'slow' | null (no timeout)
|
|
89
|
+
*/
|
|
90
|
+
export function getToolCategory(toolName) {
|
|
91
|
+
return _toolToCategory.get(toolName) || null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get the approval/input timeout for a tool, or 0 if no detection applies.
|
|
96
|
+
*/
|
|
97
|
+
export function getToolTimeout(toolName) {
|
|
98
|
+
const cat = getToolCategory(toolName);
|
|
99
|
+
return cat ? (TOOL_TIMEOUTS[cat] || 0) : 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get the waiting status to set when a tool's timeout fires.
|
|
104
|
+
* @returns {'approval'|'input'|null}
|
|
105
|
+
*/
|
|
106
|
+
export function getWaitingStatus(toolName) {
|
|
107
|
+
const cat = getToolCategory(toolName);
|
|
108
|
+
return cat ? (WAITING_REASONS[cat] || null) : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get the human-readable waitingDetail label for a tool.
|
|
113
|
+
*/
|
|
114
|
+
export function getWaitingLabel(toolName, detail) {
|
|
115
|
+
const cat = getToolCategory(toolName);
|
|
116
|
+
if (!cat) return null;
|
|
117
|
+
const status = WAITING_REASONS[cat];
|
|
118
|
+
const labelFn = WAITING_LABELS[status];
|
|
119
|
+
return labelFn ? labelFn(toolName, detail) : null;
|
|
120
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// hookProcessor.js — Shared hook event processing pipeline
|
|
2
|
+
// Used by both hookRouter.js (HTTP) and mqReader.js (file-based MQ)
|
|
3
|
+
import { handleEvent } from './sessionStore.js';
|
|
4
|
+
import { broadcast } from './wsManager.js';
|
|
5
|
+
import { recordHook, getStats } from './hookStats.js';
|
|
6
|
+
import log from './logger.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Process a hook event from any transport (HTTP or MQ).
|
|
10
|
+
* Validates, calls handleEvent(), records stats, broadcasts to WebSocket clients.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} hookData - Parsed hook JSON payload
|
|
13
|
+
* @param {'http'|'mq'} [source='http'] - Transport source for logging
|
|
14
|
+
* @returns {object|null} Session delta if event was processed, null otherwise
|
|
15
|
+
*/
|
|
16
|
+
export function processHookEvent(hookData, source = 'http') {
|
|
17
|
+
const receivedAt = Date.now();
|
|
18
|
+
|
|
19
|
+
if (!hookData || !hookData.session_id) {
|
|
20
|
+
log.warn('hook', `Received hook without session_id (via ${source})`);
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
log.debug('hook', `Event: ${hookData.hook_event_name || 'unknown'} session=${hookData.session_id} via=${source}`);
|
|
25
|
+
log.debugJson('hook', 'Hook payload', hookData);
|
|
26
|
+
|
|
27
|
+
// Measure server processing time
|
|
28
|
+
const processStart = Date.now();
|
|
29
|
+
const delta = handleEvent(hookData);
|
|
30
|
+
const processingTime = Date.now() - processStart;
|
|
31
|
+
|
|
32
|
+
// Calculate delivery latency (hook_sent_at is seconds * 1000 from bash `date +%s`)
|
|
33
|
+
let deliveryLatency = null;
|
|
34
|
+
if (hookData.hook_sent_at) {
|
|
35
|
+
deliveryLatency = receivedAt - hookData.hook_sent_at;
|
|
36
|
+
if (deliveryLatency < 0) deliveryLatency = 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Record stats
|
|
40
|
+
const eventType = hookData.hook_event_name || 'unknown';
|
|
41
|
+
recordHook(eventType, deliveryLatency, processingTime);
|
|
42
|
+
|
|
43
|
+
// Broadcast to WebSocket clients
|
|
44
|
+
if (delta) {
|
|
45
|
+
log.debug('hook', `Broadcasting session_update for ${hookData.session_id} status=${delta.session?.status}`);
|
|
46
|
+
broadcast({ type: 'session_update', ...delta });
|
|
47
|
+
if (delta.team) {
|
|
48
|
+
log.debug('hook', `Broadcasting team_update for team=${delta.team.teamId}`);
|
|
49
|
+
broadcast({ type: 'team_update', team: delta.team });
|
|
50
|
+
}
|
|
51
|
+
broadcast({ type: 'hook_stats', stats: getStats() });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return delta;
|
|
55
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// hookRouter.js — POST /api/hooks endpoint (HTTP transport adapter)
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { processHookEvent } from './hookProcessor.js';
|
|
4
|
+
import log from './logger.js';
|
|
5
|
+
|
|
6
|
+
const router = Router();
|
|
7
|
+
|
|
8
|
+
router.post('/', (req, res) => {
|
|
9
|
+
const hookData = req.body;
|
|
10
|
+
if (!hookData || !hookData.session_id) {
|
|
11
|
+
log.warn('hook', 'Received hook without session_id');
|
|
12
|
+
return res.status(400).json({ error: 'Missing session_id' });
|
|
13
|
+
}
|
|
14
|
+
processHookEvent(hookData, 'http');
|
|
15
|
+
res.json({ ok: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export default router;
|