@agent-link/server 0.1.85 → 0.1.86

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.
@@ -1,450 +1,450 @@
1
- // ── WebSocket connection, message routing, reconnection ──────────────────────
2
- import { encrypt, decrypt, isEncrypted, decodeKey } from '../encryption.js';
3
- import { isContextSummary } from './messageHelpers.js';
4
-
5
- const MAX_RECONNECT_ATTEMPTS = 50;
6
- const RECONNECT_BASE_DELAY = 1000;
7
- const RECONNECT_MAX_DELAY = 15000;
8
-
9
- /**
10
- * Creates the WebSocket connection controller.
11
- * @param {object} deps - All reactive state and callbacks needed
12
- */
13
- export function createConnection(deps) {
14
- const {
15
- status, agentName, hostname, workDir, sessionId, error,
16
- serverVersion, agentVersion,
17
- messages, isProcessing, isCompacting, visibleLimit,
18
- historySessions, currentClaudeSessionId, loadingSessions, loadingHistory,
19
- folderPickerLoading, folderPickerEntries, folderPickerPath,
20
- authRequired, authPassword, authError, authAttempts, authLocked,
21
- streaming, sidebar,
22
- scrollToBottom,
23
- } = deps;
24
-
25
- let ws = null;
26
- let sessionKey = null;
27
- let reconnectAttempts = 0;
28
- let reconnectTimer = null;
29
- const toolMsgMap = new Map(); // toolId -> message (for fast tool_result lookup)
30
-
31
- function wsSend(msg) {
32
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
33
- if (sessionKey) {
34
- const encrypted = encrypt(msg, sessionKey);
35
- ws.send(JSON.stringify(encrypted));
36
- } else {
37
- ws.send(JSON.stringify(msg));
38
- }
39
- }
40
-
41
- function getSessionId() {
42
- const match = window.location.pathname.match(/^\/s\/([^/]+)/);
43
- return match ? match[1] : null;
44
- }
45
-
46
- function finalizeStreamingMsg(scheduleHighlight) {
47
- const sid = streaming.getStreamingMessageId();
48
- if (sid === null) return;
49
- const streamMsg = messages.value.find(m => m.id === sid);
50
- if (streamMsg) {
51
- streamMsg.isStreaming = false;
52
- if (isContextSummary(streamMsg.content)) {
53
- streamMsg.role = 'context-summary';
54
- streamMsg.contextExpanded = false;
55
- }
56
- }
57
- streaming.setStreamingMessageId(null);
58
- if (scheduleHighlight) scheduleHighlight();
59
- }
60
-
61
- function handleClaudeOutput(msg, scheduleHighlight) {
62
- const data = msg.data;
63
- if (!data) return;
64
-
65
- if (data.type === 'content_block_delta' && data.delta) {
66
- streaming.appendPending(data.delta);
67
- streaming.startReveal();
68
- return;
69
- }
70
-
71
- if (data.type === 'tool_use' && data.tools) {
72
- streaming.flushReveal();
73
- finalizeStreamingMsg(scheduleHighlight);
74
-
75
- for (const tool of data.tools) {
76
- const toolMsg = {
77
- id: streaming.nextId(), role: 'tool',
78
- toolId: tool.id, toolName: tool.name || 'unknown',
79
- toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
80
- hasResult: false, expanded: (tool.name === 'Edit' || tool.name === 'TodoWrite'), timestamp: new Date(),
81
- };
82
- messages.value.push(toolMsg);
83
- if (tool.id) toolMsgMap.set(tool.id, toolMsg);
84
- }
85
- scrollToBottom();
86
- return;
87
- }
88
-
89
- if (data.type === 'user' && data.tool_use_result) {
90
- const result = data.tool_use_result;
91
- const results = Array.isArray(result) ? result : [result];
92
- for (const r of results) {
93
- const toolMsg = toolMsgMap.get(r.tool_use_id);
94
- if (toolMsg) {
95
- toolMsg.toolOutput = typeof r.content === 'string'
96
- ? r.content : JSON.stringify(r.content, null, 2);
97
- toolMsg.hasResult = true;
98
- }
99
- }
100
- scrollToBottom();
101
- return;
102
- }
103
- }
104
-
105
- function connect(scheduleHighlight) {
106
- const sid = getSessionId();
107
- if (!sid) {
108
- status.value = 'No Session';
109
- error.value = 'No session ID in URL. Use a session URL provided by agentlink start.';
110
- return;
111
- }
112
- sessionId.value = sid;
113
- status.value = 'Connecting...';
114
- error.value = '';
115
-
116
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
117
- let wsUrl = `${protocol}//${window.location.host}/?type=web&sessionId=${sid}`;
118
- // Include saved auth token for automatic re-authentication
119
- const savedToken = localStorage.getItem(`agentlink-auth-${sid}`);
120
- if (savedToken) {
121
- wsUrl += `&authToken=${encodeURIComponent(savedToken)}`;
122
- }
123
- ws = new WebSocket(wsUrl);
124
-
125
- ws.onopen = () => { error.value = ''; reconnectAttempts = 0; };
126
-
127
- ws.onmessage = (event) => {
128
- let msg;
129
- const parsed = JSON.parse(event.data);
130
-
131
- // Auth messages are always plaintext (before session key exchange)
132
- if (parsed.type === 'auth_required') {
133
- authRequired.value = true;
134
- authError.value = '';
135
- authLocked.value = false;
136
- status.value = 'Authentication Required';
137
- return;
138
- }
139
- if (parsed.type === 'auth_failed') {
140
- authError.value = parsed.message || 'Incorrect password.';
141
- authAttempts.value = parsed.attemptsRemaining != null
142
- ? `${parsed.attemptsRemaining} attempt${parsed.attemptsRemaining !== 1 ? 's' : ''} remaining`
143
- : null;
144
- authPassword.value = '';
145
- return;
146
- }
147
- if (parsed.type === 'auth_locked') {
148
- authLocked.value = true;
149
- authRequired.value = false;
150
- authError.value = parsed.message || 'Too many failed attempts.';
151
- status.value = 'Locked';
152
- return;
153
- }
154
-
155
- if (parsed.type === 'connected') {
156
- msg = parsed;
157
- if (typeof parsed.sessionKey === 'string') {
158
- sessionKey = decodeKey(parsed.sessionKey);
159
- }
160
- } else if (sessionKey && isEncrypted(parsed)) {
161
- msg = decrypt(parsed, sessionKey);
162
- if (!msg) {
163
- console.error('[WS] Failed to decrypt message');
164
- return;
165
- }
166
- } else {
167
- msg = parsed;
168
- }
169
-
170
- if (msg.type === 'connected') {
171
- // Reset auth state
172
- authRequired.value = false;
173
- authPassword.value = '';
174
- authError.value = '';
175
- authAttempts.value = null;
176
- authLocked.value = false;
177
- // Save auth token for automatic re-authentication
178
- if (msg.authToken) {
179
- localStorage.setItem(`agentlink-auth-${sessionId.value}`, msg.authToken);
180
- }
181
- if (msg.serverVersion) serverVersion.value = msg.serverVersion;
182
- if (msg.agent) {
183
- status.value = 'Connected';
184
- agentName.value = msg.agent.name;
185
- hostname.value = msg.agent.hostname || '';
186
- workDir.value = msg.agent.workDir;
187
- agentVersion.value = msg.agent.version || '';
188
- sidebar.loadWorkdirHistory();
189
- sidebar.addToWorkdirHistory(msg.agent.workDir);
190
- const savedDir = localStorage.getItem(`agentlink-workdir-${sessionId.value}`);
191
- if (savedDir && savedDir !== msg.agent.workDir) {
192
- wsSend({ type: 'change_workdir', workDir: savedDir });
193
- }
194
- sidebar.requestSessionList();
195
- } else {
196
- status.value = 'Waiting';
197
- error.value = 'Agent is not connected yet.';
198
- }
199
- } else if (msg.type === 'agent_disconnected') {
200
- status.value = 'Waiting';
201
- agentName.value = '';
202
- hostname.value = '';
203
- error.value = 'Agent disconnected. Waiting for reconnect...';
204
- isProcessing.value = false;
205
- isCompacting.value = false;
206
- } else if (msg.type === 'agent_reconnected') {
207
- status.value = 'Connected';
208
- error.value = '';
209
- if (msg.agent) {
210
- agentName.value = msg.agent.name;
211
- hostname.value = msg.agent.hostname || '';
212
- workDir.value = msg.agent.workDir;
213
- agentVersion.value = msg.agent.version || '';
214
- workDir.value = msg.agent.workDir;
215
- sidebar.addToWorkdirHistory(msg.agent.workDir);
216
- }
217
- sidebar.requestSessionList();
218
- } else if (msg.type === 'error') {
219
- streaming.flushReveal();
220
- finalizeStreamingMsg(scheduleHighlight);
221
- messages.value.push({
222
- id: streaming.nextId(), role: 'system',
223
- content: msg.message, isError: true,
224
- timestamp: new Date(),
225
- });
226
- scrollToBottom();
227
- isProcessing.value = false;
228
- isCompacting.value = false;
229
- } else if (msg.type === 'claude_output') {
230
- handleClaudeOutput(msg, scheduleHighlight);
231
- } else if (msg.type === 'command_output') {
232
- streaming.flushReveal();
233
- finalizeStreamingMsg(scheduleHighlight);
234
- messages.value.push({
235
- id: streaming.nextId(), role: 'system',
236
- content: msg.content, isCommandOutput: true,
237
- timestamp: new Date(),
238
- });
239
- scrollToBottom();
240
- } else if (msg.type === 'context_compaction') {
241
- if (msg.status === 'started') {
242
- isCompacting.value = true;
243
- messages.value.push({
244
- id: streaming.nextId(), role: 'system',
245
- content: 'Context compacting...', isCompactStart: true,
246
- timestamp: new Date(),
247
- });
248
- scrollToBottom();
249
- } else if (msg.status === 'completed') {
250
- isCompacting.value = false;
251
- // Update the start message to show completed
252
- const startMsg = [...messages.value].reverse().find(m => m.isCompactStart && !m.compactDone);
253
- if (startMsg) {
254
- startMsg.content = 'Context compacted';
255
- startMsg.compactDone = true;
256
- }
257
- scrollToBottom();
258
- }
259
- } else if (msg.type === 'turn_completed' || msg.type === 'execution_cancelled') {
260
- streaming.flushReveal();
261
- finalizeStreamingMsg(scheduleHighlight);
262
- isProcessing.value = false;
263
- isCompacting.value = false;
264
- toolMsgMap.clear();
265
- if (msg.type === 'execution_cancelled') {
266
- messages.value.push({
267
- id: streaming.nextId(), role: 'system',
268
- content: 'Generation stopped.', timestamp: new Date(),
269
- });
270
- scrollToBottom();
271
- }
272
- } else if (msg.type === 'ask_user_question') {
273
- streaming.flushReveal();
274
- finalizeStreamingMsg(scheduleHighlight);
275
- for (let i = messages.value.length - 1; i >= 0; i--) {
276
- const m = messages.value[i];
277
- if (m.role === 'tool' && m.toolName === 'AskUserQuestion') {
278
- messages.value.splice(i, 1);
279
- break;
280
- }
281
- if (m.role === 'user') break;
282
- }
283
- const questions = msg.questions || [];
284
- const selectedAnswers = {};
285
- const customTexts = {};
286
- for (let i = 0; i < questions.length; i++) {
287
- selectedAnswers[i] = questions[i].multiSelect ? [] : null;
288
- customTexts[i] = '';
289
- }
290
- messages.value.push({
291
- id: streaming.nextId(),
292
- role: 'ask-question',
293
- requestId: msg.requestId,
294
- questions,
295
- answered: false,
296
- selectedAnswers,
297
- customTexts,
298
- timestamp: new Date(),
299
- });
300
- scrollToBottom();
301
- } else if (msg.type === 'sessions_list') {
302
- historySessions.value = msg.sessions || [];
303
- loadingSessions.value = false;
304
- } else if (msg.type === 'session_deleted') {
305
- historySessions.value = historySessions.value.filter(s => s.sessionId !== msg.sessionId);
306
- } else if (msg.type === 'conversation_resumed') {
307
- currentClaudeSessionId.value = msg.claudeSessionId;
308
- if (msg.history && Array.isArray(msg.history)) {
309
- const batch = [];
310
- for (const h of msg.history) {
311
- if (h.role === 'user') {
312
- if (isContextSummary(h.content)) {
313
- batch.push({
314
- id: streaming.nextId(), role: 'context-summary',
315
- content: h.content, contextExpanded: false,
316
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
317
- });
318
- } else if (h.isCommandOutput) {
319
- batch.push({
320
- id: streaming.nextId(), role: 'system',
321
- content: h.content, isCommandOutput: true,
322
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
323
- });
324
- } else {
325
- batch.push({
326
- id: streaming.nextId(), role: 'user',
327
- content: h.content,
328
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
329
- });
330
- }
331
- } else if (h.role === 'assistant') {
332
- const last = batch[batch.length - 1];
333
- if (last && last.role === 'assistant' && !last.isStreaming) {
334
- last.content += '\n\n' + h.content;
335
- } else {
336
- batch.push({
337
- id: streaming.nextId(), role: 'assistant',
338
- content: h.content, isStreaming: false,
339
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
340
- });
341
- }
342
- } else if (h.role === 'tool') {
343
- batch.push({
344
- id: streaming.nextId(), role: 'tool',
345
- toolId: h.toolId || '', toolName: h.toolName || 'unknown',
346
- toolInput: h.toolInput || '', hasResult: true,
347
- expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite'), timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
348
- });
349
- }
350
- }
351
- messages.value = batch;
352
- toolMsgMap.clear();
353
- }
354
- loadingHistory.value = false;
355
- // Restore live status from agent (compacting / processing)
356
- if (msg.isCompacting) {
357
- isCompacting.value = true;
358
- isProcessing.value = true;
359
- messages.value.push({
360
- id: streaming.nextId(), role: 'system',
361
- content: 'Context compacting...', isCompactStart: true,
362
- timestamp: new Date(),
363
- });
364
- } else if (msg.isProcessing) {
365
- isProcessing.value = true;
366
- messages.value.push({
367
- id: streaming.nextId(), role: 'system',
368
- content: 'Agent is processing...',
369
- timestamp: new Date(),
370
- });
371
- } else {
372
- messages.value.push({
373
- id: streaming.nextId(), role: 'system',
374
- content: 'Session restored. You can continue the conversation.',
375
- timestamp: new Date(),
376
- });
377
- }
378
- scrollToBottom();
379
- } else if (msg.type === 'directory_listing') {
380
- folderPickerLoading.value = false;
381
- folderPickerEntries.value = (msg.entries || [])
382
- .filter(e => e.type === 'directory')
383
- .sort((a, b) => a.name.localeCompare(b.name));
384
- if (msg.dirPath != null) folderPickerPath.value = msg.dirPath;
385
- } else if (msg.type === 'workdir_changed') {
386
- workDir.value = msg.workDir;
387
- localStorage.setItem(`agentlink-workdir-${sessionId.value}`, msg.workDir);
388
- sidebar.addToWorkdirHistory(msg.workDir);
389
- messages.value = [];
390
- toolMsgMap.clear();
391
- visibleLimit.value = 50;
392
- streaming.setMessageIdCounter(0);
393
- streaming.setStreamingMessageId(null);
394
- streaming.reset();
395
- currentClaudeSessionId.value = null;
396
- isProcessing.value = false;
397
- messages.value.push({
398
- id: streaming.nextId(), role: 'system',
399
- content: 'Working directory changed to: ' + msg.workDir,
400
- timestamp: new Date(),
401
- });
402
- sidebar.requestSessionList();
403
- }
404
- };
405
-
406
- ws.onclose = () => {
407
- sessionKey = null;
408
- const wasConnected = status.value === 'Connected' || status.value === 'Connecting...';
409
- isProcessing.value = false;
410
- isCompacting.value = false;
411
-
412
- // Don't auto-reconnect if auth-locked or still in auth prompt
413
- if (authLocked.value || authRequired.value) return;
414
-
415
- if (wasConnected || reconnectAttempts > 0) {
416
- scheduleReconnect(scheduleHighlight);
417
- }
418
- };
419
-
420
- ws.onerror = () => {};
421
- }
422
-
423
- function scheduleReconnect(scheduleHighlight) {
424
- if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
425
- status.value = 'Disconnected';
426
- error.value = 'Unable to reconnect. Please refresh the page.';
427
- return;
428
- }
429
- const delay = Math.min(RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts), RECONNECT_MAX_DELAY);
430
- reconnectAttempts++;
431
- status.value = 'Reconnecting...';
432
- error.value = 'Connection lost. Reconnecting... (attempt ' + reconnectAttempts + ')';
433
- if (reconnectTimer) clearTimeout(reconnectTimer);
434
- reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(scheduleHighlight); }, delay);
435
- }
436
-
437
- function closeWs() {
438
- if (reconnectTimer) clearTimeout(reconnectTimer);
439
- if (ws) ws.close();
440
- }
441
-
442
- function submitPassword() {
443
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
444
- const pwd = authPassword.value.trim();
445
- if (!pwd) return;
446
- ws.send(JSON.stringify({ type: 'authenticate', password: pwd }));
447
- }
448
-
449
- return { connect, wsSend, closeWs, submitPassword };
450
- }
1
+ // ── WebSocket connection, message routing, reconnection ──────────────────────
2
+ import { encrypt, decrypt, isEncrypted, decodeKey } from '../encryption.js';
3
+ import { isContextSummary } from './messageHelpers.js';
4
+
5
+ const MAX_RECONNECT_ATTEMPTS = 50;
6
+ const RECONNECT_BASE_DELAY = 1000;
7
+ const RECONNECT_MAX_DELAY = 15000;
8
+
9
+ /**
10
+ * Creates the WebSocket connection controller.
11
+ * @param {object} deps - All reactive state and callbacks needed
12
+ */
13
+ export function createConnection(deps) {
14
+ const {
15
+ status, agentName, hostname, workDir, sessionId, error,
16
+ serverVersion, agentVersion,
17
+ messages, isProcessing, isCompacting, visibleLimit,
18
+ historySessions, currentClaudeSessionId, loadingSessions, loadingHistory,
19
+ folderPickerLoading, folderPickerEntries, folderPickerPath,
20
+ authRequired, authPassword, authError, authAttempts, authLocked,
21
+ streaming, sidebar,
22
+ scrollToBottom,
23
+ } = deps;
24
+
25
+ let ws = null;
26
+ let sessionKey = null;
27
+ let reconnectAttempts = 0;
28
+ let reconnectTimer = null;
29
+ const toolMsgMap = new Map(); // toolId -> message (for fast tool_result lookup)
30
+
31
+ function wsSend(msg) {
32
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
33
+ if (sessionKey) {
34
+ const encrypted = encrypt(msg, sessionKey);
35
+ ws.send(JSON.stringify(encrypted));
36
+ } else {
37
+ ws.send(JSON.stringify(msg));
38
+ }
39
+ }
40
+
41
+ function getSessionId() {
42
+ const match = window.location.pathname.match(/^\/s\/([^/]+)/);
43
+ return match ? match[1] : null;
44
+ }
45
+
46
+ function finalizeStreamingMsg(scheduleHighlight) {
47
+ const sid = streaming.getStreamingMessageId();
48
+ if (sid === null) return;
49
+ const streamMsg = messages.value.find(m => m.id === sid);
50
+ if (streamMsg) {
51
+ streamMsg.isStreaming = false;
52
+ if (isContextSummary(streamMsg.content)) {
53
+ streamMsg.role = 'context-summary';
54
+ streamMsg.contextExpanded = false;
55
+ }
56
+ }
57
+ streaming.setStreamingMessageId(null);
58
+ if (scheduleHighlight) scheduleHighlight();
59
+ }
60
+
61
+ function handleClaudeOutput(msg, scheduleHighlight) {
62
+ const data = msg.data;
63
+ if (!data) return;
64
+
65
+ if (data.type === 'content_block_delta' && data.delta) {
66
+ streaming.appendPending(data.delta);
67
+ streaming.startReveal();
68
+ return;
69
+ }
70
+
71
+ if (data.type === 'tool_use' && data.tools) {
72
+ streaming.flushReveal();
73
+ finalizeStreamingMsg(scheduleHighlight);
74
+
75
+ for (const tool of data.tools) {
76
+ const toolMsg = {
77
+ id: streaming.nextId(), role: 'tool',
78
+ toolId: tool.id, toolName: tool.name || 'unknown',
79
+ toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
80
+ hasResult: false, expanded: (tool.name === 'Edit' || tool.name === 'TodoWrite'), timestamp: new Date(),
81
+ };
82
+ messages.value.push(toolMsg);
83
+ if (tool.id) toolMsgMap.set(tool.id, toolMsg);
84
+ }
85
+ scrollToBottom();
86
+ return;
87
+ }
88
+
89
+ if (data.type === 'user' && data.tool_use_result) {
90
+ const result = data.tool_use_result;
91
+ const results = Array.isArray(result) ? result : [result];
92
+ for (const r of results) {
93
+ const toolMsg = toolMsgMap.get(r.tool_use_id);
94
+ if (toolMsg) {
95
+ toolMsg.toolOutput = typeof r.content === 'string'
96
+ ? r.content : JSON.stringify(r.content, null, 2);
97
+ toolMsg.hasResult = true;
98
+ }
99
+ }
100
+ scrollToBottom();
101
+ return;
102
+ }
103
+ }
104
+
105
+ function connect(scheduleHighlight) {
106
+ const sid = getSessionId();
107
+ if (!sid) {
108
+ status.value = 'No Session';
109
+ error.value = 'No session ID in URL. Use a session URL provided by agentlink start.';
110
+ return;
111
+ }
112
+ sessionId.value = sid;
113
+ status.value = 'Connecting...';
114
+ error.value = '';
115
+
116
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
117
+ let wsUrl = `${protocol}//${window.location.host}/?type=web&sessionId=${sid}`;
118
+ // Include saved auth token for automatic re-authentication
119
+ const savedToken = localStorage.getItem(`agentlink-auth-${sid}`);
120
+ if (savedToken) {
121
+ wsUrl += `&authToken=${encodeURIComponent(savedToken)}`;
122
+ }
123
+ ws = new WebSocket(wsUrl);
124
+
125
+ ws.onopen = () => { error.value = ''; reconnectAttempts = 0; };
126
+
127
+ ws.onmessage = (event) => {
128
+ let msg;
129
+ const parsed = JSON.parse(event.data);
130
+
131
+ // Auth messages are always plaintext (before session key exchange)
132
+ if (parsed.type === 'auth_required') {
133
+ authRequired.value = true;
134
+ authError.value = '';
135
+ authLocked.value = false;
136
+ status.value = 'Authentication Required';
137
+ return;
138
+ }
139
+ if (parsed.type === 'auth_failed') {
140
+ authError.value = parsed.message || 'Incorrect password.';
141
+ authAttempts.value = parsed.attemptsRemaining != null
142
+ ? `${parsed.attemptsRemaining} attempt${parsed.attemptsRemaining !== 1 ? 's' : ''} remaining`
143
+ : null;
144
+ authPassword.value = '';
145
+ return;
146
+ }
147
+ if (parsed.type === 'auth_locked') {
148
+ authLocked.value = true;
149
+ authRequired.value = false;
150
+ authError.value = parsed.message || 'Too many failed attempts.';
151
+ status.value = 'Locked';
152
+ return;
153
+ }
154
+
155
+ if (parsed.type === 'connected') {
156
+ msg = parsed;
157
+ if (typeof parsed.sessionKey === 'string') {
158
+ sessionKey = decodeKey(parsed.sessionKey);
159
+ }
160
+ } else if (sessionKey && isEncrypted(parsed)) {
161
+ msg = decrypt(parsed, sessionKey);
162
+ if (!msg) {
163
+ console.error('[WS] Failed to decrypt message');
164
+ return;
165
+ }
166
+ } else {
167
+ msg = parsed;
168
+ }
169
+
170
+ if (msg.type === 'connected') {
171
+ // Reset auth state
172
+ authRequired.value = false;
173
+ authPassword.value = '';
174
+ authError.value = '';
175
+ authAttempts.value = null;
176
+ authLocked.value = false;
177
+ // Save auth token for automatic re-authentication
178
+ if (msg.authToken) {
179
+ localStorage.setItem(`agentlink-auth-${sessionId.value}`, msg.authToken);
180
+ }
181
+ if (msg.serverVersion) serverVersion.value = msg.serverVersion;
182
+ if (msg.agent) {
183
+ status.value = 'Connected';
184
+ agentName.value = msg.agent.name;
185
+ hostname.value = msg.agent.hostname || '';
186
+ workDir.value = msg.agent.workDir;
187
+ agentVersion.value = msg.agent.version || '';
188
+ sidebar.loadWorkdirHistory();
189
+ sidebar.addToWorkdirHistory(msg.agent.workDir);
190
+ const savedDir = localStorage.getItem(`agentlink-workdir-${sessionId.value}`);
191
+ if (savedDir && savedDir !== msg.agent.workDir) {
192
+ wsSend({ type: 'change_workdir', workDir: savedDir });
193
+ }
194
+ sidebar.requestSessionList();
195
+ } else {
196
+ status.value = 'Waiting';
197
+ error.value = 'Agent is not connected yet.';
198
+ }
199
+ } else if (msg.type === 'agent_disconnected') {
200
+ status.value = 'Waiting';
201
+ agentName.value = '';
202
+ hostname.value = '';
203
+ error.value = 'Agent disconnected. Waiting for reconnect...';
204
+ isProcessing.value = false;
205
+ isCompacting.value = false;
206
+ } else if (msg.type === 'agent_reconnected') {
207
+ status.value = 'Connected';
208
+ error.value = '';
209
+ if (msg.agent) {
210
+ agentName.value = msg.agent.name;
211
+ hostname.value = msg.agent.hostname || '';
212
+ workDir.value = msg.agent.workDir;
213
+ agentVersion.value = msg.agent.version || '';
214
+ workDir.value = msg.agent.workDir;
215
+ sidebar.addToWorkdirHistory(msg.agent.workDir);
216
+ }
217
+ sidebar.requestSessionList();
218
+ } else if (msg.type === 'error') {
219
+ streaming.flushReveal();
220
+ finalizeStreamingMsg(scheduleHighlight);
221
+ messages.value.push({
222
+ id: streaming.nextId(), role: 'system',
223
+ content: msg.message, isError: true,
224
+ timestamp: new Date(),
225
+ });
226
+ scrollToBottom();
227
+ isProcessing.value = false;
228
+ isCompacting.value = false;
229
+ } else if (msg.type === 'claude_output') {
230
+ handleClaudeOutput(msg, scheduleHighlight);
231
+ } else if (msg.type === 'command_output') {
232
+ streaming.flushReveal();
233
+ finalizeStreamingMsg(scheduleHighlight);
234
+ messages.value.push({
235
+ id: streaming.nextId(), role: 'system',
236
+ content: msg.content, isCommandOutput: true,
237
+ timestamp: new Date(),
238
+ });
239
+ scrollToBottom();
240
+ } else if (msg.type === 'context_compaction') {
241
+ if (msg.status === 'started') {
242
+ isCompacting.value = true;
243
+ messages.value.push({
244
+ id: streaming.nextId(), role: 'system',
245
+ content: 'Context compacting...', isCompactStart: true,
246
+ timestamp: new Date(),
247
+ });
248
+ scrollToBottom();
249
+ } else if (msg.status === 'completed') {
250
+ isCompacting.value = false;
251
+ // Update the start message to show completed
252
+ const startMsg = [...messages.value].reverse().find(m => m.isCompactStart && !m.compactDone);
253
+ if (startMsg) {
254
+ startMsg.content = 'Context compacted';
255
+ startMsg.compactDone = true;
256
+ }
257
+ scrollToBottom();
258
+ }
259
+ } else if (msg.type === 'turn_completed' || msg.type === 'execution_cancelled') {
260
+ streaming.flushReveal();
261
+ finalizeStreamingMsg(scheduleHighlight);
262
+ isProcessing.value = false;
263
+ isCompacting.value = false;
264
+ toolMsgMap.clear();
265
+ if (msg.type === 'execution_cancelled') {
266
+ messages.value.push({
267
+ id: streaming.nextId(), role: 'system',
268
+ content: 'Generation stopped.', timestamp: new Date(),
269
+ });
270
+ scrollToBottom();
271
+ }
272
+ } else if (msg.type === 'ask_user_question') {
273
+ streaming.flushReveal();
274
+ finalizeStreamingMsg(scheduleHighlight);
275
+ for (let i = messages.value.length - 1; i >= 0; i--) {
276
+ const m = messages.value[i];
277
+ if (m.role === 'tool' && m.toolName === 'AskUserQuestion') {
278
+ messages.value.splice(i, 1);
279
+ break;
280
+ }
281
+ if (m.role === 'user') break;
282
+ }
283
+ const questions = msg.questions || [];
284
+ const selectedAnswers = {};
285
+ const customTexts = {};
286
+ for (let i = 0; i < questions.length; i++) {
287
+ selectedAnswers[i] = questions[i].multiSelect ? [] : null;
288
+ customTexts[i] = '';
289
+ }
290
+ messages.value.push({
291
+ id: streaming.nextId(),
292
+ role: 'ask-question',
293
+ requestId: msg.requestId,
294
+ questions,
295
+ answered: false,
296
+ selectedAnswers,
297
+ customTexts,
298
+ timestamp: new Date(),
299
+ });
300
+ scrollToBottom();
301
+ } else if (msg.type === 'sessions_list') {
302
+ historySessions.value = msg.sessions || [];
303
+ loadingSessions.value = false;
304
+ } else if (msg.type === 'session_deleted') {
305
+ historySessions.value = historySessions.value.filter(s => s.sessionId !== msg.sessionId);
306
+ } else if (msg.type === 'conversation_resumed') {
307
+ currentClaudeSessionId.value = msg.claudeSessionId;
308
+ if (msg.history && Array.isArray(msg.history)) {
309
+ const batch = [];
310
+ for (const h of msg.history) {
311
+ if (h.role === 'user') {
312
+ if (isContextSummary(h.content)) {
313
+ batch.push({
314
+ id: streaming.nextId(), role: 'context-summary',
315
+ content: h.content, contextExpanded: false,
316
+ timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
317
+ });
318
+ } else if (h.isCommandOutput) {
319
+ batch.push({
320
+ id: streaming.nextId(), role: 'system',
321
+ content: h.content, isCommandOutput: true,
322
+ timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
323
+ });
324
+ } else {
325
+ batch.push({
326
+ id: streaming.nextId(), role: 'user',
327
+ content: h.content,
328
+ timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
329
+ });
330
+ }
331
+ } else if (h.role === 'assistant') {
332
+ const last = batch[batch.length - 1];
333
+ if (last && last.role === 'assistant' && !last.isStreaming) {
334
+ last.content += '\n\n' + h.content;
335
+ } else {
336
+ batch.push({
337
+ id: streaming.nextId(), role: 'assistant',
338
+ content: h.content, isStreaming: false,
339
+ timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
340
+ });
341
+ }
342
+ } else if (h.role === 'tool') {
343
+ batch.push({
344
+ id: streaming.nextId(), role: 'tool',
345
+ toolId: h.toolId || '', toolName: h.toolName || 'unknown',
346
+ toolInput: h.toolInput || '', hasResult: true,
347
+ expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite'), timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
348
+ });
349
+ }
350
+ }
351
+ messages.value = batch;
352
+ toolMsgMap.clear();
353
+ }
354
+ loadingHistory.value = false;
355
+ // Restore live status from agent (compacting / processing)
356
+ if (msg.isCompacting) {
357
+ isCompacting.value = true;
358
+ isProcessing.value = true;
359
+ messages.value.push({
360
+ id: streaming.nextId(), role: 'system',
361
+ content: 'Context compacting...', isCompactStart: true,
362
+ timestamp: new Date(),
363
+ });
364
+ } else if (msg.isProcessing) {
365
+ isProcessing.value = true;
366
+ messages.value.push({
367
+ id: streaming.nextId(), role: 'system',
368
+ content: 'Agent is processing...',
369
+ timestamp: new Date(),
370
+ });
371
+ } else {
372
+ messages.value.push({
373
+ id: streaming.nextId(), role: 'system',
374
+ content: 'Session restored. You can continue the conversation.',
375
+ timestamp: new Date(),
376
+ });
377
+ }
378
+ scrollToBottom();
379
+ } else if (msg.type === 'directory_listing') {
380
+ folderPickerLoading.value = false;
381
+ folderPickerEntries.value = (msg.entries || [])
382
+ .filter(e => e.type === 'directory')
383
+ .sort((a, b) => a.name.localeCompare(b.name));
384
+ if (msg.dirPath != null) folderPickerPath.value = msg.dirPath;
385
+ } else if (msg.type === 'workdir_changed') {
386
+ workDir.value = msg.workDir;
387
+ localStorage.setItem(`agentlink-workdir-${sessionId.value}`, msg.workDir);
388
+ sidebar.addToWorkdirHistory(msg.workDir);
389
+ messages.value = [];
390
+ toolMsgMap.clear();
391
+ visibleLimit.value = 50;
392
+ streaming.setMessageIdCounter(0);
393
+ streaming.setStreamingMessageId(null);
394
+ streaming.reset();
395
+ currentClaudeSessionId.value = null;
396
+ isProcessing.value = false;
397
+ messages.value.push({
398
+ id: streaming.nextId(), role: 'system',
399
+ content: 'Working directory changed to: ' + msg.workDir,
400
+ timestamp: new Date(),
401
+ });
402
+ sidebar.requestSessionList();
403
+ }
404
+ };
405
+
406
+ ws.onclose = () => {
407
+ sessionKey = null;
408
+ const wasConnected = status.value === 'Connected' || status.value === 'Connecting...';
409
+ isProcessing.value = false;
410
+ isCompacting.value = false;
411
+
412
+ // Don't auto-reconnect if auth-locked or still in auth prompt
413
+ if (authLocked.value || authRequired.value) return;
414
+
415
+ if (wasConnected || reconnectAttempts > 0) {
416
+ scheduleReconnect(scheduleHighlight);
417
+ }
418
+ };
419
+
420
+ ws.onerror = () => {};
421
+ }
422
+
423
+ function scheduleReconnect(scheduleHighlight) {
424
+ if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
425
+ status.value = 'Disconnected';
426
+ error.value = 'Unable to reconnect. Please refresh the page.';
427
+ return;
428
+ }
429
+ const delay = Math.min(RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts), RECONNECT_MAX_DELAY);
430
+ reconnectAttempts++;
431
+ status.value = 'Reconnecting...';
432
+ error.value = 'Connection lost. Reconnecting... (attempt ' + reconnectAttempts + ')';
433
+ if (reconnectTimer) clearTimeout(reconnectTimer);
434
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(scheduleHighlight); }, delay);
435
+ }
436
+
437
+ function closeWs() {
438
+ if (reconnectTimer) clearTimeout(reconnectTimer);
439
+ if (ws) ws.close();
440
+ }
441
+
442
+ function submitPassword() {
443
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
444
+ const pwd = authPassword.value.trim();
445
+ if (!pwd) return;
446
+ ws.send(JSON.stringify({ type: 'authenticate', password: pwd }));
447
+ }
448
+
449
+ return { connect, wsSend, closeWs, submitPassword };
450
+ }