@gricha/perry 0.2.6 → 0.3.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/dist/agent/router.js +127 -0
- package/dist/agent/run.js +157 -99
- package/dist/agent/static.js +32 -0
- package/dist/agent/web/assets/index-CYo-1I5o.css +1 -0
- package/dist/agent/web/assets/index-CZjSxNrg.js +104 -0
- package/dist/agent/web/index.html +2 -2
- package/dist/chat/base-claude-session.js +48 -2
- package/dist/chat/opencode-server.js +241 -24
- package/dist/chat/session-monitor.js +186 -0
- package/dist/chat/session-utils.js +155 -0
- package/dist/client/api.js +19 -0
- package/dist/perry-worker +0 -0
- package/dist/session-manager/adapters/claude.js +256 -0
- package/dist/session-manager/adapters/index.js +2 -0
- package/dist/session-manager/adapters/opencode.js +317 -0
- package/dist/session-manager/bun-handler.js +175 -0
- package/dist/session-manager/index.js +3 -0
- package/dist/session-manager/manager.js +302 -0
- package/dist/session-manager/ring-buffer.js +66 -0
- package/dist/session-manager/types.js +1 -0
- package/dist/session-manager/websocket.js +153 -0
- package/dist/shared/base-websocket.js +39 -7
- package/dist/tailscale/index.js +20 -6
- package/dist/terminal/bun-handler.js +151 -0
- package/package.json +3 -3
- package/dist/agent/web/assets/index-BwItLEFi.css +0 -1
- package/dist/agent/web/assets/index-DhU_amC3.js +0 -104
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { RingBuffer } from './ring-buffer';
|
|
2
|
+
import { ClaudeCodeAdapter } from './adapters/claude';
|
|
3
|
+
import { OpenCodeAdapter } from './adapters/opencode';
|
|
4
|
+
import { getContainerName } from '../docker';
|
|
5
|
+
import { HOST_WORKSPACE_NAME } from '../shared/client-types';
|
|
6
|
+
const DEFAULT_BUFFER_SIZE = 1000;
|
|
7
|
+
export class SessionManager {
|
|
8
|
+
sessions = new Map();
|
|
9
|
+
clientIdCounter = 0;
|
|
10
|
+
generateSessionId() {
|
|
11
|
+
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
12
|
+
}
|
|
13
|
+
generateClientId() {
|
|
14
|
+
return `client-${++this.clientIdCounter}`;
|
|
15
|
+
}
|
|
16
|
+
createAdapter(agentType) {
|
|
17
|
+
switch (agentType) {
|
|
18
|
+
case 'claude':
|
|
19
|
+
return new ClaudeCodeAdapter();
|
|
20
|
+
case 'opencode':
|
|
21
|
+
return new OpenCodeAdapter();
|
|
22
|
+
case 'codex':
|
|
23
|
+
throw new Error('Codex adapter not yet implemented');
|
|
24
|
+
default:
|
|
25
|
+
throw new Error(`Unknown agent type: ${agentType}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async startSession(options) {
|
|
29
|
+
const sessionId = options.sessionId || this.generateSessionId();
|
|
30
|
+
const existing = this.sessions.get(sessionId);
|
|
31
|
+
if (existing) {
|
|
32
|
+
return sessionId;
|
|
33
|
+
}
|
|
34
|
+
const adapter = this.createAdapter(options.agentType);
|
|
35
|
+
const buffer = new RingBuffer(DEFAULT_BUFFER_SIZE);
|
|
36
|
+
const info = {
|
|
37
|
+
id: sessionId,
|
|
38
|
+
workspaceName: options.workspaceName,
|
|
39
|
+
agentType: options.agentType,
|
|
40
|
+
status: 'idle',
|
|
41
|
+
agentSessionId: options.agentSessionId,
|
|
42
|
+
model: options.model,
|
|
43
|
+
startedAt: new Date(),
|
|
44
|
+
lastActivity: new Date(),
|
|
45
|
+
};
|
|
46
|
+
const session = {
|
|
47
|
+
info,
|
|
48
|
+
adapter,
|
|
49
|
+
buffer,
|
|
50
|
+
clients: new Map(),
|
|
51
|
+
};
|
|
52
|
+
adapter.onMessage((message) => {
|
|
53
|
+
this.handleAdapterMessage(sessionId, message);
|
|
54
|
+
});
|
|
55
|
+
adapter.onStatusChange((status) => {
|
|
56
|
+
this.handleStatusChange(sessionId, status);
|
|
57
|
+
});
|
|
58
|
+
adapter.onError((error) => {
|
|
59
|
+
this.handleAdapterError(sessionId, error);
|
|
60
|
+
});
|
|
61
|
+
const isHost = options.workspaceName === HOST_WORKSPACE_NAME;
|
|
62
|
+
const containerName = isHost ? undefined : getContainerName(options.workspaceName);
|
|
63
|
+
await adapter.start({
|
|
64
|
+
workspaceName: options.workspaceName,
|
|
65
|
+
containerName,
|
|
66
|
+
agentSessionId: options.agentSessionId,
|
|
67
|
+
model: options.model,
|
|
68
|
+
projectPath: options.projectPath,
|
|
69
|
+
isHost,
|
|
70
|
+
});
|
|
71
|
+
this.sessions.set(sessionId, session);
|
|
72
|
+
return sessionId;
|
|
73
|
+
}
|
|
74
|
+
handleAdapterMessage(sessionId, message) {
|
|
75
|
+
const session = this.sessions.get(sessionId);
|
|
76
|
+
if (!session)
|
|
77
|
+
return;
|
|
78
|
+
session.info.lastActivity = new Date();
|
|
79
|
+
const bufferedMessage = {
|
|
80
|
+
id: session.buffer.getLatestId() + 1,
|
|
81
|
+
message,
|
|
82
|
+
timestamp: Date.now(),
|
|
83
|
+
};
|
|
84
|
+
session.buffer.push(bufferedMessage);
|
|
85
|
+
for (const client of session.clients.values()) {
|
|
86
|
+
try {
|
|
87
|
+
client.send(message);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Client send failed, will be cleaned up on disconnect
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
handleStatusChange(sessionId, status) {
|
|
95
|
+
const session = this.sessions.get(sessionId);
|
|
96
|
+
if (!session)
|
|
97
|
+
return;
|
|
98
|
+
session.info.status = status;
|
|
99
|
+
session.info.lastActivity = new Date();
|
|
100
|
+
const previousAgentSessionId = session.info.agentSessionId;
|
|
101
|
+
const currentAgentSessionId = session.adapter.getAgentSessionId();
|
|
102
|
+
if (currentAgentSessionId !== undefined && previousAgentSessionId !== currentAgentSessionId) {
|
|
103
|
+
session.info.agentSessionId = currentAgentSessionId;
|
|
104
|
+
const updateMessage = {
|
|
105
|
+
type: 'system',
|
|
106
|
+
content: JSON.stringify({ agentSessionId: currentAgentSessionId }),
|
|
107
|
+
timestamp: new Date().toISOString(),
|
|
108
|
+
};
|
|
109
|
+
for (const client of session.clients.values()) {
|
|
110
|
+
try {
|
|
111
|
+
client.send(updateMessage);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Client send failed
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
handleAdapterError(sessionId, error) {
|
|
120
|
+
const session = this.sessions.get(sessionId);
|
|
121
|
+
if (!session)
|
|
122
|
+
return;
|
|
123
|
+
session.info.status = 'error';
|
|
124
|
+
session.info.error = error.message;
|
|
125
|
+
session.info.lastActivity = new Date();
|
|
126
|
+
const errorMessage = {
|
|
127
|
+
type: 'error',
|
|
128
|
+
content: error.message,
|
|
129
|
+
timestamp: new Date().toISOString(),
|
|
130
|
+
};
|
|
131
|
+
for (const client of session.clients.values()) {
|
|
132
|
+
try {
|
|
133
|
+
client.send(errorMessage);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Client send failed
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
connectClient(sessionId, sendFn, options) {
|
|
141
|
+
const session = this.sessions.get(sessionId);
|
|
142
|
+
if (!session)
|
|
143
|
+
return null;
|
|
144
|
+
const clientId = this.generateClientId();
|
|
145
|
+
const client = {
|
|
146
|
+
id: clientId,
|
|
147
|
+
send: sendFn,
|
|
148
|
+
};
|
|
149
|
+
session.clients.set(clientId, client);
|
|
150
|
+
if (options?.resumeFromId !== undefined) {
|
|
151
|
+
const missedMessages = session.buffer.getSince(options.resumeFromId);
|
|
152
|
+
for (const buffered of missedMessages) {
|
|
153
|
+
try {
|
|
154
|
+
sendFn(buffered.message);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Failed to send buffered message
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
const allMessages = session.buffer.getAll();
|
|
163
|
+
for (const buffered of allMessages) {
|
|
164
|
+
try {
|
|
165
|
+
sendFn(buffered.message);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Failed to send buffered message
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const statusMessage = {
|
|
173
|
+
type: 'system',
|
|
174
|
+
content: `Connected to session (status: ${session.info.status})`,
|
|
175
|
+
timestamp: new Date().toISOString(),
|
|
176
|
+
};
|
|
177
|
+
try {
|
|
178
|
+
sendFn(statusMessage);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Failed to send status
|
|
182
|
+
}
|
|
183
|
+
return clientId;
|
|
184
|
+
}
|
|
185
|
+
disconnectClient(sessionId, clientId) {
|
|
186
|
+
const session = this.sessions.get(sessionId);
|
|
187
|
+
if (!session)
|
|
188
|
+
return;
|
|
189
|
+
const client = session.clients.get(clientId);
|
|
190
|
+
if (client?.onDisconnect) {
|
|
191
|
+
client.onDisconnect();
|
|
192
|
+
}
|
|
193
|
+
session.clients.delete(clientId);
|
|
194
|
+
}
|
|
195
|
+
async sendMessage(sessionId, message) {
|
|
196
|
+
const session = this.sessions.get(sessionId);
|
|
197
|
+
if (!session) {
|
|
198
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
199
|
+
}
|
|
200
|
+
if (session.info.status === 'running') {
|
|
201
|
+
throw new Error('Session is already processing a message');
|
|
202
|
+
}
|
|
203
|
+
session.info.status = 'running';
|
|
204
|
+
session.info.lastActivity = new Date();
|
|
205
|
+
const userMessage = {
|
|
206
|
+
type: 'user',
|
|
207
|
+
content: message,
|
|
208
|
+
timestamp: new Date().toISOString(),
|
|
209
|
+
};
|
|
210
|
+
this.handleAdapterMessage(sessionId, userMessage);
|
|
211
|
+
await session.adapter.sendMessage(message);
|
|
212
|
+
}
|
|
213
|
+
async interrupt(sessionId) {
|
|
214
|
+
const session = this.sessions.get(sessionId);
|
|
215
|
+
if (!session)
|
|
216
|
+
return;
|
|
217
|
+
await session.adapter.interrupt();
|
|
218
|
+
session.info.status = 'interrupted';
|
|
219
|
+
session.info.lastActivity = new Date();
|
|
220
|
+
}
|
|
221
|
+
getSession(sessionId) {
|
|
222
|
+
const session = this.sessions.get(sessionId);
|
|
223
|
+
return session?.info ?? null;
|
|
224
|
+
}
|
|
225
|
+
findSession(id) {
|
|
226
|
+
// First try direct lookup by internal sessionId
|
|
227
|
+
const direct = this.sessions.get(id);
|
|
228
|
+
if (direct) {
|
|
229
|
+
return { sessionId: id, info: direct.info };
|
|
230
|
+
}
|
|
231
|
+
// Then search by agentSessionId (Claude session ID)
|
|
232
|
+
for (const [sessionId, session] of this.sessions) {
|
|
233
|
+
if (session.info.agentSessionId === id) {
|
|
234
|
+
return { sessionId, info: session.info };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
getSessionStatus(sessionId) {
|
|
240
|
+
const session = this.sessions.get(sessionId);
|
|
241
|
+
return session?.info.status ?? null;
|
|
242
|
+
}
|
|
243
|
+
listActiveSessions(workspaceName) {
|
|
244
|
+
const sessions = [];
|
|
245
|
+
for (const session of this.sessions.values()) {
|
|
246
|
+
if (!workspaceName || session.info.workspaceName === workspaceName) {
|
|
247
|
+
sessions.push(session.info);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return sessions;
|
|
251
|
+
}
|
|
252
|
+
getBufferedMessages(sessionId, sinceId) {
|
|
253
|
+
const session = this.sessions.get(sessionId);
|
|
254
|
+
if (!session)
|
|
255
|
+
return [];
|
|
256
|
+
if (sinceId !== undefined) {
|
|
257
|
+
return session.buffer.getSince(sinceId);
|
|
258
|
+
}
|
|
259
|
+
return session.buffer.getAll();
|
|
260
|
+
}
|
|
261
|
+
async disposeSession(sessionId) {
|
|
262
|
+
const session = this.sessions.get(sessionId);
|
|
263
|
+
if (!session)
|
|
264
|
+
return;
|
|
265
|
+
for (const client of session.clients.values()) {
|
|
266
|
+
if (client.onDisconnect) {
|
|
267
|
+
client.onDisconnect();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
session.clients.clear();
|
|
271
|
+
await session.adapter.dispose();
|
|
272
|
+
this.sessions.delete(sessionId);
|
|
273
|
+
}
|
|
274
|
+
async disposeWorkspaceSessions(workspaceName) {
|
|
275
|
+
const toDispose = [];
|
|
276
|
+
for (const [id, session] of this.sessions) {
|
|
277
|
+
if (session.info.workspaceName === workspaceName) {
|
|
278
|
+
toDispose.push(id);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
await Promise.all(toDispose.map((id) => this.disposeSession(id)));
|
|
282
|
+
}
|
|
283
|
+
async disposeAll() {
|
|
284
|
+
const ids = [...this.sessions.keys()];
|
|
285
|
+
await Promise.all(ids.map((id) => this.disposeSession(id)));
|
|
286
|
+
}
|
|
287
|
+
setModel(sessionId, model) {
|
|
288
|
+
const session = this.sessions.get(sessionId);
|
|
289
|
+
if (!session)
|
|
290
|
+
return;
|
|
291
|
+
session.info.model = model;
|
|
292
|
+
}
|
|
293
|
+
hasActiveClients(sessionId) {
|
|
294
|
+
const session = this.sessions.get(sessionId);
|
|
295
|
+
return session ? session.clients.size > 0 : false;
|
|
296
|
+
}
|
|
297
|
+
getClientCount(sessionId) {
|
|
298
|
+
const session = this.sessions.get(sessionId);
|
|
299
|
+
return session?.clients.size ?? 0;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
export const sessionManager = new SessionManager();
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export class RingBuffer {
|
|
2
|
+
capacity;
|
|
3
|
+
buffer;
|
|
4
|
+
head = 0;
|
|
5
|
+
tail = 0;
|
|
6
|
+
count = 0;
|
|
7
|
+
nextId = 0;
|
|
8
|
+
constructor(capacity) {
|
|
9
|
+
this.capacity = capacity;
|
|
10
|
+
this.buffer = Array.from({ length: capacity });
|
|
11
|
+
}
|
|
12
|
+
push(item) {
|
|
13
|
+
const id = this.nextId++;
|
|
14
|
+
this.buffer[this.tail] = item;
|
|
15
|
+
this.tail = (this.tail + 1) % this.capacity;
|
|
16
|
+
if (this.count < this.capacity) {
|
|
17
|
+
this.count++;
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
this.head = (this.head + 1) % this.capacity;
|
|
21
|
+
}
|
|
22
|
+
return id;
|
|
23
|
+
}
|
|
24
|
+
getAll() {
|
|
25
|
+
const result = [];
|
|
26
|
+
for (let i = 0; i < this.count; i++) {
|
|
27
|
+
const idx = (this.head + i) % this.capacity;
|
|
28
|
+
const item = this.buffer[idx];
|
|
29
|
+
if (item !== undefined) {
|
|
30
|
+
result.push(item);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
getSince(startId) {
|
|
36
|
+
const oldestId = this.nextId - this.count;
|
|
37
|
+
if (startId < oldestId) {
|
|
38
|
+
return this.getAll();
|
|
39
|
+
}
|
|
40
|
+
const result = [];
|
|
41
|
+
const skipCount = startId - oldestId;
|
|
42
|
+
for (let i = skipCount; i < this.count; i++) {
|
|
43
|
+
const idx = (this.head + i) % this.capacity;
|
|
44
|
+
const item = this.buffer[idx];
|
|
45
|
+
if (item !== undefined) {
|
|
46
|
+
result.push(item);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
getLatestId() {
|
|
52
|
+
return this.nextId - 1;
|
|
53
|
+
}
|
|
54
|
+
getOldestId() {
|
|
55
|
+
return this.nextId - this.count;
|
|
56
|
+
}
|
|
57
|
+
size() {
|
|
58
|
+
return this.count;
|
|
59
|
+
}
|
|
60
|
+
clear() {
|
|
61
|
+
this.buffer = Array.from({ length: this.capacity });
|
|
62
|
+
this.head = 0;
|
|
63
|
+
this.tail = 0;
|
|
64
|
+
this.count = 0;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { BaseWebSocketServer, safeSend } from '../shared/base-websocket';
|
|
2
|
+
import { sessionManager } from './manager';
|
|
3
|
+
import { HOST_WORKSPACE_NAME } from '../shared/client-types';
|
|
4
|
+
export class LiveChatWebSocketServer extends BaseWebSocketServer {
|
|
5
|
+
isHostAccessAllowed;
|
|
6
|
+
agentType;
|
|
7
|
+
constructor(options) {
|
|
8
|
+
super(options);
|
|
9
|
+
this.isHostAccessAllowed = options.isHostAccessAllowed || (() => false);
|
|
10
|
+
this.agentType = options.agentType;
|
|
11
|
+
}
|
|
12
|
+
handleConnection(ws, workspaceName) {
|
|
13
|
+
const isHostMode = workspaceName === HOST_WORKSPACE_NAME;
|
|
14
|
+
if (isHostMode && !this.isHostAccessAllowed()) {
|
|
15
|
+
ws.close(4003, 'Host access is disabled');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const connection = {
|
|
19
|
+
ws,
|
|
20
|
+
workspaceName,
|
|
21
|
+
sessionId: null,
|
|
22
|
+
clientId: null,
|
|
23
|
+
agentType: this.agentType,
|
|
24
|
+
};
|
|
25
|
+
this.connections.set(ws, connection);
|
|
26
|
+
safeSend(ws, JSON.stringify({
|
|
27
|
+
type: 'connected',
|
|
28
|
+
workspaceName,
|
|
29
|
+
agentType: this.agentType,
|
|
30
|
+
timestamp: new Date().toISOString(),
|
|
31
|
+
}));
|
|
32
|
+
ws.on('message', async (data) => {
|
|
33
|
+
const str = typeof data === 'string' ? data : data.toString();
|
|
34
|
+
try {
|
|
35
|
+
const message = JSON.parse(str);
|
|
36
|
+
if (message.type === 'connect') {
|
|
37
|
+
await this.handleConnect(connection, ws, workspaceName, message);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (message.type === 'disconnect') {
|
|
41
|
+
this.handleDisconnect(connection);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (message.type === 'interrupt') {
|
|
45
|
+
if (connection.sessionId) {
|
|
46
|
+
await sessionManager.interrupt(connection.sessionId);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (message.type === 'message' && message.content) {
|
|
51
|
+
await this.handleMessage(connection, ws, workspaceName, message);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
safeSend(ws, JSON.stringify({
|
|
56
|
+
type: 'error',
|
|
57
|
+
content: err.message,
|
|
58
|
+
timestamp: new Date().toISOString(),
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
ws.on('close', () => {
|
|
63
|
+
this.handleDisconnect(connection);
|
|
64
|
+
this.connections.delete(ws);
|
|
65
|
+
});
|
|
66
|
+
ws.on('error', (err) => {
|
|
67
|
+
console.error(`Live chat WebSocket error:`, err);
|
|
68
|
+
this.handleDisconnect(connection);
|
|
69
|
+
this.connections.delete(ws);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
async handleConnect(connection, ws, workspaceName, message) {
|
|
73
|
+
const agentType = message.agentType || this.agentType;
|
|
74
|
+
if (message.sessionId) {
|
|
75
|
+
const existingSession = sessionManager.getSession(message.sessionId);
|
|
76
|
+
if (existingSession) {
|
|
77
|
+
connection.sessionId = message.sessionId;
|
|
78
|
+
const sendFn = (msg) => {
|
|
79
|
+
safeSend(ws, JSON.stringify(msg));
|
|
80
|
+
};
|
|
81
|
+
const clientId = sessionManager.connectClient(message.sessionId, sendFn, {
|
|
82
|
+
resumeFromId: message.resumeFromId,
|
|
83
|
+
});
|
|
84
|
+
if (clientId) {
|
|
85
|
+
connection.clientId = clientId;
|
|
86
|
+
safeSend(ws, JSON.stringify({
|
|
87
|
+
type: 'session_joined',
|
|
88
|
+
sessionId: message.sessionId,
|
|
89
|
+
status: existingSession.status,
|
|
90
|
+
agentSessionId: existingSession.agentSessionId,
|
|
91
|
+
timestamp: new Date().toISOString(),
|
|
92
|
+
}));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const sessionId = await sessionManager.startSession({
|
|
98
|
+
workspaceName,
|
|
99
|
+
agentType,
|
|
100
|
+
sessionId: message.sessionId,
|
|
101
|
+
agentSessionId: message.agentSessionId,
|
|
102
|
+
model: message.model,
|
|
103
|
+
projectPath: message.projectPath,
|
|
104
|
+
});
|
|
105
|
+
connection.sessionId = sessionId;
|
|
106
|
+
const sendFn = (msg) => {
|
|
107
|
+
safeSend(ws, JSON.stringify(msg));
|
|
108
|
+
};
|
|
109
|
+
const clientId = sessionManager.connectClient(sessionId, sendFn);
|
|
110
|
+
connection.clientId = clientId;
|
|
111
|
+
safeSend(ws, JSON.stringify({
|
|
112
|
+
type: 'session_started',
|
|
113
|
+
sessionId,
|
|
114
|
+
timestamp: new Date().toISOString(),
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
async handleMessage(connection, ws, workspaceName, message) {
|
|
118
|
+
if (!connection.sessionId) {
|
|
119
|
+
await this.handleConnect(connection, ws, workspaceName, {
|
|
120
|
+
type: 'connect',
|
|
121
|
+
agentType: message.agentType || this.agentType,
|
|
122
|
+
agentSessionId: message.agentSessionId,
|
|
123
|
+
model: message.model,
|
|
124
|
+
projectPath: message.projectPath,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (!connection.sessionId) {
|
|
128
|
+
throw new Error('Failed to create session');
|
|
129
|
+
}
|
|
130
|
+
await sessionManager.sendMessage(connection.sessionId, message.content);
|
|
131
|
+
}
|
|
132
|
+
handleDisconnect(connection) {
|
|
133
|
+
if (connection.sessionId && connection.clientId) {
|
|
134
|
+
sessionManager.disconnectClient(connection.sessionId, connection.clientId);
|
|
135
|
+
connection.clientId = null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
cleanupConnection(connection) {
|
|
139
|
+
this.handleDisconnect(connection);
|
|
140
|
+
}
|
|
141
|
+
closeConnectionsForWorkspace(workspaceName) {
|
|
142
|
+
for (const [ws, connection] of this.connections) {
|
|
143
|
+
if (connection.workspaceName === workspaceName) {
|
|
144
|
+
this.handleDisconnect(connection);
|
|
145
|
+
ws.close(1001, 'Workspace stopped');
|
|
146
|
+
this.connections.delete(ws);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
export function createLiveChatWebSocketServer(options) {
|
|
152
|
+
return new LiveChatWebSocketServer(options);
|
|
153
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { WebSocket } from 'ws';
|
|
3
|
+
const WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
2
4
|
export function safeSend(ws, data) {
|
|
3
5
|
if (ws.readyState !== WebSocket.OPEN) {
|
|
4
6
|
return false;
|
|
@@ -11,14 +13,45 @@ export function safeSend(ws, data) {
|
|
|
11
13
|
return false;
|
|
12
14
|
}
|
|
13
15
|
}
|
|
16
|
+
function manualWebSocketUpgrade(request, socket, head, callback) {
|
|
17
|
+
const key = request.headers['sec-websocket-key'];
|
|
18
|
+
if (!key) {
|
|
19
|
+
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
20
|
+
socket.destroy();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const acceptKey = createHash('sha1')
|
|
24
|
+
.update(key + WEBSOCKET_GUID)
|
|
25
|
+
.digest('base64');
|
|
26
|
+
const responseHeaders = [
|
|
27
|
+
'HTTP/1.1 101 Switching Protocols',
|
|
28
|
+
'Upgrade: websocket',
|
|
29
|
+
'Connection: Upgrade',
|
|
30
|
+
`Sec-WebSocket-Accept: ${acceptKey}`,
|
|
31
|
+
];
|
|
32
|
+
const protocol = request.headers['sec-websocket-protocol'];
|
|
33
|
+
if (protocol) {
|
|
34
|
+
const protocols = protocol.split(',').map((p) => p.trim());
|
|
35
|
+
if (protocols.length > 0) {
|
|
36
|
+
responseHeaders.push(`Sec-WebSocket-Protocol: ${protocols[0]}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
responseHeaders.push('', '');
|
|
40
|
+
socket.write(responseHeaders.join('\r\n'));
|
|
41
|
+
const wsOptions = {
|
|
42
|
+
allowSynchronousEvents: true,
|
|
43
|
+
maxPayload: 100 * 1024 * 1024,
|
|
44
|
+
skipUTF8Validation: false,
|
|
45
|
+
};
|
|
46
|
+
const ws = new WebSocket(null, undefined, wsOptions);
|
|
47
|
+
ws.setSocket(socket, head, wsOptions);
|
|
48
|
+
callback(ws);
|
|
49
|
+
}
|
|
14
50
|
export class BaseWebSocketServer {
|
|
15
|
-
wss;
|
|
16
51
|
connections = new Map();
|
|
17
52
|
isWorkspaceRunning;
|
|
18
53
|
constructor(options) {
|
|
19
|
-
this.wss = new WebSocketServer({ noServer: true });
|
|
20
54
|
this.isWorkspaceRunning = options.isWorkspaceRunning;
|
|
21
|
-
this.wss.on('connection', this.onConnection.bind(this));
|
|
22
55
|
}
|
|
23
56
|
async handleUpgrade(request, socket, head, workspaceName) {
|
|
24
57
|
const running = await this.isWorkspaceRunning(workspaceName);
|
|
@@ -27,9 +60,9 @@ export class BaseWebSocketServer {
|
|
|
27
60
|
socket.end();
|
|
28
61
|
return;
|
|
29
62
|
}
|
|
30
|
-
|
|
63
|
+
manualWebSocketUpgrade(request, socket, head, (ws) => {
|
|
31
64
|
ws.workspaceName = workspaceName;
|
|
32
|
-
this.
|
|
65
|
+
this.onConnection(ws);
|
|
33
66
|
});
|
|
34
67
|
}
|
|
35
68
|
onConnection(ws) {
|
|
@@ -58,6 +91,5 @@ export class BaseWebSocketServer {
|
|
|
58
91
|
ws.close(1001, 'Server shutting down');
|
|
59
92
|
}
|
|
60
93
|
this.connections.clear();
|
|
61
|
-
this.wss.close();
|
|
62
94
|
}
|
|
63
95
|
}
|
package/dist/tailscale/index.js
CHANGED
|
@@ -67,14 +67,28 @@ export async function stopTailscaleServe() {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
export function getTailscaleIdentity(req) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
let login;
|
|
71
|
+
let name;
|
|
72
|
+
let pic;
|
|
73
|
+
if ('headers' in req && req.headers instanceof Headers) {
|
|
74
|
+
login = req.headers.get('tailscale-user-login');
|
|
75
|
+
name = req.headers.get('tailscale-user-name');
|
|
76
|
+
pic = req.headers.get('tailscale-user-profile-pic');
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const nodeReq = req;
|
|
80
|
+
const loginHeader = nodeReq.headers['tailscale-user-login'];
|
|
81
|
+
const nameHeader = nodeReq.headers['tailscale-user-name'];
|
|
82
|
+
const picHeader = nodeReq.headers['tailscale-user-profile-pic'];
|
|
83
|
+
login = Array.isArray(loginHeader) ? loginHeader[0] : loginHeader || null;
|
|
84
|
+
name = Array.isArray(nameHeader) ? nameHeader[0] : nameHeader || null;
|
|
85
|
+
pic = Array.isArray(picHeader) ? picHeader[0] : picHeader || null;
|
|
86
|
+
}
|
|
73
87
|
if (!login)
|
|
74
88
|
return null;
|
|
75
89
|
return {
|
|
76
|
-
email:
|
|
77
|
-
name:
|
|
78
|
-
profilePic:
|
|
90
|
+
email: login,
|
|
91
|
+
name: name || undefined,
|
|
92
|
+
profilePic: pic || undefined,
|
|
79
93
|
};
|
|
80
94
|
}
|