@agent-link/server 0.1.32 → 0.1.34

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,369 +1,350 @@
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
- messages, isProcessing, isCompacting, visibleLimit,
17
- historySessions, currentClaudeSessionId, needsResume, loadingSessions, loadingHistory,
18
- folderPickerLoading, folderPickerEntries, folderPickerPath,
19
- streaming, sidebar,
20
- scrollToBottom,
21
- } = deps;
22
-
23
- let ws = null;
24
- let sessionKey = null;
25
- let reconnectAttempts = 0;
26
- let reconnectTimer = null;
27
-
28
- function wsSend(msg) {
29
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
30
- if (sessionKey) {
31
- const encrypted = encrypt(msg, sessionKey);
32
- ws.send(JSON.stringify(encrypted));
33
- } else {
34
- ws.send(JSON.stringify(msg));
35
- }
36
- }
37
-
38
- function getSessionId() {
39
- const match = window.location.pathname.match(/^\/s\/([^/]+)/);
40
- return match ? match[1] : null;
41
- }
42
-
43
- function finalizeStreamingMsg(scheduleHighlight) {
44
- const sid = streaming.getStreamingMessageId();
45
- if (sid === null) return;
46
- const streamMsg = messages.value.find(m => m.id === sid);
47
- if (streamMsg) {
48
- streamMsg.isStreaming = false;
49
- if (isContextSummary(streamMsg.content)) {
50
- streamMsg.role = 'context-summary';
51
- streamMsg.contextExpanded = false;
52
- }
53
- }
54
- streaming.setStreamingMessageId(null);
55
- if (scheduleHighlight) scheduleHighlight();
56
- }
57
-
58
- function handleClaudeOutput(msg, scheduleHighlight) {
59
- const data = msg.data;
60
- if (!data) return;
61
-
62
- if (data.type === 'content_block_delta' && data.delta) {
63
- streaming.appendPending(data.delta);
64
- streaming.startReveal();
65
- return;
66
- }
67
-
68
- if (data.type === 'tool_use' && data.tools) {
69
- streaming.flushReveal();
70
- finalizeStreamingMsg(scheduleHighlight);
71
-
72
- for (const tool of data.tools) {
73
- messages.value.push({
74
- id: streaming.nextId(), role: 'tool',
75
- toolId: tool.id, toolName: tool.name || 'unknown',
76
- toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
77
- hasResult: false, expanded: (tool.name === 'Edit' || tool.name === 'TodoWrite'), timestamp: new Date(),
78
- });
79
- }
80
- scrollToBottom();
81
- return;
82
- }
83
-
84
- if (data.type === 'user' && data.tool_use_result) {
85
- const result = data.tool_use_result;
86
- const results = Array.isArray(result) ? result : [result];
87
- for (const r of results) {
88
- const toolMsg = [...messages.value].reverse().find(
89
- m => m.role === 'tool' && m.toolId === r.tool_use_id
90
- );
91
- if (toolMsg) {
92
- toolMsg.toolOutput = typeof r.content === 'string'
93
- ? r.content : JSON.stringify(r.content, null, 2);
94
- toolMsg.hasResult = true;
95
- }
96
- }
97
- scrollToBottom();
98
- return;
99
- }
100
- }
101
-
102
- function connect(scheduleHighlight) {
103
- const sid = getSessionId();
104
- if (!sid) {
105
- status.value = 'No Session';
106
- error.value = 'No session ID in URL. Use a session URL provided by agentlink start.';
107
- return;
108
- }
109
- sessionId.value = sid;
110
- status.value = 'Connecting...';
111
- error.value = '';
112
-
113
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
114
- const wsUrl = `${protocol}//${window.location.host}/?type=web&sessionId=${sid}`;
115
- ws = new WebSocket(wsUrl);
116
-
117
- ws.onopen = () => { error.value = ''; reconnectAttempts = 0; };
118
-
119
- ws.onmessage = (event) => {
120
- let msg;
121
- const parsed = JSON.parse(event.data);
122
-
123
- if (parsed.type === 'connected') {
124
- msg = parsed;
125
- if (typeof parsed.sessionKey === 'string') {
126
- sessionKey = decodeKey(parsed.sessionKey);
127
- }
128
- } else if (sessionKey && isEncrypted(parsed)) {
129
- msg = decrypt(parsed, sessionKey);
130
- if (!msg) {
131
- console.error('[WS] Failed to decrypt message');
132
- return;
133
- }
134
- } else {
135
- msg = parsed;
136
- }
137
-
138
- if (msg.type === 'connected') {
139
- if (msg.agent) {
140
- status.value = 'Connected';
141
- agentName.value = msg.agent.name;
142
- hostname.value = msg.agent.hostname || '';
143
- workDir.value = msg.agent.workDir;
144
- // Restore processing state (e.g. after page refresh mid-turn)
145
- if (msg.agent.processing) {
146
- isProcessing.value = true;
147
- }
148
- const savedDir = localStorage.getItem('agentlink-workdir');
149
- if (savedDir && savedDir !== msg.agent.workDir) {
150
- wsSend({ type: 'change_workdir', workDir: savedDir });
151
- }
152
- sidebar.requestSessionList();
153
- // Auto-resume last active session after page refresh
154
- if (currentClaudeSessionId.value && messages.value.length === 0) {
155
- needsResume.value = true;
156
- loadingHistory.value = true;
157
- wsSend({
158
- type: 'resume_conversation',
159
- claudeSessionId: currentClaudeSessionId.value,
160
- });
161
- }
162
- } else {
163
- status.value = 'Waiting';
164
- error.value = 'Agent is not connected yet.';
165
- }
166
- } else if (msg.type === 'agent_disconnected') {
167
- status.value = 'Waiting';
168
- agentName.value = '';
169
- hostname.value = '';
170
- error.value = 'Agent disconnected. Waiting for reconnect...';
171
- isProcessing.value = false;
172
- isCompacting.value = false;
173
- } else if (msg.type === 'agent_reconnected') {
174
- status.value = 'Connected';
175
- error.value = '';
176
- if (msg.agent) {
177
- agentName.value = msg.agent.name;
178
- hostname.value = msg.agent.hostname || '';
179
- workDir.value = msg.agent.workDir;
180
- }
181
- sidebar.requestSessionList();
182
- // Auto-resume last active session after agent reconnect
183
- if (currentClaudeSessionId.value && messages.value.length === 0) {
184
- needsResume.value = true;
185
- loadingHistory.value = true;
186
- wsSend({
187
- type: 'resume_conversation',
188
- claudeSessionId: currentClaudeSessionId.value,
189
- });
190
- }
191
- } else if (msg.type === 'error') {
192
- status.value = 'Error';
193
- error.value = msg.message;
194
- isProcessing.value = false;
195
- isCompacting.value = false;
196
- } else if (msg.type === 'claude_output') {
197
- handleClaudeOutput(msg, scheduleHighlight);
198
- } else if (msg.type === 'command_output') {
199
- streaming.flushReveal();
200
- finalizeStreamingMsg(scheduleHighlight);
201
- messages.value.push({
202
- id: streaming.nextId(), role: 'user',
203
- content: msg.content, isCommandOutput: true,
204
- timestamp: new Date(),
205
- });
206
- scrollToBottom();
207
- } else if (msg.type === 'context_compaction') {
208
- if (msg.status === 'started') {
209
- isCompacting.value = true;
210
- } else if (msg.status === 'completed') {
211
- isCompacting.value = false;
212
- }
213
- } else if (msg.type === 'turn_completed' || msg.type === 'execution_cancelled') {
214
- isProcessing.value = false;
215
- isCompacting.value = false;
216
- streaming.flushReveal();
217
- finalizeStreamingMsg(scheduleHighlight);
218
- if (msg.type === 'execution_cancelled') {
219
- messages.value.push({
220
- id: streaming.nextId(), role: 'system',
221
- content: 'Generation stopped.', timestamp: new Date(),
222
- });
223
- scrollToBottom();
224
- }
225
- } else if (msg.type === 'ask_user_question') {
226
- streaming.flushReveal();
227
- finalizeStreamingMsg(scheduleHighlight);
228
- for (let i = messages.value.length - 1; i >= 0; i--) {
229
- const m = messages.value[i];
230
- if (m.role === 'tool' && m.toolName === 'AskUserQuestion') {
231
- messages.value.splice(i, 1);
232
- break;
233
- }
234
- if (m.role === 'user') break;
235
- }
236
- const questions = msg.questions || [];
237
- const selectedAnswers = {};
238
- const customTexts = {};
239
- for (let i = 0; i < questions.length; i++) {
240
- selectedAnswers[i] = questions[i].multiSelect ? [] : null;
241
- customTexts[i] = '';
242
- }
243
- messages.value.push({
244
- id: streaming.nextId(),
245
- role: 'ask-question',
246
- requestId: msg.requestId,
247
- questions,
248
- answered: false,
249
- selectedAnswers,
250
- customTexts,
251
- timestamp: new Date(),
252
- });
253
- scrollToBottom();
254
- } else if (msg.type === 'sessions_list') {
255
- historySessions.value = msg.sessions || [];
256
- loadingSessions.value = false;
257
- } else if (msg.type === 'session_started') {
258
- currentClaudeSessionId.value = msg.claudeSessionId;
259
- localStorage.setItem('agentlink-claude-session', msg.claudeSessionId);
260
- } else if (msg.type === 'conversation_resumed') {
261
- currentClaudeSessionId.value = msg.claudeSessionId;
262
- localStorage.setItem('agentlink-claude-session', msg.claudeSessionId);
263
- if (msg.history && Array.isArray(msg.history)) {
264
- const batch = [];
265
- for (const h of msg.history) {
266
- if (h.role === 'user') {
267
- if (isContextSummary(h.content)) {
268
- batch.push({
269
- id: streaming.nextId(), role: 'context-summary',
270
- content: h.content, contextExpanded: false,
271
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
272
- });
273
- } else {
274
- batch.push({
275
- id: streaming.nextId(), role: 'user',
276
- content: h.content, isCommandOutput: !!h.isCommandOutput,
277
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
278
- });
279
- }
280
- } else if (h.role === 'assistant') {
281
- const last = batch[batch.length - 1];
282
- if (last && last.role === 'assistant' && !last.isStreaming) {
283
- last.content += '\n\n' + h.content;
284
- } else {
285
- batch.push({
286
- id: streaming.nextId(), role: 'assistant',
287
- content: h.content, isStreaming: false,
288
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
289
- });
290
- }
291
- } else if (h.role === 'tool') {
292
- batch.push({
293
- id: streaming.nextId(), role: 'tool',
294
- toolId: h.toolId || '', toolName: h.toolName || 'unknown',
295
- toolInput: h.toolInput || '', hasResult: true,
296
- expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite'), timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
297
- });
298
- }
299
- }
300
- messages.value = batch;
301
- }
302
- loadingHistory.value = false;
303
- messages.value.push({
304
- id: streaming.nextId(), role: 'system',
305
- content: 'Session restored. You can continue the conversation.',
306
- timestamp: new Date(),
307
- });
308
- scrollToBottom();
309
- } else if (msg.type === 'directory_listing') {
310
- folderPickerLoading.value = false;
311
- folderPickerEntries.value = (msg.entries || [])
312
- .filter(e => e.type === 'directory')
313
- .sort((a, b) => a.name.localeCompare(b.name));
314
- if (msg.dirPath != null) folderPickerPath.value = msg.dirPath;
315
- } else if (msg.type === 'workdir_changed') {
316
- workDir.value = msg.workDir;
317
- localStorage.setItem('agentlink-workdir', msg.workDir);
318
- messages.value = [];
319
- visibleLimit.value = 50;
320
- streaming.setMessageIdCounter(0);
321
- streaming.setStreamingMessageId(null);
322
- streaming.reset();
323
- currentClaudeSessionId.value = null;
324
- localStorage.removeItem('agentlink-claude-session');
325
- isProcessing.value = false;
326
- messages.value.push({
327
- id: streaming.nextId(), role: 'system',
328
- content: 'Working directory changed to: ' + msg.workDir,
329
- timestamp: new Date(),
330
- });
331
- sidebar.requestSessionList();
332
- }
333
- };
334
-
335
- ws.onclose = () => {
336
- sessionKey = null;
337
- const wasConnected = status.value === 'Connected' || status.value === 'Connecting...';
338
- isProcessing.value = false;
339
- isCompacting.value = false;
340
-
341
- if (wasConnected || reconnectAttempts > 0) {
342
- scheduleReconnect(scheduleHighlight);
343
- }
344
- };
345
-
346
- ws.onerror = () => {};
347
- }
348
-
349
- function scheduleReconnect(scheduleHighlight) {
350
- if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
351
- status.value = 'Disconnected';
352
- error.value = 'Unable to reconnect. Please refresh the page.';
353
- return;
354
- }
355
- const delay = Math.min(RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts), RECONNECT_MAX_DELAY);
356
- reconnectAttempts++;
357
- status.value = 'Reconnecting...';
358
- error.value = 'Connection lost. Reconnecting... (attempt ' + reconnectAttempts + ')';
359
- if (reconnectTimer) clearTimeout(reconnectTimer);
360
- reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(scheduleHighlight); }, delay);
361
- }
362
-
363
- function closeWs() {
364
- if (reconnectTimer) clearTimeout(reconnectTimer);
365
- if (ws) ws.close();
366
- }
367
-
368
- return { connect, wsSend, closeWs };
369
- }
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
+ messages, isProcessing, isCompacting, visibleLimit,
17
+ historySessions, currentClaudeSessionId, needsResume, loadingSessions, loadingHistory,
18
+ folderPickerLoading, folderPickerEntries, folderPickerPath,
19
+ streaming, sidebar,
20
+ scrollToBottom,
21
+ } = deps;
22
+
23
+ let ws = null;
24
+ let sessionKey = null;
25
+ let reconnectAttempts = 0;
26
+ let reconnectTimer = null;
27
+
28
+ function wsSend(msg) {
29
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
30
+ if (sessionKey) {
31
+ const encrypted = encrypt(msg, sessionKey);
32
+ ws.send(JSON.stringify(encrypted));
33
+ } else {
34
+ ws.send(JSON.stringify(msg));
35
+ }
36
+ }
37
+
38
+ function getSessionId() {
39
+ const match = window.location.pathname.match(/^\/s\/([^/]+)/);
40
+ return match ? match[1] : null;
41
+ }
42
+
43
+ function finalizeStreamingMsg(scheduleHighlight) {
44
+ const sid = streaming.getStreamingMessageId();
45
+ if (sid === null) return;
46
+ const streamMsg = messages.value.find(m => m.id === sid);
47
+ if (streamMsg) {
48
+ streamMsg.isStreaming = false;
49
+ if (isContextSummary(streamMsg.content)) {
50
+ streamMsg.role = 'context-summary';
51
+ streamMsg.contextExpanded = false;
52
+ }
53
+ }
54
+ streaming.setStreamingMessageId(null);
55
+ if (scheduleHighlight) scheduleHighlight();
56
+ }
57
+
58
+ function handleClaudeOutput(msg, scheduleHighlight) {
59
+ const data = msg.data;
60
+ if (!data) return;
61
+
62
+ if (data.type === 'content_block_delta' && data.delta) {
63
+ streaming.appendPending(data.delta);
64
+ streaming.startReveal();
65
+ return;
66
+ }
67
+
68
+ if (data.type === 'tool_use' && data.tools) {
69
+ streaming.flushReveal();
70
+ finalizeStreamingMsg(scheduleHighlight);
71
+
72
+ for (const tool of data.tools) {
73
+ messages.value.push({
74
+ id: streaming.nextId(), role: 'tool',
75
+ toolId: tool.id, toolName: tool.name || 'unknown',
76
+ toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
77
+ hasResult: false, expanded: (tool.name === 'Edit' || tool.name === 'TodoWrite'), timestamp: new Date(),
78
+ });
79
+ }
80
+ scrollToBottom();
81
+ return;
82
+ }
83
+
84
+ if (data.type === 'user' && data.tool_use_result) {
85
+ const result = data.tool_use_result;
86
+ const results = Array.isArray(result) ? result : [result];
87
+ for (const r of results) {
88
+ const toolMsg = [...messages.value].reverse().find(
89
+ m => m.role === 'tool' && m.toolId === r.tool_use_id
90
+ );
91
+ if (toolMsg) {
92
+ toolMsg.toolOutput = typeof r.content === 'string'
93
+ ? r.content : JSON.stringify(r.content, null, 2);
94
+ toolMsg.hasResult = true;
95
+ }
96
+ }
97
+ scrollToBottom();
98
+ return;
99
+ }
100
+ }
101
+
102
+ function connect(scheduleHighlight) {
103
+ const sid = getSessionId();
104
+ if (!sid) {
105
+ status.value = 'No Session';
106
+ error.value = 'No session ID in URL. Use a session URL provided by agentlink start.';
107
+ return;
108
+ }
109
+ sessionId.value = sid;
110
+ status.value = 'Connecting...';
111
+ error.value = '';
112
+
113
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
114
+ const wsUrl = `${protocol}//${window.location.host}/?type=web&sessionId=${sid}`;
115
+ ws = new WebSocket(wsUrl);
116
+
117
+ ws.onopen = () => { error.value = ''; reconnectAttempts = 0; };
118
+
119
+ ws.onmessage = (event) => {
120
+ let msg;
121
+ const parsed = JSON.parse(event.data);
122
+
123
+ if (parsed.type === 'connected') {
124
+ msg = parsed;
125
+ if (typeof parsed.sessionKey === 'string') {
126
+ sessionKey = decodeKey(parsed.sessionKey);
127
+ }
128
+ } else if (sessionKey && isEncrypted(parsed)) {
129
+ msg = decrypt(parsed, sessionKey);
130
+ if (!msg) {
131
+ console.error('[WS] Failed to decrypt message');
132
+ return;
133
+ }
134
+ } else {
135
+ msg = parsed;
136
+ }
137
+
138
+ if (msg.type === 'connected') {
139
+ if (msg.agent) {
140
+ status.value = 'Connected';
141
+ agentName.value = msg.agent.name;
142
+ hostname.value = msg.agent.hostname || '';
143
+ workDir.value = msg.agent.workDir;
144
+ const savedDir = localStorage.getItem('agentlink-workdir');
145
+ if (savedDir && savedDir !== msg.agent.workDir) {
146
+ wsSend({ type: 'change_workdir', workDir: savedDir });
147
+ }
148
+ // Restore active Claude session (server tracks it across web client reconnects)
149
+ if (msg.agent.claudeSessionId) {
150
+ currentClaudeSessionId.value = msg.agent.claudeSessionId;
151
+ needsResume.value = true;
152
+ }
153
+ sidebar.requestSessionList();
154
+ } else {
155
+ status.value = 'Waiting';
156
+ error.value = 'Agent is not connected yet.';
157
+ }
158
+ } else if (msg.type === 'agent_disconnected') {
159
+ status.value = 'Waiting';
160
+ agentName.value = '';
161
+ hostname.value = '';
162
+ error.value = 'Agent disconnected. Waiting for reconnect...';
163
+ isProcessing.value = false;
164
+ isCompacting.value = false;
165
+ } else if (msg.type === 'agent_reconnected') {
166
+ status.value = 'Connected';
167
+ error.value = '';
168
+ if (msg.agent) {
169
+ agentName.value = msg.agent.name;
170
+ hostname.value = msg.agent.hostname || '';
171
+ workDir.value = msg.agent.workDir;
172
+ }
173
+ sidebar.requestSessionList();
174
+ } else if (msg.type === 'error') {
175
+ status.value = 'Error';
176
+ error.value = msg.message;
177
+ isProcessing.value = false;
178
+ isCompacting.value = false;
179
+ } else if (msg.type === 'claude_output') {
180
+ handleClaudeOutput(msg, scheduleHighlight);
181
+ } else if (msg.type === 'command_output') {
182
+ streaming.flushReveal();
183
+ finalizeStreamingMsg(scheduleHighlight);
184
+ messages.value.push({
185
+ id: streaming.nextId(), role: 'user',
186
+ content: msg.content, isCommandOutput: true,
187
+ timestamp: new Date(),
188
+ });
189
+ scrollToBottom();
190
+ } else if (msg.type === 'context_compaction') {
191
+ if (msg.status === 'started') {
192
+ isCompacting.value = true;
193
+ } else if (msg.status === 'completed') {
194
+ isCompacting.value = false;
195
+ }
196
+ } else if (msg.type === 'turn_completed' || msg.type === 'execution_cancelled') {
197
+ isProcessing.value = false;
198
+ isCompacting.value = false;
199
+ streaming.flushReveal();
200
+ finalizeStreamingMsg(scheduleHighlight);
201
+ if (msg.type === 'execution_cancelled') {
202
+ messages.value.push({
203
+ id: streaming.nextId(), role: 'system',
204
+ content: 'Generation stopped.', timestamp: new Date(),
205
+ });
206
+ scrollToBottom();
207
+ }
208
+ } else if (msg.type === 'ask_user_question') {
209
+ streaming.flushReveal();
210
+ finalizeStreamingMsg(scheduleHighlight);
211
+ for (let i = messages.value.length - 1; i >= 0; i--) {
212
+ const m = messages.value[i];
213
+ if (m.role === 'tool' && m.toolName === 'AskUserQuestion') {
214
+ messages.value.splice(i, 1);
215
+ break;
216
+ }
217
+ if (m.role === 'user') break;
218
+ }
219
+ const questions = msg.questions || [];
220
+ const selectedAnswers = {};
221
+ const customTexts = {};
222
+ for (let i = 0; i < questions.length; i++) {
223
+ selectedAnswers[i] = questions[i].multiSelect ? [] : null;
224
+ customTexts[i] = '';
225
+ }
226
+ messages.value.push({
227
+ id: streaming.nextId(),
228
+ role: 'ask-question',
229
+ requestId: msg.requestId,
230
+ questions,
231
+ answered: false,
232
+ selectedAnswers,
233
+ customTexts,
234
+ timestamp: new Date(),
235
+ });
236
+ scrollToBottom();
237
+ } else if (msg.type === 'sessions_list') {
238
+ historySessions.value = msg.sessions || [];
239
+ loadingSessions.value = false;
240
+ } else if (msg.type === 'session_started') {
241
+ currentClaudeSessionId.value = msg.claudeSessionId;
242
+ } else if (msg.type === 'conversation_resumed') {
243
+ currentClaudeSessionId.value = msg.claudeSessionId;
244
+ if (msg.history && Array.isArray(msg.history)) {
245
+ const batch = [];
246
+ for (const h of msg.history) {
247
+ if (h.role === 'user') {
248
+ if (isContextSummary(h.content)) {
249
+ batch.push({
250
+ id: streaming.nextId(), role: 'context-summary',
251
+ content: h.content, contextExpanded: false,
252
+ timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
253
+ });
254
+ } else {
255
+ batch.push({
256
+ id: streaming.nextId(), role: 'user',
257
+ content: h.content, isCommandOutput: !!h.isCommandOutput,
258
+ timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
259
+ });
260
+ }
261
+ } else if (h.role === 'assistant') {
262
+ const last = batch[batch.length - 1];
263
+ if (last && last.role === 'assistant' && !last.isStreaming) {
264
+ last.content += '\n\n' + h.content;
265
+ } else {
266
+ batch.push({
267
+ id: streaming.nextId(), role: 'assistant',
268
+ content: h.content, isStreaming: false,
269
+ timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
270
+ });
271
+ }
272
+ } else if (h.role === 'tool') {
273
+ batch.push({
274
+ id: streaming.nextId(), role: 'tool',
275
+ toolId: h.toolId || '', toolName: h.toolName || 'unknown',
276
+ toolInput: h.toolInput || '', hasResult: true,
277
+ expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite'), timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
278
+ });
279
+ }
280
+ }
281
+ messages.value = batch;
282
+ }
283
+ loadingHistory.value = false;
284
+ messages.value.push({
285
+ id: streaming.nextId(), role: 'system',
286
+ content: 'Session restored. You can continue the conversation.',
287
+ timestamp: new Date(),
288
+ });
289
+ scrollToBottom();
290
+ } else if (msg.type === 'directory_listing') {
291
+ folderPickerLoading.value = false;
292
+ folderPickerEntries.value = (msg.entries || [])
293
+ .filter(e => e.type === 'directory')
294
+ .sort((a, b) => a.name.localeCompare(b.name));
295
+ if (msg.dirPath != null) folderPickerPath.value = msg.dirPath;
296
+ } else if (msg.type === 'workdir_changed') {
297
+ workDir.value = msg.workDir;
298
+ localStorage.setItem('agentlink-workdir', msg.workDir);
299
+ messages.value = [];
300
+ visibleLimit.value = 50;
301
+ streaming.setMessageIdCounter(0);
302
+ streaming.setStreamingMessageId(null);
303
+ streaming.reset();
304
+ currentClaudeSessionId.value = null;
305
+ needsResume.value = false;
306
+ isProcessing.value = false;
307
+ messages.value.push({
308
+ id: streaming.nextId(), role: 'system',
309
+ content: 'Working directory changed to: ' + msg.workDir,
310
+ timestamp: new Date(),
311
+ });
312
+ sidebar.requestSessionList();
313
+ }
314
+ };
315
+
316
+ ws.onclose = () => {
317
+ sessionKey = null;
318
+ const wasConnected = status.value === 'Connected' || status.value === 'Connecting...';
319
+ isProcessing.value = false;
320
+ isCompacting.value = false;
321
+
322
+ if (wasConnected || reconnectAttempts > 0) {
323
+ scheduleReconnect(scheduleHighlight);
324
+ }
325
+ };
326
+
327
+ ws.onerror = () => {};
328
+ }
329
+
330
+ function scheduleReconnect(scheduleHighlight) {
331
+ if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
332
+ status.value = 'Disconnected';
333
+ error.value = 'Unable to reconnect. Please refresh the page.';
334
+ return;
335
+ }
336
+ const delay = Math.min(RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts), RECONNECT_MAX_DELAY);
337
+ reconnectAttempts++;
338
+ status.value = 'Reconnecting...';
339
+ error.value = 'Connection lost. Reconnecting... (attempt ' + reconnectAttempts + ')';
340
+ if (reconnectTimer) clearTimeout(reconnectTimer);
341
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(scheduleHighlight); }, delay);
342
+ }
343
+
344
+ function closeWs() {
345
+ if (reconnectTimer) clearTimeout(reconnectTimer);
346
+ if (ws) ws.close();
347
+ }
348
+
349
+ return { connect, wsSend, closeWs };
350
+ }